fix(admin): contract preview, package audit detail, empty packages, winery status

Allow HQ to save stores with zero packages, preview contract images/PDFs, inspect package audit images/content, and show gray 无需打款 when winery payable is 0.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-10 20:30:55 +08:00
parent 46361ec713
commit 478291e2b3
5 changed files with 301 additions and 57 deletions
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import {
Button,
Drawer,
Image,
Input,
Modal,
Space,
@@ -17,7 +18,10 @@ import type {
StorePackageItemDto,
StorePackageViewDto,
} from '@dukang/shared-types';
import { STORE_PACKAGE_CHANGE_STATUS_LABELS } from '@dukang/shared-types';
import {
STORE_PACKAGE_CHANGE_STATUS_LABELS,
normalizeStorePackageImageUrls,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { fmtTime } from '../lib/constants';
@@ -26,6 +30,10 @@ function packageKey(pkg: StorePackageItemDto | StorePackageViewDto, index: numbe
return name ? `name:${name}` : `idx:${index}`;
}
function imageSignature(pkg: StorePackageItemDto | StorePackageViewDto) {
return normalizeStorePackageImageUrls(pkg).join('|');
}
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]));
@@ -49,7 +57,8 @@ function diffPackages(live: StorePackageViewDto[], proposed: StorePackageItemDto
l.price !== p.price ||
l.dishes !== p.dishes ||
(l.usableTime ?? '') !== (p.usableTime ?? '') ||
(l.otherNotes ?? '') !== (p.otherNotes ?? '');
(l.otherNotes ?? '') !== (p.otherNotes ?? '') ||
imageSignature(l) !== imageSignature(p);
rows.push({ key, change: changed ? 'changed' : 'unchanged', live: l, proposed: p });
}
}
@@ -63,6 +72,108 @@ const CHANGE_LABELS = {
unchanged: { text: '未变', color: 'default' },
} as const;
function PackageDetailCard({
title,
pkg,
}: {
title?: string;
pkg: StorePackageItemDto | StorePackageViewDto;
}) {
const images = normalizeStorePackageImageUrls(pkg);
return (
<div
style={{
marginBottom: 12,
padding: 12,
border: '1px solid #f0f0f0',
borderRadius: 8,
background: '#fafafa',
}}
>
{title ? (
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
{title}
</Typography.Text>
) : null}
<div style={{ marginBottom: 8 }}>
<strong>{pkg.name}</strong>
<span style={{ marginLeft: 8 }}>¥{pkg.price}</span>
</div>
<Typography.Paragraph style={{ marginBottom: 8, whiteSpace: 'pre-wrap' }}>
{pkg.dishes || '—'}
</Typography.Paragraph>
{pkg.usableTime ? (
<div style={{ marginBottom: 4 }}>
<Typography.Text type="secondary">{pkg.usableTime}</Typography.Text>
</div>
) : null}
{pkg.otherNotes ? (
<div style={{ marginBottom: 8 }}>
<Typography.Text type="secondary">{pkg.otherNotes}</Typography.Text>
</div>
) : 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>
)}
</div>
);
}
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[]>([]);
@@ -75,6 +186,11 @@ export default function StorePackageAuditsPage() {
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);
@@ -116,6 +232,15 @@ 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`, {
@@ -146,39 +271,11 @@ export default function StorePackageAuditsPage() {
},
{
title: '当前线上',
render: (_, row) =>
row.live ? (
<div>
<div><strong>{row.live.name}</strong> · ¥{row.live.price}</div>
<Typography.Text type="secondary">{row.live.dishes}</Typography.Text>
{row.live.usableTime ? (
<div><Typography.Text type="secondary">{row.live.usableTime}</Typography.Text></div>
) : null}
{row.live.otherNotes ? (
<div><Typography.Text type="secondary">{row.live.otherNotes}</Typography.Text></div>
) : null}
</div>
) : (
'—'
),
render: (_, row) => <PackageSummaryCell pkg={row.live} />,
},
{
title: '申请变更',
render: (_, row) =>
row.proposed ? (
<div>
<div><strong>{row.proposed.name}</strong> · ¥{row.proposed.price}</div>
<Typography.Text type="secondary">{row.proposed.dishes}</Typography.Text>
{row.proposed.usableTime ? (
<div><Typography.Text type="secondary">{row.proposed.usableTime}</Typography.Text></div>
) : null}
{row.proposed.otherNotes ? (
<div><Typography.Text type="secondary">{row.proposed.otherNotes}</Typography.Text></div>
) : null}
</div>
) : (
'—'
),
render: (_, row) => <PackageSummaryCell pkg={row.proposed} />,
},
];
@@ -257,7 +354,7 @@ export default function StorePackageAuditsPage() {
<Drawer
title={detail ? `${detail.storeName || detail.storeId} · 套餐变更` : '套餐变更详情'}
width={720}
width={880}
open={detailOpen}
onClose={() => setDetailOpen(false)}
extra={
@@ -282,7 +379,7 @@ export default function StorePackageAuditsPage() {
<Typography.Text type="secondary"></Typography.Text>
) : detail ? (
<>
<Space style={{ marginBottom: 16 }}>
<Space style={{ marginBottom: 16 }} wrap>
<Tag>{STORE_PACKAGE_CHANGE_STATUS_LABELS[detail.status]}</Tag>
<Typography.Text type="secondary">
{detail.submitterType === 'PARTNER' ? '合伙人' : '门店'} · {fmtTime(detail.createdAt)}
@@ -291,9 +388,27 @@ export default function StorePackageAuditsPage() {
{detail.rejectReason ? (
<Typography.Paragraph type="danger">{detail.rejectReason}</Typography.Paragraph>
) : null}
<Typography.Paragraph type="secondary">
线 {detail.livePackages?.length ?? 0} {detail.packages?.length ?? 0}
</Typography.Paragraph>
<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"
@@ -305,6 +420,23 @@ export default function StorePackageAuditsPage() {
) : 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}