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:
@@ -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>
|
||||
|
||||
@@ -168,6 +168,12 @@ export type AdminUserRow = {
|
||||
createdAt: string;
|
||||
orderCount: number;
|
||||
isTest?: boolean;
|
||||
/** 好客权益·累计获得(含已使用,不含退款作废) */
|
||||
benefitTotalAmount?: number;
|
||||
/** 好客权益·已使用(已核销) */
|
||||
benefitUsedAmount?: number;
|
||||
/** 好客权益·剩余未使用 */
|
||||
benefitBalance?: number;
|
||||
};
|
||||
|
||||
export type AdminOrderItem = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -13,12 +13,20 @@ type Props = {
|
||||
maxCount?: number;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
/** 系统文件选择器的 accept,默认仅图片 */
|
||||
accept?: string;
|
||||
/** 计量单位文案,如「张」「个」 */
|
||||
unit?: string;
|
||||
};
|
||||
|
||||
function isCancelError(msg: string): boolean {
|
||||
return /cancel|取消/i.test(msg);
|
||||
}
|
||||
|
||||
function isPdf(url: string): boolean {
|
||||
return /\.pdf(\?|$)/i.test(url);
|
||||
}
|
||||
|
||||
function normalizeUrls(value?: string[]) {
|
||||
return (value ?? []).map((u) => String(u || '').trim()).filter(Boolean);
|
||||
}
|
||||
@@ -32,6 +40,8 @@ export default function MultiOssUploadField({
|
||||
maxCount = 20,
|
||||
disabled,
|
||||
label,
|
||||
accept = 'image/*',
|
||||
unit = '张',
|
||||
}: Props) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const pickingRef = useRef(false);
|
||||
@@ -67,7 +77,7 @@ export default function MultiOssUploadField({
|
||||
const room = Math.max(0, maxCount - current.length);
|
||||
const picked = files.slice(0, room);
|
||||
if (!picked.length) {
|
||||
showUploadError(`最多 ${maxCount} 张`);
|
||||
showUploadError(`最多 ${maxCount} ${unit}`);
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
@@ -83,7 +93,7 @@ export default function MultiOssUploadField({
|
||||
const next = [...urlsRef.current, ...appended];
|
||||
urlsRef.current = next;
|
||||
onChangeRef.current?.(next);
|
||||
toastSuccess(`已上传 ${appended.length} 张`);
|
||||
toastSuccess(`已上传 ${appended.length} ${unit}`);
|
||||
}
|
||||
} catch (e) {
|
||||
showUploadError(e instanceof Error ? e.message : '上传失败');
|
||||
@@ -126,7 +136,7 @@ export default function MultiOssUploadField({
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
accept={accept}
|
||||
multiple
|
||||
className="partner-oss-upload-input"
|
||||
disabled={disabled || uploading || remaining <= 0}
|
||||
@@ -140,11 +150,37 @@ export default function MultiOssUploadField({
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
|
||||
{urls.map((url, index) => (
|
||||
<div key={`${url}-${index}`} style={{ position: 'relative', width: 88, height: 88 }}>
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
style={{ width: 88, height: 88, objectFit: 'cover', borderRadius: 8 }}
|
||||
/>
|
||||
{isPdf(url) ? (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: 88,
|
||||
height: 88,
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 2,
|
||||
borderRadius: 8,
|
||||
border: '1px solid rgba(0,0,0,0.08)',
|
||||
background: '#f7f7f7',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-outlined text-primary" style={{ fontSize: 26 }}>
|
||||
description
|
||||
</span>
|
||||
<span className="text-muted">PDF</span>
|
||||
</a>
|
||||
) : (
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
style={{ width: 88, height: 88, objectFit: 'cover', borderRadius: 8 }}
|
||||
/>
|
||||
)}
|
||||
{!disabled ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -186,7 +222,7 @@ export default function MultiOssUploadField({
|
||||
{uploading
|
||||
? '上传中…'
|
||||
: remaining <= 0
|
||||
? `已达上限 ${maxCount}`
|
||||
? `已达上限 ${maxCount}${unit}`
|
||||
: label ?? `批量上传(${urls.length}/${maxCount})`}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -28,7 +28,8 @@ export type StoreDraftForm = {
|
||||
benefitUsageRule: string;
|
||||
coverUrl: string;
|
||||
envPhotoUrls: string[];
|
||||
contractUrl: string;
|
||||
/** 签约合同,支持多张照片 / PDF */
|
||||
contractUrls: string[];
|
||||
bankAccountName: string;
|
||||
bankAccountNo: string;
|
||||
bankBranch: string;
|
||||
@@ -70,7 +71,7 @@ export const defaultStoreForm = (): StoreDraftForm => ({
|
||||
benefitUsageRule: '',
|
||||
coverUrl: '',
|
||||
envPhotoUrls: ['', '', ''],
|
||||
contractUrl: '',
|
||||
contractUrls: [],
|
||||
bankAccountName: '',
|
||||
bankAccountNo: '',
|
||||
bankBranch: '',
|
||||
@@ -88,6 +89,9 @@ function timeToMinutes(hhmm: string): number {
|
||||
|
||||
export const MIN_ENV_PHOTO_COUNT = 3;
|
||||
|
||||
/** 签约合同最多可上传的照片 / PDF 数量 */
|
||||
export const MAX_CONTRACT_COUNT = 20;
|
||||
|
||||
export function normalizeStringArray(urls: unknown, minLen: number): string[] {
|
||||
const arr = Array.isArray(urls) ? urls.map((u) => String(u ?? '')) : [];
|
||||
while (arr.length < minLen) arr.push('');
|
||||
@@ -118,6 +122,22 @@ export function normalizeStoreDraftForm(raw: Partial<StoreDraftForm> | null | un
|
||||
closeTime2: String(raw.closeTime2 ?? base.closeTime2),
|
||||
avgPrice: String(raw.avgPrice ?? base.avgPrice),
|
||||
envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, MIN_ENV_PHOTO_COUNT),
|
||||
// 兼容旧草稿:单个 contractUrl 迁移为数组
|
||||
contractUrls: (() => {
|
||||
const list = Array.isArray(raw.contractUrls)
|
||||
? raw.contractUrls
|
||||
: [(raw as { contractUrl?: unknown }).contractUrl];
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const item of list) {
|
||||
const url = String(item ?? '').trim();
|
||||
if (!url || seen.has(url)) continue;
|
||||
seen.add(url);
|
||||
out.push(url);
|
||||
if (out.length >= MAX_CONTRACT_COUNT) break;
|
||||
}
|
||||
return out;
|
||||
})(),
|
||||
packages: Array.isArray(raw.packages)
|
||||
? raw.packages.map((p, i) => ({
|
||||
name: String((p as { name?: string }).name ?? ''),
|
||||
@@ -224,12 +244,13 @@ export function validateStoreStep1(
|
||||
}
|
||||
|
||||
export function validateStoreStep2(
|
||||
form: Pick<StoreDraftForm, 'coverUrl' | 'envPhotoUrls' | 'contractUrl'>,
|
||||
form: Pick<StoreDraftForm, 'coverUrl' | 'envPhotoUrls' | 'contractUrls'>,
|
||||
): string | null {
|
||||
if (!form.coverUrl.trim()) return '请上传门头照';
|
||||
const envCount = form.envPhotoUrls.filter((u) => u.trim()).length;
|
||||
if (envCount < MIN_ENV_PHOTO_COUNT) return `请上传至少 ${MIN_ENV_PHOTO_COUNT} 张环境照片`;
|
||||
if (!form.contractUrl.trim()) return '请上传签约合同';
|
||||
const contractCount = (form.contractUrls ?? []).filter((u) => u.trim()).length;
|
||||
if (contractCount < 1) return '请上传签约合同';
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ import {
|
||||
validateStoreStep3,
|
||||
|
||||
MIN_ENV_PHOTO_COUNT,
|
||||
|
||||
MAX_CONTRACT_COUNT,
|
||||
} from '../lib/storeDraft';
|
||||
import StorePackagesForm from '../components/StorePackagesForm';
|
||||
import { normalizePackageFormItems, validatePackageFormItems } from '../lib/storePackages';
|
||||
@@ -476,7 +478,11 @@ export default function StoreCreatePage() {
|
||||
|
||||
const envPhotoUrls = Array.from(
|
||||
new Set(form.envPhotoUrls.map((u) => u.trim()).filter(Boolean)),
|
||||
).slice(0, 3);
|
||||
).slice(0, 20);
|
||||
|
||||
const contractUrls = Array.from(
|
||||
new Set((form.contractUrls ?? []).map((u) => u.trim()).filter(Boolean)),
|
||||
).slice(0, MAX_CONTRACT_COUNT);
|
||||
|
||||
const result = await request<{ store: { id: string } }>('PARTNER_H5', '/partner/stores', {
|
||||
|
||||
@@ -526,7 +532,7 @@ export default function StoreCreatePage() {
|
||||
|
||||
envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined,
|
||||
|
||||
contractUrl: form.contractUrl.trim() || undefined,
|
||||
contractUrls: contractUrls.length ? contractUrls : undefined,
|
||||
|
||||
bankAccountName: form.bankAccountName.trim(),
|
||||
|
||||
@@ -1013,9 +1019,11 @@ export default function StoreCreatePage() {
|
||||
|
||||
<h3 className="headline-md">签约合同 <span className="text-primary">*</span></h3>
|
||||
|
||||
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>拍照上传签约协议首页与盖章页</p>
|
||||
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>
|
||||
拍照上传签约协议首页与盖章页,支持多张,最多 {MAX_CONTRACT_COUNT} 个
|
||||
</p>
|
||||
|
||||
<OssUploadField
|
||||
<MultiOssUploadField
|
||||
|
||||
bizType="STORE_CONTRACT"
|
||||
|
||||
@@ -1023,11 +1031,15 @@ export default function StoreCreatePage() {
|
||||
|
||||
accept="image/*,.pdf"
|
||||
|
||||
value={form.contractUrl}
|
||||
unit="个"
|
||||
|
||||
onChange={(contractUrl) => patchForm({ contractUrl })}
|
||||
maxCount={MAX_CONTRACT_COUNT}
|
||||
|
||||
label="上传合同副本"
|
||||
value={form.contractUrls ?? []}
|
||||
|
||||
onChange={(urls) => patchForm({ contractUrls: urls })}
|
||||
|
||||
label={`批量上传合同(${(form.contractUrls ?? []).length}/${MAX_CONTRACT_COUNT})`}
|
||||
|
||||
/>
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 门店签约合同(CommonResource bizType='CONTRACT')多附件工具。
|
||||
*
|
||||
* 历史上合同只存单条记录(字段 contractUrl:string),现改为支持多张照片 / PDF。
|
||||
* 入参同时兼容新的 contractUrls:string[] 与旧的 contractUrl:string。
|
||||
*/
|
||||
|
||||
export const MAX_CONTRACT_FILES = 20;
|
||||
|
||||
/**
|
||||
* 归一化合同附件地址:去空白、去重、限制数量。
|
||||
* @param urls 新字段 contractUrls
|
||||
* @param legacy 旧字段 contractUrl(仅在 urls 未提供时生效)
|
||||
*/
|
||||
export function normalizeContractUrls(
|
||||
urls?: unknown,
|
||||
legacy?: string | null,
|
||||
): string[] {
|
||||
const raw: unknown[] = Array.isArray(urls)
|
||||
? urls
|
||||
: legacy != null
|
||||
? [legacy]
|
||||
: [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const item of raw) {
|
||||
const url = String(item ?? '').trim();
|
||||
if (!url || seen.has(url)) continue;
|
||||
seen.add(url);
|
||||
out.push(url);
|
||||
if (out.length >= MAX_CONTRACT_FILES) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** PDF 存 FILE,其余(合同照片)存 IMAGE,便于前端按图片预览 */
|
||||
export function contractMediaType(url: string): 'FILE' | 'IMAGE' {
|
||||
return /\.pdf(\?|$)/i.test(url) ? 'FILE' : 'IMAGE';
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { validateBusinessHours } from '@dukang/domain';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
||||
import { contractMediaType, normalizeContractUrls } from '../../common/store-media/contract-urls.util';
|
||||
import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto';
|
||||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||
import { StoreCategoryService } from '../store/store-category.service';
|
||||
@@ -446,24 +447,23 @@ export class AdminStoresService {
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.contractUrl !== undefined) {
|
||||
const contractUrl = dto.contractUrl?.trim() || '';
|
||||
if (dto.contractUrls !== undefined || dto.contractUrl !== undefined) {
|
||||
const contractUrls = normalizeContractUrls(dto.contractUrls, dto.contractUrl);
|
||||
await tx.commonResource.updateMany({
|
||||
where: { ownerType: 'STORE', ownerId: id, bizType: 'CONTRACT', status: 'ACTIVE' },
|
||||
data: { status: 'DELETED' },
|
||||
});
|
||||
if (contractUrl) {
|
||||
const isPdf = /\.pdf(\?|$)/i.test(contractUrl);
|
||||
for (let i = 0; i < contractUrls.length; i++) {
|
||||
await tx.commonResource.create({
|
||||
data: {
|
||||
ownerType: 'STORE',
|
||||
ownerId: id,
|
||||
bizType: 'CONTRACT',
|
||||
mediaType: isPdf ? 'FILE' : 'IMAGE',
|
||||
mediaType: contractMediaType(contractUrls[i]),
|
||||
ossBucket: 'legacy',
|
||||
ossKey: contractUrl,
|
||||
url: contractUrl,
|
||||
sortOrder: 0,
|
||||
ossKey: contractUrls[i],
|
||||
url: contractUrls[i],
|
||||
sortOrder: i,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -642,16 +642,18 @@ export class AdminStoresService {
|
||||
});
|
||||
}
|
||||
|
||||
if (dto.contractUrl) {
|
||||
const contractUrls = normalizeContractUrls(dto.contractUrls, dto.contractUrl);
|
||||
for (let i = 0; i < contractUrls.length; i++) {
|
||||
await this.prisma.commonResource.create({
|
||||
data: {
|
||||
ownerType: 'STORE',
|
||||
ownerId: store.id,
|
||||
bizType: 'CONTRACT',
|
||||
mediaType: 'FILE',
|
||||
mediaType: contractMediaType(contractUrls[i]),
|
||||
ossBucket: 'legacy',
|
||||
ossKey: dto.contractUrl,
|
||||
url: dto.contractUrl,
|
||||
ossKey: contractUrls[i],
|
||||
url: contractUrls[i],
|
||||
sortOrder: i,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,28 @@ import type { AdminUsersQueryDto } from './dto/admin-query.dto';
|
||||
|
||||
const FINISHED_ORDER_STATUSES = ['COMPLETED', 'CANCELLED', 'REFUNDED'] as const;
|
||||
|
||||
/** 权益统计口径:VOID(退款作废)不计入,与 C 端 listCoupons 一致 */
|
||||
const BENEFIT_STAT_STATUSES = ['ACTIVE', 'USED_UP'] as const;
|
||||
|
||||
export type UserBenefitStat = {
|
||||
/** 累计获得(含已使用) */
|
||||
benefitTotalAmount: number;
|
||||
/** 已使用(已核销) */
|
||||
benefitUsedAmount: number;
|
||||
/** 剩余未使用(可核销余额) */
|
||||
benefitBalance: number;
|
||||
};
|
||||
|
||||
const EMPTY_BENEFIT_STAT: UserBenefitStat = {
|
||||
benefitTotalAmount: 0,
|
||||
benefitUsedAmount: 0,
|
||||
benefitBalance: 0,
|
||||
};
|
||||
|
||||
function toAmount(v: Prisma.Decimal | number | null | undefined) {
|
||||
return v == null ? 0 : Number(v);
|
||||
}
|
||||
|
||||
function mapAdminUserRow(u: {
|
||||
id: bigint;
|
||||
userNo: string;
|
||||
@@ -23,7 +45,7 @@ function mapAdminUserRow(u: {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
_count: { orders: number };
|
||||
}) {
|
||||
}, benefit: UserBenefitStat = EMPTY_BENEFIT_STAT) {
|
||||
return {
|
||||
id: u.id,
|
||||
userNo: u.userNo,
|
||||
@@ -42,6 +64,7 @@ function mapAdminUserRow(u: {
|
||||
createdAt: u.createdAt,
|
||||
updatedAt: u.updatedAt,
|
||||
orderCount: u._count.orders,
|
||||
...benefit,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -90,14 +113,37 @@ export class AdminUsersService {
|
||||
this.prisma.user.count({ where }),
|
||||
]);
|
||||
|
||||
const benefitMap = await this.loadBenefitStats(items.map((u) => u.id));
|
||||
|
||||
return serializeBigInt({
|
||||
items: items.map((u) => mapAdminUserRow(u)),
|
||||
items: items.map((u) => mapAdminUserRow(u, benefitMap.get(u.id.toString()))),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
/** 批量聚合当前页用户的好客权益金额(累计获得 / 已使用 / 剩余) */
|
||||
private async loadBenefitStats(userIds: bigint[]): Promise<Map<string, UserBenefitStat>> {
|
||||
const map = new Map<string, UserBenefitStat>();
|
||||
if (!userIds.length) return map;
|
||||
|
||||
const grouped = await this.prisma.benefitCoupon.groupBy({
|
||||
by: ['userId'],
|
||||
where: { userId: { in: userIds }, status: { in: [...BENEFIT_STAT_STATUSES] } },
|
||||
_sum: { totalAmount: true, usedAmount: true, balance: true },
|
||||
});
|
||||
|
||||
for (const row of grouped) {
|
||||
map.set(row.userId.toString(), {
|
||||
benefitTotalAmount: toAmount(row._sum.totalAmount),
|
||||
benefitUsedAmount: toAmount(row._sum.usedAmount),
|
||||
benefitBalance: toAmount(row._sum.balance),
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async detail(id: bigint) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id },
|
||||
@@ -128,8 +174,11 @@ export class AdminUsersService {
|
||||
});
|
||||
}
|
||||
|
||||
const benefitMap = await this.loadBenefitStats([user.id]);
|
||||
|
||||
return serializeBigInt({
|
||||
...user,
|
||||
...(benefitMap.get(user.id.toString()) ?? EMPTY_BENEFIT_STAT),
|
||||
wechatVerified: !!user.wxOpenId,
|
||||
mergedFromCount: user._count.mergedFrom,
|
||||
orderCount: user._count.orders,
|
||||
|
||||
@@ -105,10 +105,18 @@ export class CreateStoreDto {
|
||||
@ArrayMaxSize(20)
|
||||
envPhotoUrls?: string[];
|
||||
|
||||
/** @deprecated 用 contractUrls;保留兼容旧客户端 */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
contractUrl?: string;
|
||||
|
||||
/** 签约合同(支持多张照片 / PDF) */
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@ArrayMaxSize(20)
|
||||
contractUrls?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@@ -190,10 +198,18 @@ export class UpdateStoreDto {
|
||||
@ArrayMaxSize(20)
|
||||
envPhotoUrls?: string[];
|
||||
|
||||
/** @deprecated 用 contractUrls;保留兼容旧客户端 */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
contractUrl?: string | null;
|
||||
|
||||
/** 签约合同(支持多张照片 / PDF),传空数组即清空 */
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@ArrayMaxSize(20)
|
||||
contractUrls?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
address?: string;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
||||
import { parseBigIntParam } from '../../common/parse-bigint';
|
||||
import { contractMediaType, normalizeContractUrls } from '../../common/store-media/contract-urls.util';
|
||||
import { AnalyticsService } from '../analytics/analytics.service';
|
||||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||
import { AuthService } from '../iam/auth.service';
|
||||
@@ -377,11 +378,14 @@ export class StoreService {
|
||||
const city = await this.resolvePartnerCity(partnerAccountId, body.cityId);
|
||||
const coverUrl = body.coverUrl ? String(body.coverUrl).trim() : '';
|
||||
const envPhotoUrls = this.normalizeEnvPhotoUrls(body.envPhotoUrls);
|
||||
const contractUrl = body.contractUrl ? String(body.contractUrl).trim() : '';
|
||||
const contractUrls = normalizeContractUrls(
|
||||
body.contractUrls,
|
||||
body.contractUrl != null ? String(body.contractUrl) : null,
|
||||
);
|
||||
|
||||
if (!coverUrl) throw new BadRequestException('请上传门头照');
|
||||
if (envPhotoUrls.length < 3) throw new BadRequestException('请上传至少 3 张环境照片');
|
||||
if (!contractUrl) throw new BadRequestException('请上传签约合同');
|
||||
if (!contractUrls.length) throw new BadRequestException('请上传签约合同');
|
||||
|
||||
const bankAccountName = body.bankAccountName ? String(body.bankAccountName) : null;
|
||||
const bankAccountNo = body.bankAccountNo ? String(body.bankAccountNo) : null;
|
||||
@@ -486,16 +490,17 @@ export class StoreService {
|
||||
});
|
||||
}
|
||||
|
||||
if (contractUrl) {
|
||||
for (let i = 0; i < contractUrls.length; i++) {
|
||||
await this.prisma.commonResource.create({
|
||||
data: {
|
||||
ownerType: 'STORE',
|
||||
ownerId: store.id,
|
||||
bizType: 'CONTRACT',
|
||||
mediaType: 'FILE',
|
||||
mediaType: contractMediaType(contractUrls[i]),
|
||||
ossBucket,
|
||||
ossKey: contractUrl,
|
||||
url: contractUrl,
|
||||
ossKey: contractUrls[i],
|
||||
url: contractUrls[i],
|
||||
sortOrder: i,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user