feat(ops,store,partner): 用户权益列 + 门店合同多图上传

- admin 用户列表新增剩余/已用/累计好客权益金额三列(benefitCoupon groupBy 聚合,排除 VOID)
- admin/partner 门店入驻合同支持多张照片与 PDF(CommonResource 多记录,复用 ENV 多图逻辑)
- 新增 contract-urls.util 归一化工具,向后兼容旧 contractUrl 字段并标记 deprecated

需求1/2/3
This commit is contained in:
2026-08-12 22:48:02 +08:00
parent 478291e2b3
commit 88053353ab
13 changed files with 356 additions and 59 deletions
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { Button, Image, Space, Typography, Upload, message } from 'antd';
import { UploadOutlined, DeleteOutlined } from '@ant-design/icons';
import { UploadOutlined, DeleteOutlined, FilePdfOutlined } from '@ant-design/icons';
import type { UploadProps } from 'antd';
import { uploadFileToOss, type OssMediaType } from '../lib/upload';
@@ -13,12 +13,18 @@ type Props = {
maxCount?: number;
tip?: string;
accept?: string;
/** 上传按钮文案,默认「批量上传图片」 */
buttonText?: string;
};
function normalizeUrls(value?: string[]) {
return (value ?? []).map((u) => String(u || '').trim()).filter(Boolean);
}
function isPdf(url: string) {
return /\.pdf(\?|$)/i.test(url);
}
/**
* 多图批量上传(一次可选多张),用于套餐图 / 环境照 / 商品详情图等。
* Form.Item 直接绑定 string[]。
@@ -31,6 +37,7 @@ export default function MultiImageUpload({
maxCount,
tip,
accept = 'image/*',
buttonText = '批量上传图片',
}: Props) {
const urls = normalizeUrls(value);
const urlsRef = useRef(urls);
@@ -143,12 +150,36 @@ export default function MultiImageUpload({
<Space wrap size={12}>
{urls.map((url, index) => (
<div key={`${url}-${index}`} style={{ position: 'relative', width: 96 }}>
<Image
src={url}
width={96}
height={96}
style={{ objectFit: 'cover', borderRadius: 6, border: '1px solid #f0f0f0' }}
/>
{isPdf(url) ? (
<a
href={url}
target="_blank"
rel="noreferrer"
style={{
display: 'flex',
width: 96,
height: 96,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
gap: 4,
borderRadius: 6,
border: '1px solid #f0f0f0',
background: '#fafafa',
fontSize: 12,
}}
>
<FilePdfOutlined style={{ fontSize: 24, color: '#cf1322' }} />
<span>PDF</span>
</a>
) : (
<Image
src={url}
width={96}
height={96}
style={{ objectFit: 'cover', borderRadius: 6, border: '1px solid #f0f0f0' }}
/>
)}
<Button
type="text"
danger
@@ -176,7 +207,7 @@ export default function MultiImageUpload({
disabled={uploading || !canAdd}
>
<Button icon={<UploadOutlined />} loading={uploading} disabled={!canAdd}>
{canAdd ? '批量上传图片' : '已达上限'}
{canAdd ? buttonText : '已达上限'}
</Button>
</Upload>
</Space>
+6
View File
@@ -168,6 +168,12 @@ export type AdminUserRow = {
createdAt: string;
orderCount: number;
isTest?: boolean;
/** 好客权益·累计获得(含已使用,不含退款作废) */
benefitTotalAmount?: number;
/** 好客权益·已使用(已核销) */
benefitUsedAmount?: number;
/** 好客权益·剩余未使用 */
benefitBalance?: number;
};
export type AdminOrderItem = {
+2 -1
View File
@@ -22,7 +22,8 @@ export type StoreCreateForm = {
avgPrice?: number | null;
coverUrl?: string;
envPhotoUrls?: string[];
contractUrl?: string;
/** 签约合同,支持多张照片 / PDF */
contractUrls?: string[];
bankAccountName: string;
bankAccountNo: string;
bankBranch: string;
+35 -9
View File
@@ -116,7 +116,7 @@ function StoreAuditMediaEditor() {
type="info"
showIcon
style={{ marginBottom: 16 }}
message="可替换或删除门头照 / 环境照 / 签约合同,点击右上角「保存修改」后生效。环境照最多 20 张。套餐请在「套餐」页签编辑,同样由「保存修改」一并提交。"
message="可替换或删除门头照 / 环境照 / 签约合同,点击右上角「保存修改」后生效。环境照、签约合同均最多 20 张。套餐请在「套餐」页签编辑,同样由「保存修改」一并提交。"
/>
<Form.Item name="coverUrl" label="门头照">
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
@@ -133,8 +133,20 @@ function StoreAuditMediaEditor() {
tip="环境照支持一次选择多张批量上传"
/>
</Form.Item>
<Form.Item name="contractUrl" label="签约合同" style={{ marginTop: 16 }}>
<OssUpload bizType="STORE_CONTRACT" mediaType="FILE" accept="image/*,.pdf" />
<Form.Item
name="contractUrls"
label="签约合同"
style={{ marginTop: 16 }}
extra="支持多张合同照片(如首页、盖章页),也可上传 PDF,最多 20 个。"
>
<MultiImageUpload
bizType="STORE_CONTRACT"
mediaType="FILE"
accept="image/*,.pdf"
maxCount={20}
buttonText="批量上传合同"
tip="合同支持一次选择多张照片批量上传,最多 20 个"
/>
</Form.Item>
</div>
);
@@ -393,9 +405,9 @@ export default function StoresPage() {
const urls = envs.map((item) => item.url).filter(Boolean);
return urls;
})(),
contractUrl: (() => {
contractUrls: (() => {
const { contracts } = collectMediaUrls(d);
return contracts[0]?.url || '';
return contracts.map((item) => item.url).filter(Boolean);
})(),
province: d.province,
city: d.cityName,
@@ -442,7 +454,9 @@ export default function StoresPage() {
envPhotoUrls: Array.isArray(v.envPhotoUrls)
? v.envPhotoUrls.map((u: string) => String(u || '').trim()).filter(Boolean)
: [],
contractUrl: v.contractUrl ?? '',
contractUrls: Array.isArray(v.contractUrls)
? v.contractUrls.map((u: string) => String(u || '').trim()).filter(Boolean)
: [],
intro: v.intro,
benefitUsageRule:
typeof v.benefitUsageRule === 'string' &&
@@ -605,6 +619,7 @@ export default function StoresPage() {
}
const envPhotoUrls = (values.envPhotoUrls ?? []).map((u: string) => u?.trim()).filter(Boolean) as string[];
const contractUrls = (values.contractUrls ?? []).map((u: string) => u?.trim()).filter(Boolean) as string[];
await request('/admin/stores', {
method: 'POST',
body: JSON.stringify({
@@ -639,7 +654,7 @@ export default function StoresPage() {
: {}),
coverUrl: values.coverUrl?.trim() || undefined,
envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined,
contractUrl: values.contractUrl?.trim() || undefined,
contractUrls: contractUrls.length ? contractUrls : undefined,
bankAccountName: values.bankAccountName.trim(),
bankAccountNo: values.bankAccountNo.replace(/\s/g, ''),
bankBranch: values.bankBranch.trim(),
@@ -1340,8 +1355,19 @@ export default function StoresPage() {
tip="环境照支持一次选择多张批量上传"
/>
</Form.Item>
<Form.Item name="contractUrl" label="签约合同">
<OssUpload bizType="STORE_CONTRACT" mediaType="FILE" accept="image/*,.pdf" />
<Form.Item
name="contractUrls"
label="签约合同"
extra="选填;支持多张合同照片或 PDF,最多 20 个"
>
<MultiImageUpload
bizType="STORE_CONTRACT"
mediaType="FILE"
accept="image/*,.pdf"
maxCount={20}
buttonText="批量上传合同"
tip="合同支持一次选择多张照片批量上传,最多 20 个"
/>
</Form.Item>
</div>
<div style={{ display: createStep === 2 ? 'block' : 'none' }}>
+53 -1
View File
@@ -30,6 +30,13 @@ type UserOrderRow = {
createdAt: string;
};
/** 好客权益金额展示:0 与空值统一显示占位,避免整列都是 ¥0.00 干扰 */
function fmtBenefit(v: number | null | undefined) {
const n = Number(v ?? 0);
if (!Number.isFinite(n) || n <= 0) return '—';
return `¥${n.toFixed(2)}`;
}
type UserBehaviorLog = {
id: string;
eventName: string;
@@ -315,6 +322,39 @@ export default function UsersPage() {
render: (v) => (v ? <Tag color="blue"></Tag> : '—'),
},
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
{
title: '剩余权益',
dataIndex: 'benefitBalance',
width: 110,
align: 'right',
sorter: (a, b) => Number(a.benefitBalance ?? 0) - Number(b.benefitBalance ?? 0),
render: (v: number | undefined) =>
Number(v ?? 0) > 0 ? (
<Typography.Text strong style={{ color: '#cf1322' }}>
{fmtBenefit(v)}
</Typography.Text>
) : (
<Typography.Text type="secondary"></Typography.Text>
),
},
{
title: '已用权益',
dataIndex: 'benefitUsedAmount',
width: 110,
align: 'right',
sorter: (a, b) => Number(a.benefitUsedAmount ?? 0) - Number(b.benefitUsedAmount ?? 0),
render: (v: number | undefined) => (
<Typography.Text type="secondary">{fmtBenefit(v)}</Typography.Text>
),
},
{
title: '累计权益',
dataIndex: 'benefitTotalAmount',
width: 110,
align: 'right',
sorter: (a, b) => Number(a.benefitTotalAmount ?? 0) - Number(b.benefitTotalAmount ?? 0),
render: (v: number | undefined) => fmtBenefit(v),
},
{
title: '注册时间',
dataIndex: 'createdAt',
@@ -389,7 +429,7 @@ export default function UsersPage() {
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1500 }}
scroll={{ x: 1830 }}
rowSelection={canDeleteUsers ? {
selectedRowKeys,
preserveSelectedRowKeys: true,
@@ -461,6 +501,18 @@ export default function UsersPage() {
</Descriptions.Item>
<Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item>
<Descriptions.Item label="订单/地址">{detail.orderCount} / {detail.addressCount}</Descriptions.Item>
<Descriptions.Item label="好客权益">
<Space size={16} wrap>
<span>
{' '}
<Typography.Text strong style={{ color: '#cf1322' }}>
{fmtBenefit(detail.benefitBalance)}
</Typography.Text>
</span>
<span> {fmtBenefit(detail.benefitUsedAmount)}</span>
<span> {fmtBenefit(detail.benefitTotalAmount)}</span>
</Space>
</Descriptions.Item>
<Descriptions.Item label="注册时间">
{new Date(detail.createdAt).toLocaleString('zh-CN')}
</Descriptions.Item>