454 lines
16 KiB
TypeScript
454 lines
16 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import { Button, Image, Input, Modal, Space, Tag, Typography, message } from 'antd';
|
||
import type {
|
||
StorePackageAuditDetailDto,
|
||
StorePackageItemDto,
|
||
StorePackageViewDto,
|
||
} from '@dukang/shared-types';
|
||
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
|
||
import { request } from '../lib/api';
|
||
import { notifyPackageAuditChanged } from '../lib/admin-events';
|
||
import { fmtTime } from '../lib/constants';
|
||
|
||
const HQ_PACKAGE_STATUS_LABELS: Record<string, string> = {
|
||
PENDING: '待审核',
|
||
APPROVED: '已通过',
|
||
REJECTED: '已驳回',
|
||
};
|
||
|
||
function packageKey(pkg: StorePackageItemDto | StorePackageViewDto, index: number) {
|
||
const name = String(pkg.name ?? '').trim();
|
||
return name ? `name:${name}` : `idx:${index}`;
|
||
}
|
||
|
||
function imageSignature(pkg: StorePackageItemDto | StorePackageViewDto) {
|
||
return normalizeStorePackageImageUrls(pkg).join('|');
|
||
}
|
||
|
||
type FieldChange = { label: string; old: string; now: string; kind: 'text' | 'value' };
|
||
|
||
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(
|
||
live: StorePackageItemDto | StorePackageViewDto,
|
||
proposed: StorePackageItemDto | StorePackageViewDto,
|
||
): FieldChange[] {
|
||
const changes: FieldChange[] = [];
|
||
const text = (v: string | number | null | undefined) => (v ?? '').toString().trim();
|
||
const pushText = (label: string, oldV: string, newV: string) => {
|
||
if (oldV !== newV) changes.push({ label, old: oldV, now: newV, kind: 'text' });
|
||
};
|
||
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} 张`, kind: 'value' });
|
||
}
|
||
return changes;
|
||
}
|
||
|
||
function diffPackages(live: StorePackageViewDto[], proposed: StorePackageItemDto[]) {
|
||
const liveMap = new Map(live.map((p, i) => [packageKey(p, i), p]));
|
||
const proposedMap = new Map(proposed.map((p, i) => [packageKey(p, i), p]));
|
||
const keys = new Set([...liveMap.keys(), ...proposedMap.keys()]);
|
||
const rows: Array<{
|
||
key: string;
|
||
change: 'added' | 'removed' | 'changed' | 'unchanged';
|
||
live?: StorePackageViewDto;
|
||
proposed?: StorePackageItemDto;
|
||
changes?: FieldChange[];
|
||
}> = [];
|
||
|
||
for (const key of keys) {
|
||
const l = liveMap.get(key);
|
||
const p = proposedMap.get(key);
|
||
if (l && !p) {
|
||
rows.push({ key, change: 'removed', live: l });
|
||
} else if (!l && p) {
|
||
rows.push({ key, change: 'added', proposed: p });
|
||
} else if (l && p) {
|
||
const changed =
|
||
l.price !== p.price ||
|
||
l.dishes !== p.dishes ||
|
||
(l.usableTime ?? '') !== (p.usableTime ?? '') ||
|
||
(l.otherNotes ?? '') !== (p.otherNotes ?? '') ||
|
||
imageSignature(l) !== imageSignature(p);
|
||
rows.push({
|
||
key,
|
||
change: changed ? 'changed' : 'unchanged',
|
||
live: l,
|
||
proposed: p,
|
||
changes: changed ? fieldChanges(l, p) : undefined,
|
||
});
|
||
}
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
const CHANGE_LABELS = {
|
||
added: { text: '新增', color: 'green' },
|
||
removed: { text: '删除', color: 'red' },
|
||
changed: { text: '变更', color: 'orange' },
|
||
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,
|
||
change,
|
||
changes,
|
||
}: {
|
||
title?: string;
|
||
pkg: StorePackageItemDto | StorePackageViewDto;
|
||
change?: keyof typeof CHANGE_LABELS;
|
||
changes?: FieldChange[];
|
||
}) {
|
||
const images = normalizeStorePackageImageUrls(pkg);
|
||
const meta = change ? CHANGE_LABELS[change] : null;
|
||
return (
|
||
<div
|
||
style={{
|
||
marginBottom: 12,
|
||
padding: 12,
|
||
border: '1px solid #f0f0f0',
|
||
borderRadius: 8,
|
||
background: '#fafafa',
|
||
}}
|
||
>
|
||
<Space style={{ marginBottom: 8 }} wrap>
|
||
{title ? <Typography.Text type="secondary">{title}</Typography.Text> : null}
|
||
{meta ? <Tag color={meta.color}>{meta.text}</Tag> : null}
|
||
</Space>
|
||
<div style={{ marginBottom: 8 }}>
|
||
<strong>{pkg.name}</strong>
|
||
<span style={{ marginLeft: 8 }}>¥{pkg.price}</span>
|
||
</div>
|
||
<Typography.Paragraph className="admin-package-audit-text" style={{ marginBottom: 8 }}>
|
||
{pkg.dishes || '—'}
|
||
</Typography.Paragraph>
|
||
{pkg.usableTime ? (
|
||
<Typography.Paragraph type="secondary" className="admin-package-audit-text" style={{ marginBottom: 4 }}>
|
||
可用时间:{pkg.usableTime}
|
||
</Typography.Paragraph>
|
||
) : null}
|
||
{pkg.otherNotes ? (
|
||
<Typography.Paragraph type="secondary" className="admin-package-audit-text" style={{ marginBottom: 8 }}>
|
||
其他说明:{pkg.otherNotes}
|
||
</Typography.Paragraph>
|
||
) : null}
|
||
{images.length ? (
|
||
<Image.PreviewGroup>
|
||
<Space wrap size={8}>
|
||
{images.map((url) => (
|
||
<Image key={url} src={url} width={72} height={72} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
||
))}
|
||
</Space>
|
||
</Image.PreviewGroup>
|
||
) : (
|
||
<Typography.Text type="secondary">无套餐图片</Typography.Text>
|
||
)}
|
||
{changes && changes.length ? (
|
||
<div
|
||
style={{
|
||
marginTop: 8,
|
||
padding: 8,
|
||
background: '#fff7e6',
|
||
border: '1px solid #ffe7ba',
|
||
borderRadius: 6,
|
||
}}
|
||
>
|
||
<Typography.Text strong style={{ fontSize: 12 }}>
|
||
变更明细
|
||
</Typography.Text>
|
||
<ul style={{ margin: '6px 0 0', paddingLeft: 18 }}>
|
||
{changes.map((c) => (
|
||
<li key={c.label} style={{ marginBottom: 6 }}>
|
||
<Typography.Text type="secondary">{c.label}:</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>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export type StorePackageAuditPanelProps = {
|
||
requestId: string;
|
||
/** 是否在面板顶部显示通过/驳回(抽屉 extra 另有按钮时可关) */
|
||
showActions?: boolean;
|
||
onAudited?: () => void;
|
||
onDetailLoaded?: (detail: StorePackageAuditDetailDto | null) => void;
|
||
};
|
||
|
||
/** 套餐变更对比 + 通过/驳回(审核通知页与门店详情抽屉共用) */
|
||
export default function StorePackageAuditPanel({
|
||
requestId,
|
||
showActions = true,
|
||
onAudited,
|
||
onDetailLoaded,
|
||
}: StorePackageAuditPanelProps) {
|
||
const [loading, setLoading] = useState(false);
|
||
const [detail, setDetail] = useState<StorePackageAuditDetailDto | null>(null);
|
||
const [rejectOpen, setRejectOpen] = useState(false);
|
||
const [rejectReason, setRejectReason] = useState('');
|
||
const [auditing, setAuditing] = useState(false);
|
||
|
||
async function load() {
|
||
setLoading(true);
|
||
try {
|
||
const data = await request<StorePackageAuditDetailDto>(`/admin/store-package-audits/${requestId}`);
|
||
setDetail(data);
|
||
onDetailLoaded?.(data);
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '加载套餐审核详情失败');
|
||
setDetail(null);
|
||
onDetailLoaded?.(null);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
void load();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [requestId]);
|
||
|
||
async function audit(action: 'APPROVE' | 'REJECT', reason?: string) {
|
||
if (!detail) return;
|
||
setAuditing(true);
|
||
try {
|
||
await request(`/admin/store-package-audits/${detail.id}/audit`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify(action === 'REJECT' ? { action, rejectReason: reason } : { action }),
|
||
});
|
||
message.success(action === 'APPROVE' ? '套餐已通过' : '套餐已驳回');
|
||
notifyPackageAuditChanged();
|
||
onAudited?.();
|
||
await load();
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '操作失败');
|
||
} finally {
|
||
setAuditing(false);
|
||
}
|
||
}
|
||
|
||
if (loading && !detail) {
|
||
return <Typography.Text type="secondary">加载套餐变更…</Typography.Text>;
|
||
}
|
||
if (!detail) {
|
||
return <Typography.Text type="secondary">暂无套餐审核详情</Typography.Text>;
|
||
}
|
||
|
||
const diffRows = diffPackages(detail.livePackages ?? [], detail.packages ?? []);
|
||
const changeByKey = new Map(diffRows.map((row) => [row.key, row.change]));
|
||
const changesByKey = new Map(diffRows.map((row) => [row.key, row.changes]));
|
||
const addedCount = diffRows.filter((r) => r.change === 'added').length;
|
||
const removedCount = diffRows.filter((r) => r.change === 'removed').length;
|
||
const changedCount = diffRows.filter((r) => r.change === 'changed').length;
|
||
const unchangedCount = diffRows.filter((r) => r.change === 'unchanged').length;
|
||
|
||
return (
|
||
<div>
|
||
<Space style={{ marginBottom: 12, width: '100%', justifyContent: 'space-between' }} wrap>
|
||
<Space wrap>
|
||
<Tag>{HQ_PACKAGE_STATUS_LABELS[detail.status] ?? detail.status}</Tag>
|
||
<Typography.Text type="secondary">
|
||
提交方:{detail.submitterType === 'PARTNER' ? '合伙人' : '门店'} · {fmtTime(detail.createdAt)}
|
||
</Typography.Text>
|
||
</Space>
|
||
{showActions && detail.status === 'PENDING' ? (
|
||
<Space>
|
||
<Button type="primary" loading={auditing} onClick={() => void audit('APPROVE')}>
|
||
通过套餐
|
||
</Button>
|
||
<Button
|
||
danger
|
||
loading={auditing}
|
||
onClick={() => {
|
||
setRejectReason('');
|
||
setRejectOpen(true);
|
||
}}
|
||
>
|
||
驳回套餐
|
||
</Button>
|
||
</Space>
|
||
) : null}
|
||
</Space>
|
||
{detail.rejectReason ? (
|
||
<Typography.Paragraph type="danger">驳回原因:{detail.rejectReason}</Typography.Paragraph>
|
||
) : null}
|
||
<Space direction="vertical" size={4} style={{ marginBottom: 12 }}>
|
||
<Typography.Text type="secondary">
|
||
线上已审核 {detail.livePackages?.length ?? 0} 条 · 待审核 {detail.packages?.length ?? 0} 条
|
||
</Typography.Text>
|
||
<Space wrap>
|
||
<Tag color="green">新增 {addedCount}</Tag>
|
||
<Tag color="red">删除 {removedCount}</Tag>
|
||
<Tag color="orange">变更 {changedCount}</Tag>
|
||
{unchangedCount ? <Tag>未变 {unchangedCount}</Tag> : null}
|
||
</Space>
|
||
</Space>
|
||
<div className="admin-package-audit-cols">
|
||
<div className="admin-package-audit-col">
|
||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||
线上已审核套餐
|
||
</Typography.Title>
|
||
{(detail.livePackages ?? []).length ? (
|
||
(detail.livePackages ?? []).map((pkg, index) => (
|
||
<PackageDetailCard
|
||
key={`live-${packageKey(pkg, index)}`}
|
||
title={`套餐 ${index + 1}`}
|
||
pkg={pkg}
|
||
change={changeByKey.get(packageKey(pkg, index))}
|
||
/>
|
||
))
|
||
) : (
|
||
<Typography.Text type="secondary">暂无线上套餐</Typography.Text>
|
||
)}
|
||
</div>
|
||
<div className="admin-package-audit-col">
|
||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||
待审核套餐
|
||
</Typography.Title>
|
||
{(detail.packages ?? []).length ? (
|
||
(detail.packages ?? []).map((pkg, index) => (
|
||
<PackageDetailCard
|
||
key={`pending-${packageKey(pkg, index)}`}
|
||
title={`套餐 ${index + 1}`}
|
||
pkg={pkg}
|
||
change={changeByKey.get(packageKey(pkg, index))}
|
||
changes={changesByKey.get(packageKey(pkg, index))}
|
||
/>
|
||
))
|
||
) : (
|
||
<Typography.Text type="secondary">暂无待审核套餐</Typography.Text>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<Modal
|
||
title="驳回套餐变更"
|
||
open={rejectOpen}
|
||
confirmLoading={auditing}
|
||
onCancel={() => setRejectOpen(false)}
|
||
onOk={() => {
|
||
if (!rejectReason.trim()) {
|
||
message.warning('请填写驳回原因');
|
||
return;
|
||
}
|
||
void audit('REJECT', rejectReason.trim()).then(() => setRejectOpen(false));
|
||
}}
|
||
>
|
||
<Input.TextArea
|
||
rows={3}
|
||
value={rejectReason}
|
||
placeholder="驳回原因"
|
||
onChange={(e) => setRejectReason(e.target.value)}
|
||
/>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 供门店详情抽屉 extra 调用 */
|
||
export async function auditStorePackageRequest(
|
||
requestId: string,
|
||
action: 'APPROVE' | 'REJECT',
|
||
rejectReason?: string,
|
||
) {
|
||
await request(`/admin/store-package-audits/${requestId}/audit`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify(action === 'REJECT' ? { action, rejectReason } : { action }),
|
||
});
|
||
notifyPackageAuditChanged();
|
||
}
|