feat(admin): 套餐变更对比逐字差异定位 + 开发计划并入v3.4.17
CI / verify (push) Has been cancelled

This commit is contained in:
2026-08-16 12:17:34 +08:00
parent bbd7e52e70
commit ecd9973d8f
2 changed files with 120 additions and 17 deletions
@@ -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 (
<div style={{ marginTop: 2 }}>
<div style={{ lineHeight: 1.6 }}>
<Typography.Text type="secondary"></Typography.Text>
{segs
.filter((s) => s.type !== 'insert')
.map((s, idx) =>
s.type === 'delete' ? (
<Typography.Text key={idx} delete style={{ color: '#cf1322' }}>
{s.text || '(空)'}
</Typography.Text>
) : (
<Typography.Text key={idx}>{s.text}</Typography.Text>
),
)}
</div>
<div style={{ lineHeight: 1.6 }}>
<Typography.Text type="secondary"></Typography.Text>
{segs
.filter((s) => s.type !== 'delete')
.map((s, idx) =>
s.type === 'insert' ? (
<Typography.Text key={idx} style={{ color: '#389e0d' }}>
{s.text || '(空)'}
</Typography.Text>
) : (
<Typography.Text key={idx}>{s.text}</Typography.Text>
),
)}
</div>
</div>
);
}
function PackageDetailCard({
title,
pkg,
@@ -198,13 +275,19 @@ function PackageDetailCard({
</Typography.Text>
<ul style={{ margin: '6px 0 0', paddingLeft: 18 }}>
{changes.map((c) => (
<li key={c.label} style={{ marginBottom: 4 }}>
<li key={c.label} style={{ marginBottom: 6 }}>
<Typography.Text type="secondary">{c.label}</Typography.Text>
<Typography.Text delete type="secondary">
{c.old}
</Typography.Text>
<Typography.Text type="secondary"> </Typography.Text>
<Typography.Text strong>{c.now}</Typography.Text>
{c.kind === 'text' ? (
<TextDiff oldText={c.old} newText={c.now} />
) : (
<>
<Typography.Text delete type="secondary">
{c.old}
</Typography.Text>
<Typography.Text type="secondary"> </Typography.Text>
<Typography.Text strong>{c.now}</Typography.Text>
</>
)}
</li>
))}
</ul>