feat(admin,store): landline contact phone and package audit UX
Allow store contactPhone as landline, show pending package audit badge, and split live vs pending packages in HQ review. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Drawer,
|
||||
Image,
|
||||
@@ -14,17 +15,23 @@ import {
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type {
|
||||
StorePackageAuditDetailDto,
|
||||
StorePackageAuditSummaryDto,
|
||||
StorePackageChangeRequestDto,
|
||||
StorePackageChangeStatus,
|
||||
StorePackageItemDto,
|
||||
StorePackageViewDto,
|
||||
} from '@dukang/shared-types';
|
||||
import {
|
||||
STORE_PACKAGE_CHANGE_STATUS_LABELS,
|
||||
normalizeStorePackageImageUrls,
|
||||
} from '@dukang/shared-types';
|
||||
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { notifyPackageAuditChanged } from '../lib/admin-events';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
|
||||
const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
|
||||
PENDING: '待审核',
|
||||
APPROVED: '已通过',
|
||||
REJECTED: '已驳回',
|
||||
};
|
||||
|
||||
function packageKey(pkg: StorePackageItemDto | StorePackageViewDto, index: number) {
|
||||
const name = String(pkg.name ?? '').trim();
|
||||
return name ? `name:${name}` : `idx:${index}`;
|
||||
@@ -75,11 +82,14 @@ const CHANGE_LABELS = {
|
||||
function PackageDetailCard({
|
||||
title,
|
||||
pkg,
|
||||
change,
|
||||
}: {
|
||||
title?: string;
|
||||
pkg: StorePackageItemDto | StorePackageViewDto;
|
||||
change?: keyof typeof CHANGE_LABELS;
|
||||
}) {
|
||||
const images = normalizeStorePackageImageUrls(pkg);
|
||||
const meta = change ? CHANGE_LABELS[change] : null;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -90,27 +100,36 @@ function PackageDetailCard({
|
||||
background: '#fafafa',
|
||||
}}
|
||||
>
|
||||
{title ? (
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
{title}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
<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 style={{ marginBottom: 8, whiteSpace: 'pre-wrap' }}>
|
||||
<Typography.Paragraph className="admin-package-audit-text" style={{ marginBottom: 8 }}>
|
||||
{pkg.dishes || '—'}
|
||||
</Typography.Paragraph>
|
||||
{pkg.usableTime ? (
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<Typography.Text type="secondary">可用时间:{pkg.usableTime}</Typography.Text>
|
||||
</div>
|
||||
<Typography.Paragraph
|
||||
type="secondary"
|
||||
className="admin-package-audit-text"
|
||||
style={{ marginBottom: 4 }}
|
||||
>
|
||||
可用时间:{pkg.usableTime}
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
{pkg.otherNotes ? (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Typography.Text type="secondary">其他说明:{pkg.otherNotes}</Typography.Text>
|
||||
</div>
|
||||
<Typography.Paragraph
|
||||
type="secondary"
|
||||
className="admin-package-audit-text"
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
其他说明:{pkg.otherNotes}
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
{images.length ? (
|
||||
<Image.PreviewGroup>
|
||||
@@ -133,64 +152,19 @@ function PackageDetailCard({
|
||||
);
|
||||
}
|
||||
|
||||
function PackageSummaryCell({ pkg }: { pkg?: StorePackageItemDto | StorePackageViewDto }) {
|
||||
if (!pkg) return <>—</>;
|
||||
const images = normalizeStorePackageImageUrls(pkg);
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<strong>{pkg.name}</strong> · ¥{pkg.price}
|
||||
</div>
|
||||
<Typography.Text type="secondary">{pkg.dishes}</Typography.Text>
|
||||
{pkg.usableTime ? (
|
||||
<div>
|
||||
<Typography.Text type="secondary">可用:{pkg.usableTime}</Typography.Text>
|
||||
</div>
|
||||
) : null}
|
||||
{pkg.otherNotes ? (
|
||||
<div>
|
||||
<Typography.Text type="secondary">备注:{pkg.otherNotes}</Typography.Text>
|
||||
</div>
|
||||
) : null}
|
||||
{images.length ? (
|
||||
<Image.PreviewGroup>
|
||||
<Space wrap size={4} style={{ marginTop: 8 }}>
|
||||
{images.slice(0, 4).map((url) => (
|
||||
<Image
|
||||
key={url}
|
||||
src={url}
|
||||
width={48}
|
||||
height={48}
|
||||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||
/>
|
||||
))}
|
||||
{images.length > 4 ? (
|
||||
<Typography.Text type="secondary">+{images.length - 4}</Typography.Text>
|
||||
) : null}
|
||||
</Space>
|
||||
</Image.PreviewGroup>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StorePackageAuditsPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [items, setItems] = useState<StorePackageChangeRequestDto[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [status, setStatus] = useState<string>('PENDING');
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
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 [pkgDetailOpen, setPkgDetailOpen] = useState(false);
|
||||
const [pkgDetailTitle, setPkgDetailTitle] = useState('');
|
||||
const [pkgDetailList, setPkgDetailList] = useState<Array<StorePackageItemDto | StorePackageViewDto>>(
|
||||
[],
|
||||
);
|
||||
|
||||
async function reload(nextPage = page, nextStatus = status) {
|
||||
setLoading(true);
|
||||
@@ -200,12 +174,14 @@ export default function StorePackageAuditsPage() {
|
||||
pageSize: '20',
|
||||
});
|
||||
if (nextStatus) qs.set('status', nextStatus);
|
||||
const data = await request<Paginated<StorePackageChangeRequestDto>>(
|
||||
`/admin/store-package-audits?${qs}`,
|
||||
);
|
||||
const [data, summary] = await Promise.all([
|
||||
request<Paginated<StorePackageChangeRequestDto>>(`/admin/store-package-audits?${qs}`),
|
||||
request<StorePackageAuditSummaryDto>('/admin/store-package-audits/summary'),
|
||||
]);
|
||||
setItems(data.items);
|
||||
setTotal(data.total);
|
||||
setPage(data.page);
|
||||
setPendingCount(summary.pendingCount ?? 0);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
@@ -232,15 +208,6 @@ export default function StorePackageAuditsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function openPackageDetails(
|
||||
title: string,
|
||||
list: Array<StorePackageItemDto | StorePackageViewDto>,
|
||||
) {
|
||||
setPkgDetailTitle(title);
|
||||
setPkgDetailList(list);
|
||||
setPkgDetailOpen(true);
|
||||
}
|
||||
|
||||
async function audit(id: string, action: 'APPROVE' | 'REJECT', reason?: string) {
|
||||
try {
|
||||
await request(`/admin/store-package-audits/${id}/audit`, {
|
||||
@@ -251,6 +218,7 @@ export default function StorePackageAuditsPage() {
|
||||
});
|
||||
message.success(action === 'APPROVE' ? '已通过' : '已驳回');
|
||||
setDetailOpen(false);
|
||||
notifyPackageAuditChanged();
|
||||
void reload(page, status);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '操作失败');
|
||||
@@ -258,26 +226,7 @@ export default function StorePackageAuditsPage() {
|
||||
}
|
||||
|
||||
const diffRows = detail ? diffPackages(detail.livePackages ?? [], detail.packages ?? []) : [];
|
||||
|
||||
const diffColumns: ColumnsType<(typeof diffRows)[number]> = [
|
||||
{
|
||||
title: '变更',
|
||||
dataIndex: 'change',
|
||||
width: 72,
|
||||
render: (v: keyof typeof CHANGE_LABELS) => {
|
||||
const meta = CHANGE_LABELS[v];
|
||||
return <Tag color={meta.color}>{meta.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '当前线上',
|
||||
render: (_, row) => <PackageSummaryCell pkg={row.live} />,
|
||||
},
|
||||
{
|
||||
title: '申请变更',
|
||||
render: (_, row) => <PackageSummaryCell pkg={row.proposed} />,
|
||||
},
|
||||
];
|
||||
const changeByKey = new Map(diffRows.map((row) => [row.key, row.change]));
|
||||
|
||||
const columns: ColumnsType<StorePackageChangeRequestDto> = [
|
||||
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
|
||||
@@ -285,7 +234,7 @@ export default function StorePackageAuditsPage() {
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
render: (v: StorePackageChangeRequestDto['status']) => (
|
||||
<Tag>{STORE_PACKAGE_CHANGE_STATUS_LABELS[v] ?? v}</Tag>
|
||||
<Tag>{HQ_PACKAGE_STATUS_LABELS[v] ?? v}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -335,7 +284,15 @@ export default function StorePackageAuditsPage() {
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
{(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => (
|
||||
<Button key={s || 'all'} type={status === s ? 'primary' : 'default'} onClick={() => setStatus(s)}>
|
||||
{s ? STORE_PACKAGE_CHANGE_STATUS_LABELS[s as keyof typeof STORE_PACKAGE_CHANGE_STATUS_LABELS] : '全部'}
|
||||
{s === 'PENDING' ? (
|
||||
<Badge count={pendingCount} size="small" offset={[8, -2]}>
|
||||
{HQ_PACKAGE_STATUS_LABELS.PENDING}
|
||||
</Badge>
|
||||
) : s ? (
|
||||
HQ_PACKAGE_STATUS_LABELS[s]
|
||||
) : (
|
||||
'全部'
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
@@ -380,7 +337,7 @@ export default function StorePackageAuditsPage() {
|
||||
) : detail ? (
|
||||
<>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Tag>{STORE_PACKAGE_CHANGE_STATUS_LABELS[detail.status]}</Tag>
|
||||
<Tag>{HQ_PACKAGE_STATUS_LABELS[detail.status]}</Tag>
|
||||
<Typography.Text type="secondary">
|
||||
提交方:{detail.submitterType === 'PARTNER' ? '合伙人' : '门店'} · {fmtTime(detail.createdAt)}
|
||||
</Typography.Text>
|
||||
@@ -388,55 +345,49 @@ export default function StorePackageAuditsPage() {
|
||||
{detail.rejectReason ? (
|
||||
<Typography.Paragraph type="danger">驳回原因:{detail.rejectReason}</Typography.Paragraph>
|
||||
) : null}
|
||||
<Space style={{ marginBottom: 12 }} wrap>
|
||||
<Typography.Text type="secondary">
|
||||
线上 {detail.livePackages?.length ?? 0} 条 → 申请 {detail.packages?.length ?? 0} 条
|
||||
</Typography.Text>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() =>
|
||||
openPackageDetails('申请套餐详情', detail.packages ?? [])
|
||||
}
|
||||
>
|
||||
查看申请套餐详情
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() =>
|
||||
openPackageDetails('线上套餐详情', detail.livePackages ?? [])
|
||||
}
|
||||
>
|
||||
查看线上套餐详情
|
||||
</Button>
|
||||
</Space>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="key"
|
||||
columns={diffColumns}
|
||||
dataSource={diffRows}
|
||||
pagination={false}
|
||||
/>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||
线上已审核 {detail.livePackages?.length ?? 0} 条 · 待审核 {detail.packages?.length ?? 0} 条
|
||||
</Typography.Paragraph>
|
||||
<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))}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Typography.Text type="secondary">暂无待审核套餐</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title={pkgDetailTitle || '套餐详情'}
|
||||
open={pkgDetailOpen}
|
||||
onCancel={() => setPkgDetailOpen(false)}
|
||||
footer={null}
|
||||
width={720}
|
||||
destroyOnClose
|
||||
>
|
||||
{pkgDetailList.length ? (
|
||||
pkgDetailList.map((pkg, index) => (
|
||||
<PackageDetailCard key={`${pkg.name}-${index}`} title={`套餐 ${index + 1}`} pkg={pkg} />
|
||||
))
|
||||
) : (
|
||||
<Typography.Text type="secondary">暂无套餐</Typography.Text>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="驳回套餐变更"
|
||||
open={rejectOpen}
|
||||
|
||||
Reference in New Issue
Block a user