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 = { 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 (
原: {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, 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 (
{title ? {title} : null} {meta ? {meta.text} : null}
{pkg.name} ¥{pkg.price}
{pkg.dishes || '—'} {pkg.usableTime ? ( 可用时间:{pkg.usableTime} ) : null} {pkg.otherNotes ? ( 其他说明:{pkg.otherNotes} ) : null} {images.length ? ( {images.map((url) => ( ))} ) : ( 无套餐图片 )} {changes && changes.length ? (
变更明细
    {changes.map((c) => (
  • {c.label}: {c.kind === 'text' ? ( ) : ( <> {c.old} {c.now} )}
  • ))}
) : null}
); } 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(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(`/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 加载套餐变更…; } if (!detail) { return 暂无套餐审核详情; } 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 (
{HQ_PACKAGE_STATUS_LABELS[detail.status] ?? detail.status} 提交方:{detail.submitterType === 'PARTNER' ? '合伙人' : '门店'} · {fmtTime(detail.createdAt)} {showActions && detail.status === 'PENDING' ? ( ) : null} {detail.rejectReason ? ( 驳回原因:{detail.rejectReason} ) : null} 线上已审核 {detail.livePackages?.length ?? 0} 条 · 待审核 {detail.packages?.length ?? 0} 条 新增 {addedCount} 删除 {removedCount} 变更 {changedCount} {unchangedCount ? 未变 {unchangedCount} : null}
线上已审核套餐 {(detail.livePackages ?? []).length ? ( (detail.livePackages ?? []).map((pkg, index) => ( )) ) : ( 暂无线上套餐 )}
待审核套餐 {(detail.packages ?? []).length ? ( (detail.packages ?? []).map((pkg, index) => ( )) ) : ( 暂无待审核套餐 )}
setRejectOpen(false)} onOk={() => { if (!rejectReason.trim()) { message.warning('请填写驳回原因'); return; } void audit('REJECT', rejectReason.trim()).then(() => setRejectOpen(false)); }} > setRejectReason(e.target.value)} />
); } /** 供门店详情抽屉 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(); }