diff --git a/apps/admin-web/src/pages/StorePackageAuditsPage.tsx b/apps/admin-web/src/pages/StorePackageAuditsPage.tsx index ecdcd9f..5c22607 100644 --- a/apps/admin-web/src/pages/StorePackageAuditsPage.tsx +++ b/apps/admin-web/src/pages/StorePackageAuditsPage.tsx @@ -42,7 +42,44 @@ function imageSignature(pkg: StorePackageItemDto | StorePackageViewDto) { return normalizeStorePackageImageUrls(pkg).join('|'); } -type FieldChange = { label: string; old: string; now: string }; +type FieldChange = { label: string; old: string; now: string; kind: 'text' | 'value' }; + +/** 文本逐字差异:LCS 比对,产出 equal / delete / insert 段落,用于高亮具体变了哪些字 */ +function diffText(a: string, b: string): Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> { + const m = a.length; + const n = b.length; + const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); + for (let i = m - 1; i >= 0; i--) { + for (let j = n - 1; j >= 0; j--) { + dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]); + } + } + const raw: Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> = []; + let i = 0; + let j = 0; + while (i < m && j < n) { + if (a[i] === b[j]) { + raw.push({ type: 'equal', text: a[i] }); + i++; + j++; + } else if (dp[i + 1][j] >= dp[i][j + 1]) { + raw.push({ type: 'delete', text: a[i] }); + i++; + } else { + raw.push({ type: 'insert', text: b[j] }); + j++; + } + } + while (i < m) raw.push({ type: 'delete', text: a[i++] }); + while (j < n) raw.push({ type: 'insert', text: b[j++] }); + const merged: Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> = []; + for (const s of raw) { + const last = merged[merged.length - 1]; + if (last && last.type === s.type) last.text += s.text; + else merged.push({ ...s }); + } + return merged; +} /** 逐字段比较套餐内容,返回发生变化的字段明细(用于“变动的地方详细列出”) */ function fieldChanges( @@ -51,18 +88,21 @@ function fieldChanges( ): FieldChange[] { const changes: FieldChange[] = []; const text = (v: string | number | null | undefined) => (v ?? '').toString().trim(); - const pushIf = (label: string, oldV: string, newV: string) => { - if (oldV !== newV) changes.push({ label, old: oldV || '(空)', now: newV || '(空)' }); + const pushText = (label: string, oldV: string, newV: string) => { + if (oldV !== newV) changes.push({ label, old: oldV, now: newV, kind: 'text' }); }; - pushIf('价格', `¥${text(live.price)}`, `¥${text(proposed.price)}`); - pushIf('套餐名称', text(live.name), text(proposed.name)); - pushIf('菜品内容', text(live.dishes), text(proposed.dishes)); - pushIf('可用时间', text(live.usableTime), text(proposed.usableTime)); - pushIf('其他说明', text(live.otherNotes), text(proposed.otherNotes)); + const pushValue = (label: string, oldV: string, newV: string) => { + if (oldV !== newV) changes.push({ label, old: oldV || '(空)', now: newV || '(空)', kind: 'value' }); + }; + pushValue('价格', `¥${text(live.price)}`, `¥${text(proposed.price)}`); + pushText('套餐名称', text(live.name), text(proposed.name)); + pushText('菜品内容', text(live.dishes), text(proposed.dishes)); + pushText('可用时间', text(live.usableTime), text(proposed.usableTime)); + pushText('其他说明', text(live.otherNotes), text(proposed.otherNotes)); const liveImgs = normalizeStorePackageImageUrls(live); const proposedImgs = normalizeStorePackageImageUrls(proposed); if (imageSignature(live) !== imageSignature(proposed)) { - changes.push({ label: '图片', old: `${liveImgs.length} 张`, now: `${proposedImgs.length} 张` }); + changes.push({ label: '图片', old: `${liveImgs.length} 张`, now: `${proposedImgs.length} 张`, kind: 'value' }); } return changes; } @@ -112,6 +152,43 @@ const CHANGE_LABELS = { unchanged: { text: '未变', color: 'default' }, } as const; +/** 文本逐字差异渲染:原行红色删除线标出被删的字,新行绿色标出新增的字 */ +function TextDiff({ oldText, newText }: { oldText: string; newText: string }) { + const segs = diffText(oldText, newText); + return ( +
+
+ 原: + {segs + .filter((s) => s.type !== 'insert') + .map((s, idx) => + s.type === 'delete' ? ( + + {s.text || '(空)'} + + ) : ( + {s.text} + ), + )} +
+
+ 新: + {segs + .filter((s) => s.type !== 'delete') + .map((s, idx) => + s.type === 'insert' ? ( + + {s.text || '(空)'} + + ) : ( + {s.text} + ), + )} +
+
+ ); +} + function PackageDetailCard({ title, pkg, @@ -198,13 +275,19 @@ function PackageDetailCard({ diff --git a/杜康好客-v3.4.17-门店套餐审核快捷入口.md b/杜康好客-v3.4.17-门店套餐审核快捷入口.md index 01c50e7..00faa15 100644 --- a/杜康好客-v3.4.17-门店套餐审核快捷入口.md +++ b/杜康好客-v3.4.17-门店套餐审核快捷入口.md @@ -50,14 +50,33 @@ | 位置 | 说明 | |------|------| -| `GET /admin/stores` | 列表,新增 `pendingPackageAuditId`(ops/admin-stores.service.ts) | +| `GET /admin/stores` | 列表,新增 `pendingPackageAuditId`(ops/admin/* 透传,无 schema 变更) | | `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/StorePackageAuditsPage.tsx` | `?requestId=` 自动开抽屉 + 变更明细(含 `fieldChanges` / `diffText` / `TextDiff`) | | `apps/admin-web/src/pages/StoresPage.tsx` | 列表操作栏快捷入口 | +## F. 套餐变更对比·逐字段 / 逐字明细 + +组件:`apps/admin-web/src/pages/StorePackageAuditsPage.tsx` + +> 需求:审核抽屉原本只给每条套餐「新增 / 删除 / 变更 / 未变」粗粒度标记,看不出具体哪里变了。本次在「待审核套餐」卡片底部新增**变更明细**块,逐字段列出差异;文本类字段进一步做**逐字(LCS)差异定位**,精确高亮哪些字被增 / 删。 + +- `fieldChanges(live, proposed)`:逐字段比对,产出 `{ label, old, now, kind }` 明细: + - `kind: 'value'`(整体替换展示 `旧 → 新`):**价格**、**图片(N 张 → M 张)**。 + - `kind: 'text'`(走逐字差异):**套餐名称、菜品内容、可用时间、其他说明**。 +- `diffText(a, b)`:LCS 动态规划(`dp[i][j]`)+ 回溯,产出 `equal / delete / insert` 段落并合并相邻同类型;时间/空间 `O(|a|·|b|)`,套餐字段长度可控,无性能风险。 +- `TextDiff({ oldText, newText })`:渲染两行—— + - 「原:」行用**红色删除线**标出被删的字(`delete` 段),其余正常; + - 「新:」行用**绿色**标出新增的字(`insert` 段),其余正常。 + - 一眼定位到具体改动的字,而非整段替换。 +- 抽屉顶部汇总条:**新增 X · 删除 Y · 变更 Z · 未变 W**(由 `diffPackages` 的 `changes` 统计)。 + +### 已知局限 +- 套餐按**名称**(空则按位置)配对;若某条套餐**改名**,会误判为「删除旧名 + 新增新名」而非「变更」,改名本身不会进入逐字明细。如需把改名也识别为「变更」,需升级 `packageKey` 配对策略(名称变了但其余字段相近 → 视为变更)。 + ## 验收 - [ ] 某门店有待审套餐变更时:门店详情-套餐页签顶部出现黄色「有待审核套餐」提醒,且「审核 / 对比」可点。 @@ -65,3 +84,4 @@ - [ ] 门店列表操作栏对该门店出现「审核套餐」「对比」按钮,点击同样跳转并自动打开抽屉。 - [ ] 在审核页完成审核后,返回门店详情-套餐页签,提醒消失(或被事件即时刷新)。 - [ ] 无待审套餐的门店:列表与详情页签均不出现上述入口。 +- [ ] 抽屉中「变更」套餐的「待审核」卡片底部出现「变更明细」:价格/图片以 `旧 → 新` 展示;菜品内容/可用时间/其他说明/套餐名称以**逐字差异**展示(原行红删、新行绿增),能精确定位到具体改动的字。