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 ( +