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
@@ -59,7 +59,7 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
sortOrder: i,
};
})
: [emptyRow()],
: [],
);
})
.catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败'))
@@ -124,7 +124,13 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
sortOrder: index,
};
})
.filter((item) => item.name || item.dishes || item.price || item.imageUrls.length > 0);
// 允许整店无套餐:忽略空白占位行(默认 price=0 不算已填)
.filter((item) => {
const hasText = !!(item.name || item.dishes || item.usableTime || item.otherNotes);
const hasImages = item.imageUrls.length > 0;
const hasNonZeroPrice = item.price !== '' && Number(item.price) !== 0;
return hasText || hasImages || hasNonZeroPrice;
});
for (let i = 0; i < filled.length; i++) {
const item = filled[i];
@@ -168,7 +174,7 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
sortOrder: i,
};
})
: [emptyRow()],
: [],
);
} catch (e) {
if (!opts?.quiet) message.error(e instanceof Error ? e.message : '保存失败');
+63 -2
View File
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { Button, Image, Input, Space, Upload, message } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import { Button, Image, Input, Modal, Space, Upload, message } from 'antd';
import { EyeOutlined, FilePdfOutlined, UploadOutlined } from '@ant-design/icons';
import type { UploadProps } from 'antd';
import { uploadFileToOss, type OssMediaType, type UploadFileResult } from '../lib/upload';
@@ -15,6 +15,14 @@ type OssUploadProps = {
placeholder?: string;
};
function isImageUrl(url: string) {
return /\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/i.test(url);
}
function isPdfUrl(url: string) {
return /\.pdf(\?|#|$)/i.test(url);
}
export default function OssUpload({
value,
onChange,
@@ -25,6 +33,7 @@ export default function OssUpload({
placeholder = '上传后自动填入,或手动粘贴 URL',
}: OssUploadProps) {
const [uploading, setUploading] = useState(false);
const [pdfPreviewOpen, setPdfPreviewOpen] = useState(false);
const resolvedAccept =
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? undefined : 'image/*');
@@ -47,6 +56,57 @@ export default function OssUpload({
}
};
const filePreview =
value && mediaType === 'FILE' ? (
isImageUrl(value) ? (
<Image src={value} width={120} height={120} style={{ objectFit: 'cover', borderRadius: 4 }} />
) : isPdfUrl(value) ? (
<Space direction="vertical" size={8}>
<div
style={{
width: 120,
height: 120,
borderRadius: 4,
border: '1px solid #f0f0f0',
background: '#fafafa',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
color: '#cf1322',
}}
>
<FilePdfOutlined style={{ fontSize: 36 }} />
<span style={{ fontSize: 12, color: 'rgba(0,0,0,0.45)' }}>PDF </span>
</div>
<Space wrap>
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => setPdfPreviewOpen(true)}>
</Button>
<Button type="link" size="small" href={value} target="_blank" rel="noreferrer">
</Button>
</Space>
<Modal
title="签约合同预览"
open={pdfPreviewOpen}
onCancel={() => setPdfPreviewOpen(false)}
footer={null}
width="90vw"
styles={{ body: { height: '75vh', padding: 0 } }}
destroyOnClose
>
<iframe title="合同 PDF 预览" src={value} style={{ width: '100%', height: '100%', border: 0 }} />
</Modal>
</Space>
) : (
<Button type="link" href={value} target="_blank" rel="noreferrer" style={{ paddingLeft: 0 }}>
</Button>
)
) : null;
return (
<Space direction="vertical" style={{ width: '100%' }} size="small">
{value && mediaType === 'IMAGE' && (
@@ -55,6 +115,7 @@ export default function OssUpload({
{value && mediaType === 'VIDEO' && (
<video src={value} controls style={{ maxWidth: '100%', maxHeight: 160, borderRadius: 4 }} />
)}
{filePreview}
<Space wrap>
<Upload
accept={resolvedAccept}
@@ -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">
<Space style={{ marginBottom: 12 }} wrap>
<Typography.Text type="secondary">
线 {detail.livePackages?.length ?? 0} {detail.packages?.length ?? 0}
</Typography.Paragraph>
</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}
+39 -11
View File
@@ -51,13 +51,35 @@ type BillItem = {
const STATUS_LABELS: Record<string, string> = {
UNPAID: '未打款',
PAID: '已打款',
NO_PAYMENT_NEEDED: '无需打款',
};
const STATUS_COLORS: Record<string, string> = {
UNPAID: 'red',
PAID: 'green',
NO_PAYMENT_NEEDED: 'default',
};
function isZeroPayable(amount: number | string | null | undefined) {
return Number(amount ?? 0) === 0;
}
/** 应付为 0 时展示「无需打款」(灰),否则按 DB 打款状态 */
function displayWineryStatus(status: string, wineryAmount: number | string) {
if (isZeroPayable(wineryAmount)) {
return { key: 'NO_PAYMENT_NEEDED', label: STATUS_LABELS.NO_PAYMENT_NEEDED, color: STATUS_COLORS.NO_PAYMENT_NEEDED };
}
return {
key: status,
label: STATUS_LABELS[status] || status,
color: STATUS_COLORS[status] || 'default',
};
}
function canConfirmWineryPay(row: { status: string; wineryAmount: number | string }) {
return row.status === 'UNPAID' && !isZeroPayable(row.wineryAmount);
}
const DELIVERY_LABELS: Record<string, string> = {
LOCAL: '同城',
CROSS_CITY: '跨城',
@@ -212,7 +234,7 @@ export default function WineryBillsPage() {
render: (v) => `${Math.round(Number(v) * 100)}%`,
},
{
title: '酒厂应付',
title: '应付',
dataIndex: 'wineryAmount',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
@@ -220,8 +242,11 @@ export default function WineryBillsPage() {
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
width: 100,
render: (s, row) => {
const d = displayWineryStatus(s, row.wineryAmount);
return <Tag color={d.color}>{d.label}</Tag>;
},
},
{
title: '操作',
@@ -232,7 +257,7 @@ export default function WineryBillsPage() {
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
</Button>
{row.status === 'UNPAID' && (
{canConfirmWineryPay(row) && (
<Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.wineryAmount))}>
</Button>
@@ -258,7 +283,8 @@ export default function WineryBillsPage() {
</Typography.Title>
<Typography.Text type="secondary">
T+3 8:00 3 / × {ratePct}%绿
T+3 8:00 3 / × {ratePct}%绿 0
</Typography.Text>
</Space>
{canEditWineryBank ? (
@@ -273,7 +299,7 @@ export default function WineryBillsPage() {
<Space size="large" wrap>
<Statistic title="账单数" value={summary.count} />
<Statistic title="酒单实付合计" value={summary.orderAmount ?? 0} prefix="¥" precision={2} />
<Statistic title="酒厂应付合计" value={summary.wineryAmount ?? 0} prefix="¥" precision={2} />
<Statistic title="应付合计" value={summary.wineryAmount ?? 0} prefix="¥" precision={2} />
</Space>
</Card>
)}
@@ -296,7 +322,7 @@ export default function WineryBillsPage() {
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 120 }}
style={{ width: 130 }}
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
@@ -347,7 +373,7 @@ export default function WineryBillsPage() {
rowSelection={{
selectedRowKeys: selectedKeys,
onChange: setSelectedKeys,
getCheckboxProps: (r) => ({ disabled: r.status !== 'UNPAID' }),
getCheckboxProps: (r) => ({ disabled: !canConfirmWineryPay(r) }),
}}
scroll={{ x: 1100 }}
pagination={{
@@ -368,8 +394,10 @@ export default function WineryBillsPage() {
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
<Descriptions.Item label="账单日">{String(detail.billDate).slice(0, 10)}</Descriptions.Item>
<Descriptions.Item label="酒厂应付">¥{Number(detail.wineryAmount).toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="状态">{STATUS_LABELS[detail.status] || detail.status}</Descriptions.Item>
<Descriptions.Item label="应付">¥{Number(detail.wineryAmount).toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="状态">
{displayWineryStatus(detail.status, detail.wineryAmount).label}
</Descriptions.Item>
<Descriptions.Item label="打款时间">{detail.paidAt ? fmtTime(detail.paidAt) : '—'}</Descriptions.Item>
</Descriptions>
<Typography.Title level={5} style={{ marginTop: 16 }}>
@@ -395,7 +423,7 @@ export default function WineryBillsPage() {
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '酒厂应付',
title: '应付',
dataIndex: 'wineryAmount',
width: 90,
render: (v) => `¥${Number(v).toFixed(2)}`,
@@ -1708,6 +1708,9 @@ export class SettlementService {
const bill = await this.prisma.wineryBill.findUnique({ where: { id } });
if (!bill) throw new NotFoundException('酒厂对账单不存在');
if (bill.status !== 'UNPAID') throw new BadRequestException('仅未打款账单可确认打款');
if (Number(bill.wineryAmount) === 0) {
throw new BadRequestException('应付为 0,无需打款');
}
const updated = await this.prisma.wineryBill.update({
where: { id },
data: { status: 'PAID', paidAt: new Date() },
@@ -1749,11 +1752,17 @@ export class SettlementService {
'配送类型',
'实付金额',
'酒厂比例',
'酒厂应付',
'应付',
'支付时间',
'账单状态',
].join(',');
const rows: string[] = [];
const statusLabel = (b: { status: string; wineryAmount: Prisma.Decimal | number }) => {
if (Number(b.wineryAmount) === 0) return '无需打款';
if (b.status === 'PAID') return '已打款';
if (b.status === 'UNPAID') return '未打款';
return b.status;
};
for (const b of bills) {
if (b.items.length === 0) {
rows.push(
@@ -1766,7 +1775,7 @@ export class SettlementService {
Number(b.wineryRate),
Number(b.wineryAmount),
'',
b.status,
statusLabel(b),
].join(','),
);
continue;
@@ -1782,7 +1791,7 @@ export class SettlementService {
Number(b.wineryRate),
Number(item.wineryAmount),
item.paidAt.toISOString().slice(0, 19).replace('T', ' '),
b.status,
statusLabel(b),
].join(','),
);
}
@@ -1798,7 +1807,15 @@ export class SettlementService {
month?: number;
}): Prisma.WineryBillWhereInput {
const where: Prisma.WineryBillWhereInput = {};
if (query.status === 'UNPAID' || query.status === 'PAID') where.status = query.status;
if (query.status === 'NO_PAYMENT_NEEDED') {
where.wineryAmount = 0;
} else if (query.status === 'UNPAID') {
where.status = 'UNPAID';
where.wineryAmount = { gt: 0 };
} else if (query.status === 'PAID') {
where.status = 'PAID';
where.wineryAmount = { gt: 0 };
}
if (query.year && query.month) {
const start = new Date(query.year, query.month - 1, 1);
const end = new Date(query.year, query.month, 0, 23, 59, 59, 999);