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 待审对照与通过/驳回。 +- [ ] 门店列表操作栏对该门店出现「审核套餐」「对比」按钮,点击同样跳转并自动打开抽屉。 +- [ ] 在审核页完成审核后,返回门店详情-套餐页签,提醒消失(或被事件即时刷新)。 +- [ ] 无待审套餐的门店:列表与详情页签均不出现上述入口。