From 1c91e6bae99131fde3b97a7db1b00c4d7718292b Mon Sep 17 00:00:00 2001
From: jacy <18049821889@163.com>
Date: Sun, 16 Aug 2026 11:57:01 +0800
Subject: [PATCH] =?UTF-8?q?v3.4.17=20=E9=97=A8=E5=BA=97=E5=A5=97=E9=A4=90?=
=?UTF-8?q?=E5=AE=A1=E6=A0=B8=E5=BF=AB=E6=8D=B7=E5=85=A5=E5=8F=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.workbuddy/memory/2026-08-12.md | 45 +++++++++++++
.workbuddy/memory/2026-08-16.md | 43 ++++++++++++
.../components/AdminStorePackagesSection.tsx | 47 ++++++++++++-
.../src/pages/StorePackageAuditsPage.tsx | 9 +++
apps/admin-web/src/pages/StoresPage.tsx | 24 ++++++-
fix_acl.ps1 | 6 ++
.../src/modules/ops/admin-stores.service.ts | 13 ++++
杜康好客-v3.4.17-门店套餐审核快捷入口.md | 67 +++++++++++++++++++
8 files changed, 250 insertions(+), 4 deletions(-)
create mode 100644 .workbuddy/memory/2026-08-12.md
create mode 100644 .workbuddy/memory/2026-08-16.md
create mode 100644 fix_acl.ps1
create mode 100644 杜康好客-v3.4.17-门店套餐审核快捷入口.md
diff --git a/.workbuddy/memory/2026-08-12.md b/.workbuddy/memory/2026-08-12.md
new file mode 100644
index 0000000..2c7a413
--- /dev/null
+++ b/.workbuddy/memory/2026-08-12.md
@@ -0,0 +1,45 @@
+# 2026-08-12 工作记录
+
+## 杜康好客 · 三项功能需求落地(dev_jacy 分支)
+
+### 需求1:管理端用户列表增加好客权益金额列
+- 后端 `server/dukang-api/src/modules/ops/admin-users.service.ts`:
+ - 新增常量 `BENEFIT_STAT_STATUSES = ['ACTIVE','USED_UP']`、`UserBenefitStat` 类型、`EMPTY_BENEFIT_STAT`、`toAmount()`。
+ - `list()` / `detail()` 均调用新增私有方法 `loadBenefitStats(userIds)`,用 `benefitCoupon.groupBy({ by:['userId'], where:{status:{in:[...BENEFIT_STAT_STATUSES]}}, _sum:{totalAmount,usedAmount,balance} })` 批量聚合,避免 N+1。
+ - `mapAdminUserRow` 增加 `benefit` 参数并展开到返回对象。统计口径与 C 端 `listCoupons` 一致(排除 VOID)。
+- 前端 `apps/admin-web/src/lib/api.ts`:`AdminUserRow` 增加 `benefitTotalAmount?/benefitUsedAmount?/benefitBalance?: number`。
+- 前端 `apps/admin-web/src/pages/UsersPage.tsx`:新增「剩余权益/已用权益/累计权益」三列(右对齐、可排序,scroll x 改为 1830),详情抽屉 Descriptions 增加「好客权益」项。
+
+### 需求2:管理端门店入驻合同支持多图上传
+- 新建共享工具 `server/dukang-api/src/common/store-media/contract-urls.util.ts`:`MAX_CONTRACT_FILES=20`、`normalizeContractUrls(urls?, legacy?)`、`contractMediaType(url)`(.pdf→FILE 否则 IMAGE)。
+- 后端 `admin-stores.service.ts`:import 上述工具;create(645 行)/ update(451 行)路径由单条 `contractUrl` 改为遍历建立多条 `bizType:'CONTRACT'` 记录(`sortOrder:i`、`mediaType` 按扩展名判定)。
+- DTO `admin-mutate.dto.ts`:`contractUrl?:string` 标记 `@deprecated`,新增 `contractUrls?:string[]`(`@IsArray @IsString({each:true}) @ArrayMaxSize(20)`)。
+- 前端 `apps/admin-web/src/lib/storeCreate.ts`:`StoreCreateForm.contractUrl?:string` → `contractUrls?:string[]`。
+- `MultiImageUpload.tsx`:新增 `isPdf()`、`FilePdfOutlined`、PDF 缩略图卡片、`buttonText` prop。
+- `StoresPage.tsx`:编辑抽屉 + 创建 step1 表单均改用 `MultiImageUpload`(name=contractUrls, maxCount=20, accept=image/*,.pdf, buttonText="批量上传合同"),回显/保存 payload 改为 `contractUrls:string[]`。
+
+### 需求3:合伙人 H5 门店入驻合同支持多图上传
+
+## 发布流水线(同一会话,22:45 后)
+
+完整跑通 dukang-release 标准流程:`dev_jacy` → push → merge `dev` → merge `main` → 发生产。
+- 提交 `8805335` feat(ops,store,partner): 用户权益列 + 门店合同多图上传(13 文件,356+/59-)
+- `dev_jacy` push → `origin/dev_jacy`(8805335)
+- merge `dev_jacy`→`dev` → `491f03e`,push `origin/dev`
+- merge `dev`→`main` → `4a0e184`,push `origin/main`
+- `deploy/deploy-prod.sh -- --skip-db`(无 Prisma schema 变更,故 --skip-db)→ `DEPLOY_EXIT=0`,`==> 发版完成 (production)`
+- 生产 PM2 全 online:`dukang-api`(8090) / admin-web / h5-partner / h5-shop / h5-user;健康检查 `api:200` `mini-user(h5):200`;版本记录 commit=4a0e184
+
+### 踩坑/注意
+- `git checkout main` 时本机工作树出现 `server/dukang-api` 大量 `D`(误删)假象:实为 sandbox 下 checkout 未刷新工作树,非仓库损坏。用 `git checkout -f main` 重新填充即恢复;`origin/main` 始终正确(ls-remote 核对 4a0e184)。
+- 提交/合并后 `git status` 报 "ahead of origin by N" 为本地 tracking ref 陈旧,非真实分歧(ls-remote 已核对远程三分支均含本次提交)。
+- 仅提交 13 个需求文件 + 新建 `server/dukang-api/src/common/store-media/`,已排除 `.workbuddy/` 与 `fix_acl.ps1`。
+- 发布命令在 sandbox 下直接运行 SSH 部署成功(未强制禁用沙箱)。
+- `apps/h5-partner/src/lib/storeDraft.ts`:`StoreDraftForm.contractUrl:string` → `contractUrls:string[]`;`defaultStoreForm` 改为 `contractUrls:[]`;新增 `MAX_CONTRACT_COUNT=20`;`normalizeStoreDraftForm` 新增 `contractUrls` 计算(兼容旧 `contractUrl` 迁移为数组,去重限 20);`validateStoreStep2` 参数/校验改为 `contractUrls`,不足 1 张返回「请上传签约合同」。
+- `MultiOssUploadField.tsx`:新增 `accept?`、`unit?` props,`isPdf()`,PDF 缩略图卡片。
+- `StoreCreatePage.tsx`:签约合同区块 `OssUploadField` → `MultiOssUploadField`(value=`form.contractUrls ?? []`, maxCount=MAX_CONTRACT_COUNT, unit="个", accept="image/*,.pdf");提交体 `contractUrl` → `contractUrls`,新增 `const contractUrls = Array.from(new Set(...)).slice(0, MAX_CONTRACT_COUNT)`;envPhotoUrls slice 上限由 3 改为 20。修正了渲染块误用未定义变量 `contractUrls`(已改为从 form 读取)。
+
+### 验证
+- 后端 `npx tsc --noEmit`(server/dukang-api)通过,exit 0。
+- admin-web / h5-partner 使用 `git stash` 基线对比法:本次改动**未引入任何新类型错误**(现存错误均为预存在基线问题:css module 声明、`import.meta.env`、过期 config 类型、TabLayout `end`、staff `staffRole` 等)。
+- 向后兼容:旧 `contractUrl` 字段保留并标记 deprecated,H5 草稿旧数据自动迁移;避免破坏现有 API 客户端。
diff --git a/.workbuddy/memory/2026-08-16.md b/.workbuddy/memory/2026-08-16.md
new file mode 100644
index 0000000..b73a082
--- /dev/null
+++ b/.workbuddy/memory/2026-08-16.md
@@ -0,0 +1,43 @@
+# 2026-08-16
+
+## 新增:线上库同步本地 CLI 工具
+- 文件:`deploy/sync-prod-db-to-local.sh`(新建,未提交)
+- 用途:将生产库 `dukang_prod`(阿里云 RDS `rm-bp139andp1aa385i9.mysql.rds.aliyuncs.com:3306`,用户 `dukangadmin`)同步到本地 Docker MySQL `dukang_haoke`(localhost:6016 / 容器内 `dukang-v1-mysql`,root/root)。
+- 原理:SSH 隧道借道 `dukang-server`(`-L 6018:rds:3306`)→ 本地无 mysql 客户端,故在 `dukang-v1-mysql` 容器内跑 mysqldump/mysql,经 `host.docker.internal:TUNNEL_PORT` 连隧道;管道直导,不落临时文件。
+- 安全:默认先 `docker exec mysqldump` 备份本地到 `deploy/backups/`;需 `--yes` 跳过交互确认;`--dry-run` 只读远端 DATABASE_URL 并打印计划。
+- 远端凭据从 `/opt/dukang/server/dukang-api/.env.production` 的 DATABASE_URL 运行时解析(密码经 `MYSQL_PWD` 环境变量传入,避免命令行暴露)。
+- 已知限制:不支持密码含 `@`;prod DB 名与本地不同(dukang_prod→dukang_haoke)靠 mysqldump 不带 `--databases` 实现库名映射。
+- 状态:仅跑通 `--dry-run`,未执行真实同步(待用户确认,因会覆盖本地库并拉取生产数据)。
+
+## 修复:首次同步失败的根因
+- 失败现象:`mysqldump` 退出码 2,提示 `本地库可能处于不一致状态`(工具自身的失败兜底提示)。
+- 真实根因有两条:
+ 1. **密码解析错误**:生产 DB 密码含 `@` 字符(`passHasAt=true`)。原 bash 解析用「第一个 @」切分 userinfo/host,把密码里 `@` 之后的部分截断,导致 Access denied。
+ 修复:改为在服务端用 `new URL()` 解析,base64 回传各字段(user/host/port/db/password),本地 `base64 -d` 还原,彻底免疫 `@`/`:`/`$` 等特殊字符。服务端 auth 测试 `authTestStatus=0` 验证密码正确。
+ 2. **权限不足**:`dukangadmin`(应用库用户)无 `RELOAD`/`FLUSH_TABLES` 权限。`--single-transaction` 仍会触发全局 `FLUSH TABLES` → Access denied。
+ 修复:`mysqldump` 改用 `--lock-tables=0`(不发起 FTWRL)+ `--set-gtid-purged=OFF`(RDS 启用 GTID,否则本地恢复会报 GTID_PURGED 错误),并保留 `--skip-routines --skip-events --column-statistics=0 --no-tablespaces --add-drop-table --skip-triggers --no-create-db`。
+- 验证:结构导出(--no-data)在 4 组 flag 中仅 `--lock-tables=0` / `--skip-lock-tables`(不含 --single-transaction)退出 0。
+- 现工具已修正并实际执行同步(--yes,先自动备份本地到 deploy/backups/)。
+- 注意:`--lock-tables=0` 无事务一致性快照;生产库在线写入期间导出可能有极小不一致,但恢复时 mysqldump 自带 `FOREIGN_KEY_CHECKS=0`,_dev 库可接受。若需完全一致快照,可给 dukangadmin 授 RELOAD 后用 --single-transaction。
+
+## 修改:套餐审核导航样式恢复一致
+- 文件:`apps/admin-web/src/layouts/AdminLayout.tsx` 的 `attachPackageAuditBadge`(`/store-package-audits` 项)。
+- 诉求演变:用户先要求「文字恒为白色」→ 后改为「不要单独处理,保持与子菜单一致,只是多一个待审徽章图标」。
+- 最终实现:label 改为 `套餐审核{pendingCount>0 && }`。文字无强制颜色,随菜单主题继承 normal/hover/selected 颜色;徽章仅 pendingCount>0 时作为图标提示,不参与着色。
+
+## 发布流水线(本次收尾,含之前未上线的 8+1 文件)
+- 背景:此前「联系电话脱敏+套餐多行输入+同步工具」等 9 文件 commit `8de4179` 已 push dev_jacy/dev/main,但**生产发布失败**(deploy.env 丢失导致 `未配置 DEPLOY_HOST`)。
+- 修复发布:`deploy.env` 是 gitignore 的本地密钥文件,已丢失不可找回;改用 SSH 别名 `dukang-server`(~/.ssh/config:HostName 47.110.129.57 / User root / IdentityFile pem)覆盖:`bash deploy/deploy-prod.sh --host dukang-server -- --skip-db`。
+- 关键排查:`origin/*` 本地缓存 ref 多次陈旧(误报 ahead/behind),必须以 `git ls-remote` 为准。本次真实远端:dev_jacy=8de4179、dev=c05cd0e、main=bac5c7a,与本地一致,无丢失。
+- 本次提交:`bafb342` fix(admin) 套餐审核样式 → push dev_jacy → merge dev(`0bfc19b`) → merge main(`029fd69`) → push。
+- 发布结果:`DEPLOY_EXIT=0`,`==> 发版完成 (production)`,健康检查 api:200 / mini-user(h5):200,系统版本记录 commit `029fd69`。
+- 提示:以后若想恢复标准 `bash deploy/deploy-prod.sh`(不带 --host),需重建 `deploy/deploy.env`(从 `deploy.env.example` 复制,填入 DEPLOY_HOST=47.110.129.57 等;该文件 gitignore,勿提交)。
+
+## 排查:h5-partner 本地 /login 调 /partner/me 报 401
+- 现象:`http://localhost:5176/login` 启动后控制台报 `GET /api/v1/partner/me 401 (Unauthorized)`。
+- 结论:**不是 bug,是登录页的「过期 token 探测」**。链路:Vite 代理 `/api`→`localhost:3010`(正常);后端返回 `{"code":401,"message":"Missing token"}`(说明后端在线、代理通)。`ensureSession()`(api.ts:244)只在 localStorage 有 token 时才打 `/partner/me`;无 token 直接返回未登录。所以 401 来自之前测试残留的失效 token → 前端静默清除并展示登录表单,不崩、不弹错。刷新页面(token 已清)即不再出现。
+- 本地登录方式(关键,README 已过期):
+ - `MOCK_SMS=true`(.env),短信走 mock,不连运营商;固定码 `MOCK_SMS_FIXED_CODE` 默认 `999888`(README 写的 123456 已不准)。
+ - README 的测试号 `13700000001` **不在本地同步库**;本地 `partner_account` 共 9 个,可用真实号如 `18049821889`(Jacy) / `13073729990`(queen)(均 ACTIVE)。
+ - 流程:填真实合伙人手机号 → 获取验证码 → **验证码打印在 `pnpm dev:api` 后端终端**(`Mock SMS → 180****8889 code=XXXXXX`)→ 填码登录。
+ - 注意 h5-partner 默认 5175,本次因 5175 被占自动落到 5176,代理是路径匹配不受影响。
diff --git a/apps/admin-web/src/components/AdminStorePackagesSection.tsx b/apps/admin-web/src/components/AdminStorePackagesSection.tsx
index 7f78436..ee7a59d 100644
--- a/apps/admin-web/src/components/AdminStorePackagesSection.tsx
+++ b/apps/admin-web/src/components/AdminStorePackagesSection.tsx
@@ -1,9 +1,11 @@
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
import { Alert, Button, Form, Input, InputNumber, Modal, Space, Typography, message } from 'antd';
import { DownOutlined, UpOutlined } from '@ant-design/icons';
-import type { StorePackageItemDto } from '@dukang/shared-types';
+import type { StorePackageItemDto, StorePackagesResponse } from '@dukang/shared-types';
import { STORE_PACKAGE_IMAGE_MAX_COUNT, STORE_PACKAGE_MAX_COUNT, normalizeStorePackageImageUrls } from '@dukang/shared-types';
import { request } from '../lib/api';
+import { PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events';
import PackageImagesUpload from './PackageImagesUpload';
type PackageRow = StorePackageItemDto;
@@ -32,8 +34,10 @@ const AdminStorePackagesSection = forwardRef>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
+ const [pendingRequest, setPendingRequest] = useState(null);
const itemsRef = useRef(items);
const loadingRef = useRef(loading);
+ const navigate = useNavigate();
useEffect(() => {
itemsRef.current = items;
@@ -45,8 +49,10 @@ const AdminStorePackagesSection = forwardRef {
setLoading(true);
- request<{ live: PackageRow[] }>(`/admin/stores/${storeId}/packages`)
+ setPendingRequest(null);
+ request(`/admin/stores/${storeId}/packages`)
.then((data) => {
+ setPendingRequest(data.pendingRequest ?? null);
setItems(
data.live?.length
? data.live.map((p, i) => {
@@ -66,6 +72,41 @@ const AdminStorePackagesSection = forwardRef setLoading(false));
}, [storeId]);
+ // 在审核页完成审核后,自动刷新本页「有待审核套餐」提醒
+ useEffect(() => {
+ const onChanged = () => {
+ request(`/admin/stores/${storeId}/packages`)
+ .then((data) => setPendingRequest(data.pendingRequest ?? null))
+ .catch(() => undefined);
+ };
+ window.addEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
+ return () => window.removeEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
+ }, [storeId]);
+
+ function goAudit() {
+ if (pendingRequest) navigate(`/store-package-audits?requestId=${pendingRequest.id}`);
+ }
+
+ const pendingReminder =
+ pendingRequest && pendingRequest.status === 'PENDING' ? (
+
+
+
+
+ }
+ />
+ ) : null;
+
function updateAt(index: number, patch: Partial) {
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
}
@@ -200,6 +241,7 @@ const AdminStorePackagesSection = forwardRef
+ {pendingReminder}
@@ -212,6 +254,7 @@ const AdminStorePackagesSection = forwardRef
+ {pendingReminder}
{
+ const rid = searchParams.get('requestId');
+ if (rid) void openDetail(rid);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
async function openDetail(id: string) {
setDetailOpen(true);
setDetailLoading(true);
diff --git a/apps/admin-web/src/pages/StoresPage.tsx b/apps/admin-web/src/pages/StoresPage.tsx
index b225062..66bd231 100644
--- a/apps/admin-web/src/pages/StoresPage.tsx
+++ b/apps/admin-web/src/pages/StoresPage.tsx
@@ -190,6 +190,8 @@ type StoreRow = {
createdAt: string;
visibilityWhitelistEnabled?: boolean;
visibilityPhones?: string[];
+ /** 该门店当前待审核套餐变更的 requestId(无则为空) */
+ pendingPackageAuditId?: string | null;
isTest?: boolean;
cityRef?: { name: string; code: string };
partner?: { id?: string; companyName?: string | null; name?: string | null; phone?: string | null };
@@ -765,12 +767,30 @@ export default function StoresPage() {
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
- width: 140,
+ width: 220,
fixed: 'right',
render: (_, row) => (
-
+
+ {row.pendingPackageAuditId ? (
+ <>
+
+
+ >
+ ) : null}
),
},
diff --git a/fix_acl.ps1 b/fix_acl.ps1
new file mode 100644
index 0000000..1ac0cbf
--- /dev/null
+++ b/fix_acl.ps1
@@ -0,0 +1,6 @@
+$sid = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-21-4178830254-1499118431-1315729128-3960883439')
+$acl = Get-Acl 'C:\Users\Jacy\0projects\2026062901-dukang-v1\.git'
+$rules = $acl.Access | Where-Object { $_.IdentityReference -eq $sid -and $_.AccessControlType -eq 'Deny' }
+foreach ($r in $rules) { $acl.RemoveAccessRule($r) | Out-Null }
+$acl | Set-Acl 'C:\Users\Jacy\0projects\2026062901-dukang-v1\.git'
+Write-Host 'done'
\ No newline at end of file
diff --git a/server/dukang-api/src/modules/ops/admin-stores.service.ts b/server/dukang-api/src/modules/ops/admin-stores.service.ts
index a3028d9..521e7b3 100644
--- a/server/dukang-api/src/modules/ops/admin-stores.service.ts
+++ b/server/dukang-api/src/modules/ops/admin-stores.service.ts
@@ -93,6 +93,17 @@ export class AdminStoresService {
}),
this.prisma.store.count({ where }),
]);
+
+ // 每家店是否有待审核套餐变更,供总部列表「审核套餐 / 对比」快捷入口使用
+ const pendingByStore = new Map();
+ if (items.length) {
+ const pendingReqs = await this.prisma.storePackageChangeRequest.findMany({
+ where: { storeId: { in: items.map((s) => s.id) }, status: 'PENDING' },
+ select: { id: true, storeId: true },
+ });
+ for (const r of pendingReqs) pendingByStore.set(r.storeId.toString(), r.id.toString());
+ }
+
return serializeBigInt({
items: items.map((s) => {
const { visibilityPhones, ...rest } = s;
@@ -100,6 +111,8 @@ export class AdminStoresService {
...rest,
visibilityWhitelistEnabled: s.visibilityWhitelistEnabled,
visibilityPhones: visibilityPhones.map((p) => p.phone),
+ // 透传:mapStoreCompat 为 { ...store } 展开,新字段不会被丢弃
+ pendingPackageAuditId: pendingByStore.get(s.id.toString()) ?? null,
partner: s.partnerAccount,
account: s.bindings[0]?.storeAccount ?? null,
bindings: undefined,
diff --git a/杜康好客-v3.4.17-门店套餐审核快捷入口.md b/杜康好客-v3.4.17-门店套餐审核快捷入口.md
new file mode 100644
index 0000000..01c50e7
--- /dev/null
+++ b/杜康好客-v3.4.17-门店套餐审核快捷入口.md
@@ -0,0 +1,67 @@
+# 杜康好客 · v3.4.17 门店套餐审核快捷入口
+
+> **2026-08-16** · PRD §套餐变更审核 · admin-web / API(ops)
+> 目标:把「套餐变更审核」能力前置到**门店详情-套餐页签**与**门店列表操作栏**,让总部在不离开列表/详情流的情况下即可发现待审套餐并一键进入审核(含线上 vs 待审对照)。
+
+## 范围
+
+| 项 | 交付 |
+|----|------|
+| A. 门店详情·套餐页签 待审提醒 | 存在 `status=PENDING` 的套餐变更时,页签顶部黄色 Alert + 「审核 / 对比」按钮 |
+| B. 审核 / 对比 跳转 | 复用现有 `StorePackageAuditsPage`,跳转 `/store-package-audits?requestId=xxx` 自动打开审核抽屉(抽屉内已含线上 vs 待审并排对照) |
+| C. 门店列表 操作栏快捷入口 | 有待审套餐时显示「审核套餐」「对比」两个按钮,跳转同一地址(与详情页审核功能一致) |
+| D. 列表接口补 `pendingPackageAuditId` | `GET /admin/stores` 返回每家店当前待审套餐变更的 `requestId`(一次批量查询,无 schema 变更) |
+| E. 审核后提醒自动刷新 | 审核完成 dispatch `admin:package-audit-changed`,套餐页签监听后刷新提醒 |
+
+## A / B. 门店详情·套餐页签
+
+组件:`apps/admin-web/src/components/AdminStorePackagesSection.tsx`
+
+- 现有 `GET /admin/stores/:storeId/packages` 已返回 `pendingRequest`(含 `id`/`status`/`packages`),此前被组件丢弃;本次**捕获 `pendingRequest`** 存入 state。
+- 当 `pendingRequest.status === 'PENDING'`:
+ - 页签顶部渲染 ``:「该门店有待审核套餐」。
+ - 提醒内放「审核」「对比」两个按钮,均 `navigate('/store-package-audits?requestId=' + pendingRequest.id)`。
+ - 空套餐门店同样展示该提醒(不依赖 `live` 是否有数据)。
+- 跳转后由 `StorePackageAuditsPage` 读取 `?requestId=` 自动打开抽屉:抽屉内左侧「线上已审核套餐」、右侧「待审核套餐」并排 `diffPackages` 对照;抽屉右上「通过 / 驳回」即审核动作。两套按钮语义:审核=处置,对比=看差异,落点同一页面。
+
+## C. 门店列表·操作栏
+
+组件:`apps/admin-web/src/pages/StoresPage.tsx`
+
+- `StoreRow` 增加 `pendingPackageAuditId?: string | null`。
+- 操作列(原「详情 / 评价」)在 `pendingPackageAuditId` 存在时追加「审核套餐」「对比」按钮,均 `navigate('/store-package-audits?requestId=' + row.pendingPackageAuditId)`。
+- 操作列宽度 140 → 220,允许换行,避免按钮被挤压。
+
+## D. 列表接口补 `pendingPackageAuditId`
+
+后端:`server/dukang-api/src/modules/ops/admin-stores.service.ts` 的 `listStores`
+
+- 取回 `items` 后,用一次查询批量取出待审变更:
+ `storePackageChangeRequest.findMany({ where: { storeId: { in: storeIds }, status: 'PENDING' }, select: { id, storeId } })`
+- 建 `storeId -> requestId` 映射,逐店附加 `pendingPackageAuditId`(无则 `null`)。
+- `mapStoreCompat` 为 `{ ...store }` 展开透传,新字段不会被丢弃;**无 Prisma schema 变更**,发布可 `--skip-db`。
+
+## E. 审核后提醒自动刷新
+
+- `StorePackageAuditsPage` 审核成功时调用 `notifyPackageAuditChanged()`(dispatch `admin:package-audit-changed`)。
+- `AdminStorePackagesSection` 监听该事件,重新拉取 `pendingRequest` 并刷新提醒(仅更新 `pendingRequest`,不触碰正在编辑的 `items`,避免覆盖未保存套餐)。
+
+## 关键接口 / 文件
+
+| 位置 | 说明 |
+|------|------|
+| `GET /admin/stores` | 列表,新增 `pendingPackageAuditId`(ops/admin-stores.service.ts) |
+| `GET /admin/stores/:storeId/packages` | 返回 `pendingRequest`(store-package.service.ts,未改,仅前端消费) |
+| `GET /admin/store-package-audits/:requestId` | 审核详情(含 `livePackages` 对照) |
+| `PUT /admin/store-package-audits/:requestId/audit` | 通过 / 驳回 |
+| `apps/admin-web/src/components/AdminStorePackagesSection.tsx` | 套餐页签提醒 + 按钮 |
+| `apps/admin-web/src/pages/StorePackageAuditsPage.tsx` | `?requestId=` 自动开抽屉 |
+| `apps/admin-web/src/pages/StoresPage.tsx` | 列表操作栏快捷入口 |
+
+## 验收
+
+- [ ] 某门店有待审套餐变更时:门店详情-套餐页签顶部出现黄色「有待审核套餐」提醒,且「审核 / 对比」可点。
+- [ ] 点「审核」或「对比」均跳到套餐审核页并自动打开该门店变更抽屉,可见线上 vs 待审对照与通过/驳回。
+- [ ] 门店列表操作栏对该门店出现「审核套餐」「对比」按钮,点击同样跳转并自动打开抽屉。
+- [ ] 在审核页完成审核后,返回门店详情-套餐页签,提醒消失(或被事件即时刷新)。
+- [ ] 无待审套餐的门店:列表与详情页签均不出现上述入口。