@@ -0,0 +1,453 @@
|
||||
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();
|
||||
}
|
||||
@@ -20,20 +20,15 @@ import type {
|
||||
StoreInfoChangeFieldDiff,
|
||||
StoreInfoChangeRequestDto,
|
||||
StoreInfoChangeStatus,
|
||||
StorePackageAuditDetailDto,
|
||||
StorePackageAuditSummaryDto,
|
||||
StorePackageChangeRequestDto,
|
||||
StorePackageChangeStatus,
|
||||
StorePackageItemDto,
|
||||
StorePackageViewDto,
|
||||
} from '@dukang/shared-types';
|
||||
import {
|
||||
STORE_INFO_CHANGE_STATUS_LABELS,
|
||||
normalizeStorePackageImageUrls,
|
||||
} from '@dukang/shared-types';
|
||||
import { STORE_INFO_CHANGE_STATUS_LABELS } from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { notifyPackageAuditChanged } from '../lib/admin-events';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import StorePackageAuditPanel from '../components/StorePackageAuditPanel';
|
||||
|
||||
const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
|
||||
PENDING: '待审核',
|
||||
@@ -41,270 +36,6 @@ const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
|
||||
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' };
|
||||
|
||||
/** 文本逐字差异: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(
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const INFO_CHANGE_FIELD_LABELS: Record<string, string> = {
|
||||
name: '门店名称',
|
||||
contactPhone: '联系电话',
|
||||
@@ -318,6 +49,8 @@ const INFO_CHANGE_FIELD_LABELS: Record<string, string> = {
|
||||
openTime2: '第二段开始',
|
||||
closeTime2: '第二段结束',
|
||||
avgPrice: '人均费用',
|
||||
coverUrl: '门头照',
|
||||
envPhotoUrls: '环境照片',
|
||||
};
|
||||
|
||||
function fmtFieldValue(field: string, v: unknown): string {
|
||||
@@ -325,9 +58,73 @@ function fmtFieldValue(field: string, v: unknown): string {
|
||||
if (field === 'avgPrice' || field === 'latitude' || field === 'longitude') {
|
||||
return String(v);
|
||||
}
|
||||
if (field === 'envPhotoUrls' && Array.isArray(v)) {
|
||||
return `${v.length} 张`;
|
||||
}
|
||||
if (field === 'coverUrl') return '见对比图';
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function InfoChangeImageDiff({
|
||||
field,
|
||||
live,
|
||||
proposed,
|
||||
}: {
|
||||
field: string;
|
||||
live: unknown;
|
||||
proposed: unknown;
|
||||
}) {
|
||||
const liveUrls =
|
||||
field === 'coverUrl'
|
||||
? [String(live || '').trim()].filter(Boolean)
|
||||
: Array.isArray(live)
|
||||
? live.map((u) => String(u || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
const proposedUrls =
|
||||
field === 'coverUrl'
|
||||
? [String(proposed || '').trim()].filter(Boolean)
|
||||
: Array.isArray(proposed)
|
||||
? proposed.map((u) => String(u || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 6 }}>
|
||||
变更前
|
||||
</Typography.Text>
|
||||
{liveUrls.length ? (
|
||||
<Image.PreviewGroup>
|
||||
<Space wrap size={8}>
|
||||
{liveUrls.map((url) => (
|
||||
<Image key={`live-${url}`} src={url} width={72} height={72} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
||||
))}
|
||||
</Space>
|
||||
</Image.PreviewGroup>
|
||||
) : (
|
||||
<Typography.Text type="secondary">(空)</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 6 }}>
|
||||
变更后
|
||||
</Typography.Text>
|
||||
{proposedUrls.length ? (
|
||||
<Image.PreviewGroup>
|
||||
<Space wrap size={8}>
|
||||
{proposedUrls.map((url) => (
|
||||
<Image key={`new-${url}`} src={url} width={72} height={72} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
||||
))}
|
||||
</Space>
|
||||
</Image.PreviewGroup>
|
||||
) : (
|
||||
<Typography.Text type="secondary">(空)</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoChangeAuditPanel({
|
||||
initialRequestId,
|
||||
}: {
|
||||
@@ -544,15 +341,19 @@ function InfoChangeAuditPanel({
|
||||
key={d.field}
|
||||
label={INFO_CHANGE_FIELD_LABELS[d.field] ?? d.field}
|
||||
>
|
||||
<span>
|
||||
<Typography.Text delete type="secondary">
|
||||
{fmtFieldValue(d.field, d.live)}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary"> → </Typography.Text>
|
||||
<Typography.Text strong>
|
||||
{fmtFieldValue(d.field, d.proposed)}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
{d.field === 'coverUrl' || d.field === 'envPhotoUrls' ? (
|
||||
<InfoChangeImageDiff field={d.field} live={d.live} proposed={d.proposed} />
|
||||
) : (
|
||||
<span>
|
||||
<Typography.Text delete type="secondary">
|
||||
{fmtFieldValue(d.field, d.live)}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary"> → </Typography.Text>
|
||||
<Typography.Text strong>
|
||||
{fmtFieldValue(d.field, d.proposed)}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
@@ -599,8 +400,8 @@ export default function StorePackageAuditsPage() {
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [detail, setDetail] = useState<StorePackageAuditDetailDto | null>(null);
|
||||
const [activeRequestId, setActiveRequestId] = useState<string | null>(null);
|
||||
const [drawerTitle, setDrawerTitle] = useState('套餐变更详情');
|
||||
|
||||
async function reload(nextPage = page, nextStatus = status) {
|
||||
setLoading(true);
|
||||
@@ -629,30 +430,23 @@ export default function StorePackageAuditsPage() {
|
||||
void reload(1, status);
|
||||
}, [status]);
|
||||
|
||||
// 从门店详情 / 门店列表跳转过来时,带 requestId 自动打开审核(对比)抽屉
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialTab = searchParams.get('tab') === 'info' ? 'info' : 'package';
|
||||
const [activeTab, setActiveTab] = useState<string>(initialTab);
|
||||
const infoRequestId = searchParams.get('infoRequestId');
|
||||
useEffect(() => {
|
||||
const rid = searchParams.get('requestId');
|
||||
if (rid) void openDetail(rid);
|
||||
if (rid) {
|
||||
setActiveRequestId(rid);
|
||||
setDetailOpen(true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
async function openDetail(id: string) {
|
||||
function openDetail(id: string) {
|
||||
setActiveRequestId(id);
|
||||
setDrawerTitle('套餐变更详情');
|
||||
setDetailOpen(true);
|
||||
setDetailLoading(true);
|
||||
setDetail(null);
|
||||
try {
|
||||
const data = await request<StorePackageAuditDetailDto>(`/admin/store-package-audits/${id}`);
|
||||
setDetail(data);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载详情失败');
|
||||
setDetailOpen(false);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function audit(id: string, action: 'APPROVE' | 'REJECT', reason?: string) {
|
||||
@@ -672,14 +466,6 @@ export default function StorePackageAuditsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const diffRows = detail ? 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;
|
||||
|
||||
const columns: ColumnsType<StorePackageChangeRequestDto> = [
|
||||
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
|
||||
{
|
||||
@@ -702,7 +488,7 @@ export default function StorePackageAuditsPage() {
|
||||
title: '操作',
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button type="link" onClick={() => void openDetail(row.id)}>
|
||||
<Button type="link" onClick={() => openDetail(row.id)}>
|
||||
查看变更
|
||||
</Button>
|
||||
{row.status === 'PENDING' ? (
|
||||
@@ -781,90 +567,23 @@ export default function StorePackageAuditsPage() {
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
title={detail ? `${detail.storeName || detail.storeId} · 套餐变更` : '套餐变更详情'}
|
||||
title={drawerTitle}
|
||||
width={880}
|
||||
open={detailOpen}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
extra={
|
||||
detail?.status === 'PENDING' ? (
|
||||
<Space>
|
||||
<Button onClick={() => void audit(detail.id, 'APPROVE')}>通过</Button>
|
||||
<Button
|
||||
danger
|
||||
onClick={() => {
|
||||
setActiveId(detail.id);
|
||||
setRejectReason('');
|
||||
setRejectOpen(true);
|
||||
}}
|
||||
>
|
||||
驳回
|
||||
</Button>
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
destroyOnClose
|
||||
>
|
||||
{detailLoading ? (
|
||||
<Typography.Text type="secondary">加载中…</Typography.Text>
|
||||
) : detail ? (
|
||||
<>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Tag>{HQ_PACKAGE_STATUS_LABELS[detail.status]}</Tag>
|
||||
<Typography.Text type="secondary">
|
||||
提交方:{detail.submitterType === 'PARTNER' ? '合伙人' : '门店'} · {fmtTime(detail.createdAt)}
|
||||
</Typography.Text>
|
||||
</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>
|
||||
</>
|
||||
{activeRequestId ? (
|
||||
<StorePackageAuditPanel
|
||||
requestId={activeRequestId}
|
||||
onAudited={() => {
|
||||
setDetailOpen(false);
|
||||
void reload(page, status);
|
||||
}}
|
||||
onDetailLoaded={(d) => {
|
||||
if (d) setDrawerTitle(`${d.storeName || d.storeId} · 套餐变更`);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Drawer>
|
||||
|
||||
@@ -892,3 +611,4 @@ export default function StorePackageAuditsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Descriptions,
|
||||
@@ -46,6 +47,9 @@ import TencentLocPickerModal from '../components/TencentLocPickerModal';
|
||||
import AdminStorePackagesSection, {
|
||||
type AdminStorePackagesHandle,
|
||||
} from '../components/AdminStorePackagesSection';
|
||||
import StorePackageAuditPanel, {
|
||||
auditStorePackageRequest,
|
||||
} from '../components/StorePackageAuditPanel';
|
||||
|
||||
const CREATE_STEPS = [
|
||||
{ title: '基本信息' },
|
||||
@@ -270,7 +274,11 @@ export default function StoresPage() {
|
||||
const [filterPartners, setFilterPartners] = useState<PartnerOption[]>([]);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [detailTab, setDetailTab] = useState('basic');
|
||||
const packagesRef = useRef<AdminStorePackagesHandle>(null);
|
||||
const [packageRejectOpen, setPackageRejectOpen] = useState(false);
|
||||
const [packageRejectReason, setPackageRejectReason] = useState('');
|
||||
const [packageAuditing, setPackageAuditing] = useState(false);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [auditing, setAuditing] = useState(false);
|
||||
@@ -348,13 +356,18 @@ export default function StoresPage() {
|
||||
.map((n) => ({ value: n.id, label: n.name }));
|
||||
}, [categoryTree, editCategoryParentId]);
|
||||
|
||||
async function openStoreDetail(row: StoreRow) {
|
||||
async function openStoreDetail(row: StoreRow, opts?: { tab?: string }) {
|
||||
const [d, cats] = await Promise.all([
|
||||
request<Record<string, unknown>>(`/admin/stores/${row.id}`),
|
||||
request<CategoryNode[]>('/admin/store-categories').catch(() => [] as CategoryNode[]),
|
||||
]);
|
||||
setCategoryTree(Array.isArray(cats) ? cats : []);
|
||||
setDetail(d);
|
||||
setDetail({
|
||||
...d,
|
||||
pendingPackageAuditId: row.pendingPackageAuditId ?? d.pendingPackageAuditId,
|
||||
pendingInfoChangeId: row.pendingInfoChangeId ?? d.pendingInfoChangeId,
|
||||
});
|
||||
setDetailTab(opts?.tab || 'basic');
|
||||
const category = d.category && typeof d.category === 'object'
|
||||
? (d.category as { id?: string; parentId?: string | null })
|
||||
: null;
|
||||
@@ -776,22 +789,13 @@ export default function StoresPage() {
|
||||
<Button type="link" size="small" onClick={() => void openStoreDetail(row)}>详情</Button>
|
||||
<Button type="link" size="small" onClick={() => navigate(`/store-ratings?storeId=${row.id}`)}>评价</Button>
|
||||
{row.pendingPackageAuditId ? (
|
||||
<>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => navigate(`/store-package-audits?requestId=${row.pendingPackageAuditId}`)}
|
||||
>
|
||||
审核套餐
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => navigate(`/store-package-audits?requestId=${row.pendingPackageAuditId}`)}
|
||||
>
|
||||
对比
|
||||
</Button>
|
||||
</>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => void openStoreDetail(row, { tab: 'packages' })}
|
||||
>
|
||||
审核套餐
|
||||
</Button>
|
||||
) : null}
|
||||
{row.pendingInfoChangeId ? (
|
||||
<>
|
||||
@@ -903,7 +907,7 @@ export default function StoresPage() {
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Drawer title="门店详情" width={760} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
<Drawer title="门店详情" width={880} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={detail && (
|
||||
<Space wrap>
|
||||
{String(detail.auditStatus || '') === 'PENDING' || String(detail.auditStatus || '') === 'REJECTED' ? (
|
||||
@@ -940,6 +944,41 @@ export default function StoresPage() {
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{detail.pendingPackageAuditId ? (
|
||||
<>
|
||||
<Button
|
||||
type="primary"
|
||||
ghost
|
||||
loading={packageAuditing}
|
||||
onClick={async () => {
|
||||
setPackageAuditing(true);
|
||||
try {
|
||||
await auditStorePackageRequest(String(detail.pendingPackageAuditId), 'APPROVE');
|
||||
message.success('套餐已通过');
|
||||
setDetail({ ...detail, pendingPackageAuditId: null });
|
||||
void reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '套餐审核失败');
|
||||
} finally {
|
||||
setPackageAuditing(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
通过套餐
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
ghost
|
||||
loading={packageAuditing}
|
||||
onClick={() => {
|
||||
setPackageRejectReason('');
|
||||
setPackageRejectOpen(true);
|
||||
}}
|
||||
>
|
||||
驳回套餐
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Select value={String(detail.status)} style={{ width: 120 }}
|
||||
options={Object.entries(STORE_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
onChange={async (status) => {
|
||||
@@ -954,6 +993,8 @@ export default function StoresPage() {
|
||||
{detail && (
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Tabs
|
||||
activeKey={detailTab}
|
||||
onChange={setDetailTab}
|
||||
destroyInactiveTabPane={false}
|
||||
items={[
|
||||
{
|
||||
@@ -1202,15 +1243,76 @@ export default function StoresPage() {
|
||||
},
|
||||
{
|
||||
key: 'packages',
|
||||
label: '套餐',
|
||||
label: detail.pendingPackageAuditId ? (
|
||||
<Badge dot offset={[4, 0]}>
|
||||
套餐
|
||||
</Badge>
|
||||
) : (
|
||||
'套餐'
|
||||
),
|
||||
forceRender: true,
|
||||
children: <AdminStorePackagesSection ref={packagesRef} storeId={String(detail.id)} />,
|
||||
children: (
|
||||
<>
|
||||
{detail.pendingPackageAuditId ? (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||
待审核套餐变更
|
||||
</Typography.Title>
|
||||
<StorePackageAuditPanel
|
||||
requestId={String(detail.pendingPackageAuditId)}
|
||||
showActions
|
||||
onAudited={() => {
|
||||
setDetail({ ...detail, pendingPackageAuditId: null });
|
||||
void reload();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<AdminStorePackagesSection ref={packagesRef} storeId={String(detail.id)} />
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Form>
|
||||
)}
|
||||
</Drawer>
|
||||
<Modal
|
||||
title="驳回套餐变更"
|
||||
open={packageRejectOpen}
|
||||
confirmLoading={packageAuditing}
|
||||
onCancel={() => setPackageRejectOpen(false)}
|
||||
onOk={async () => {
|
||||
if (!detail?.pendingPackageAuditId) return;
|
||||
if (!packageRejectReason.trim()) {
|
||||
message.warning('请填写驳回原因');
|
||||
return;
|
||||
}
|
||||
setPackageAuditing(true);
|
||||
try {
|
||||
await auditStorePackageRequest(
|
||||
String(detail.pendingPackageAuditId),
|
||||
'REJECT',
|
||||
packageRejectReason.trim(),
|
||||
);
|
||||
message.success('套餐已驳回');
|
||||
setPackageRejectOpen(false);
|
||||
setDetail({ ...detail, pendingPackageAuditId: null });
|
||||
void reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '套餐驳回失败');
|
||||
} finally {
|
||||
setPackageAuditing(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
value={packageRejectReason}
|
||||
placeholder="驳回原因"
|
||||
onChange={(e) => setPackageRejectReason(e.target.value)}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title="新建门店"
|
||||
open={createOpen}
|
||||
|
||||
@@ -249,15 +249,70 @@ export default function SystemSettingsPage() {
|
||||
|
||||
const collapseItems = useMemo(() => {
|
||||
if (!meta) return [];
|
||||
return meta.groups.map((group) => ({
|
||||
key: group.key,
|
||||
label: group.label,
|
||||
forceRender: true,
|
||||
children: (
|
||||
<div style={{ maxWidth: 720 }}>
|
||||
{meta.fields
|
||||
.filter((f) => f.group === group.key)
|
||||
.map((f) =>
|
||||
const SHARE_SUBGROUP_LABELS: Record<string, string> = {
|
||||
global: '全局',
|
||||
home: '首页',
|
||||
stores: '门店列表',
|
||||
storeDetail: '门店详情',
|
||||
benefit: '权益页',
|
||||
mine: '我的',
|
||||
productDetail: '商品详情',
|
||||
orderDetail: '订单详情',
|
||||
};
|
||||
const SHARE_SUBGROUP_ORDER = [
|
||||
'global',
|
||||
'home',
|
||||
'stores',
|
||||
'storeDetail',
|
||||
'benefit',
|
||||
'mine',
|
||||
'productDetail',
|
||||
'orderDetail',
|
||||
];
|
||||
|
||||
return meta.groups.map((group) => {
|
||||
const fields = meta.fields.filter((f) => f.group === group.key);
|
||||
const hasSubgroups = fields.some((f) => f.subgroup);
|
||||
|
||||
let children: ReactNode;
|
||||
if (group.key === 'wechat_mini_share' && hasSubgroups) {
|
||||
const bySub = new Map<string, SystemConfigFieldMeta[]>();
|
||||
for (const f of fields) {
|
||||
const sk = f.subgroup || 'global';
|
||||
if (!bySub.has(sk)) bySub.set(sk, []);
|
||||
bySub.get(sk)!.push(f);
|
||||
}
|
||||
const orderedKeys = [
|
||||
...SHARE_SUBGROUP_ORDER.filter((k) => bySub.has(k)),
|
||||
...[...bySub.keys()].filter((k) => !SHARE_SUBGROUP_ORDER.includes(k)),
|
||||
];
|
||||
children = (
|
||||
<Collapse
|
||||
defaultActiveKey={[]}
|
||||
items={orderedKeys.map((sk) => ({
|
||||
key: sk,
|
||||
label: SHARE_SUBGROUP_LABELS[sk] || sk,
|
||||
forceRender: true,
|
||||
children: (
|
||||
<div style={{ maxWidth: 720 }}>
|
||||
{(bySub.get(sk) ?? []).map((f) =>
|
||||
renderField(
|
||||
f,
|
||||
meta.configuredSecrets,
|
||||
f.key === 'MOCK_SMS' && mockSmsEnabled ? (
|
||||
<MockSmsCodePanel codes={meta.mockSmsCodes ?? []} loading={loading} />
|
||||
) : undefined,
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
children = (
|
||||
<div style={{ maxWidth: 720 }}>
|
||||
{fields.map((f) =>
|
||||
renderField(
|
||||
f,
|
||||
meta.configuredSecrets,
|
||||
@@ -266,9 +321,17 @@ export default function SystemSettingsPage() {
|
||||
) : undefined,
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
key: group.key,
|
||||
label: group.label,
|
||||
forceRender: true,
|
||||
children,
|
||||
};
|
||||
});
|
||||
}, [meta, mockSmsEnabled, loading]);
|
||||
|
||||
async function onSave() {
|
||||
|
||||
Reference in New Issue
Block a user