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 { useEffect, useRef, useState } from 'react';
|
||||||
import { Button, Image, Space, Typography, Upload, message } from 'antd';
|
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 type { UploadProps } from 'antd';
|
||||||
import { uploadFileToOss, type OssMediaType } from '../lib/upload';
|
import { uploadFileToOss, type OssMediaType } from '../lib/upload';
|
||||||
|
|
||||||
@@ -13,12 +13,18 @@ type Props = {
|
|||||||
maxCount?: number;
|
maxCount?: number;
|
||||||
tip?: string;
|
tip?: string;
|
||||||
accept?: string;
|
accept?: string;
|
||||||
|
/** 上传按钮文案,默认「批量上传图片」 */
|
||||||
|
buttonText?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeUrls(value?: string[]) {
|
function normalizeUrls(value?: string[]) {
|
||||||
return (value ?? []).map((u) => String(u || '').trim()).filter(Boolean);
|
return (value ?? []).map((u) => String(u || '').trim()).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPdf(url: string) {
|
||||||
|
return /\.pdf(\?|$)/i.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 多图批量上传(一次可选多张),用于套餐图 / 环境照 / 商品详情图等。
|
* 多图批量上传(一次可选多张),用于套餐图 / 环境照 / 商品详情图等。
|
||||||
* Form.Item 直接绑定 string[]。
|
* Form.Item 直接绑定 string[]。
|
||||||
@@ -31,6 +37,7 @@ export default function MultiImageUpload({
|
|||||||
maxCount,
|
maxCount,
|
||||||
tip,
|
tip,
|
||||||
accept = 'image/*',
|
accept = 'image/*',
|
||||||
|
buttonText = '批量上传图片',
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const urls = normalizeUrls(value);
|
const urls = normalizeUrls(value);
|
||||||
const urlsRef = useRef(urls);
|
const urlsRef = useRef(urls);
|
||||||
@@ -143,12 +150,36 @@ export default function MultiImageUpload({
|
|||||||
<Space wrap size={12}>
|
<Space wrap size={12}>
|
||||||
{urls.map((url, index) => (
|
{urls.map((url, index) => (
|
||||||
<div key={`${url}-${index}`} style={{ position: 'relative', width: 96 }}>
|
<div key={`${url}-${index}`} style={{ position: 'relative', width: 96 }}>
|
||||||
<Image
|
{isPdf(url) ? (
|
||||||
src={url}
|
<a
|
||||||
width={96}
|
href={url}
|
||||||
height={96}
|
target="_blank"
|
||||||
style={{ objectFit: 'cover', borderRadius: 6, border: '1px solid #f0f0f0' }}
|
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
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
danger
|
danger
|
||||||
@@ -176,7 +207,7 @@ export default function MultiImageUpload({
|
|||||||
disabled={uploading || !canAdd}
|
disabled={uploading || !canAdd}
|
||||||
>
|
>
|
||||||
<Button icon={<UploadOutlined />} loading={uploading} disabled={!canAdd}>
|
<Button icon={<UploadOutlined />} loading={uploading} disabled={!canAdd}>
|
||||||
{canAdd ? '批量上传图片' : '已达上限'}
|
{canAdd ? buttonText : '已达上限'}
|
||||||
</Button>
|
</Button>
|
||||||
</Upload>
|
</Upload>
|
||||||
</Space>
|
</Space>
|
||||||
|
|||||||
@@ -168,6 +168,12 @@ export type AdminUserRow = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
orderCount: number;
|
orderCount: number;
|
||||||
isTest?: boolean;
|
isTest?: boolean;
|
||||||
|
/** 好客权益·累计获得(含已使用,不含退款作废) */
|
||||||
|
benefitTotalAmount?: number;
|
||||||
|
/** 好客权益·已使用(已核销) */
|
||||||
|
benefitUsedAmount?: number;
|
||||||
|
/** 好客权益·剩余未使用 */
|
||||||
|
benefitBalance?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdminOrderItem = {
|
export type AdminOrderItem = {
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ export type StoreCreateForm = {
|
|||||||
avgPrice?: number | null;
|
avgPrice?: number | null;
|
||||||
coverUrl?: string;
|
coverUrl?: string;
|
||||||
envPhotoUrls?: string[];
|
envPhotoUrls?: string[];
|
||||||
contractUrl?: string;
|
/** 签约合同,支持多张照片 / PDF */
|
||||||
|
contractUrls?: string[];
|
||||||
bankAccountName: string;
|
bankAccountName: string;
|
||||||
bankAccountNo: string;
|
bankAccountNo: string;
|
||||||
bankBranch: string;
|
bankBranch: string;
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ function StoreAuditMediaEditor() {
|
|||||||
type="info"
|
type="info"
|
||||||
showIcon
|
showIcon
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
message="可替换或删除门头照 / 环境照 / 签约合同,点击右上角「保存修改」后生效。环境照最多 20 张。套餐请在「套餐」页签编辑,同样由「保存修改」一并提交。"
|
message="可替换或删除门头照 / 环境照 / 签约合同,点击右上角「保存修改」后生效。环境照、签约合同均最多 20 张。套餐请在「套餐」页签编辑,同样由「保存修改」一并提交。"
|
||||||
/>
|
/>
|
||||||
<Form.Item name="coverUrl" label="门头照">
|
<Form.Item name="coverUrl" label="门头照">
|
||||||
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
|
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
|
||||||
@@ -133,8 +133,20 @@ function StoreAuditMediaEditor() {
|
|||||||
tip="环境照支持一次选择多张批量上传"
|
tip="环境照支持一次选择多张批量上传"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="contractUrl" label="签约合同" style={{ marginTop: 16 }}>
|
<Form.Item
|
||||||
<OssUpload bizType="STORE_CONTRACT" mediaType="FILE" accept="image/*,.pdf" />
|
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>
|
</Form.Item>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -393,9 +405,9 @@ export default function StoresPage() {
|
|||||||
const urls = envs.map((item) => item.url).filter(Boolean);
|
const urls = envs.map((item) => item.url).filter(Boolean);
|
||||||
return urls;
|
return urls;
|
||||||
})(),
|
})(),
|
||||||
contractUrl: (() => {
|
contractUrls: (() => {
|
||||||
const { contracts } = collectMediaUrls(d);
|
const { contracts } = collectMediaUrls(d);
|
||||||
return contracts[0]?.url || '';
|
return contracts.map((item) => item.url).filter(Boolean);
|
||||||
})(),
|
})(),
|
||||||
province: d.province,
|
province: d.province,
|
||||||
city: d.cityName,
|
city: d.cityName,
|
||||||
@@ -442,7 +454,9 @@ export default function StoresPage() {
|
|||||||
envPhotoUrls: Array.isArray(v.envPhotoUrls)
|
envPhotoUrls: Array.isArray(v.envPhotoUrls)
|
||||||
? v.envPhotoUrls.map((u: string) => String(u || '').trim()).filter(Boolean)
|
? 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,
|
intro: v.intro,
|
||||||
benefitUsageRule:
|
benefitUsageRule:
|
||||||
typeof v.benefitUsageRule === 'string' &&
|
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 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', {
|
await request('/admin/stores', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -639,7 +654,7 @@ export default function StoresPage() {
|
|||||||
: {}),
|
: {}),
|
||||||
coverUrl: values.coverUrl?.trim() || undefined,
|
coverUrl: values.coverUrl?.trim() || undefined,
|
||||||
envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined,
|
envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined,
|
||||||
contractUrl: values.contractUrl?.trim() || undefined,
|
contractUrls: contractUrls.length ? contractUrls : undefined,
|
||||||
bankAccountName: values.bankAccountName.trim(),
|
bankAccountName: values.bankAccountName.trim(),
|
||||||
bankAccountNo: values.bankAccountNo.replace(/\s/g, ''),
|
bankAccountNo: values.bankAccountNo.replace(/\s/g, ''),
|
||||||
bankBranch: values.bankBranch.trim(),
|
bankBranch: values.bankBranch.trim(),
|
||||||
@@ -1340,8 +1355,19 @@ export default function StoresPage() {
|
|||||||
tip="环境照支持一次选择多张批量上传"
|
tip="环境照支持一次选择多张批量上传"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="contractUrl" label="签约合同">
|
<Form.Item
|
||||||
<OssUpload bizType="STORE_CONTRACT" mediaType="FILE" accept="image/*,.pdf" />
|
name="contractUrls"
|
||||||
|
label="签约合同"
|
||||||
|
extra="选填;支持多张合同照片或 PDF,最多 20 个"
|
||||||
|
>
|
||||||
|
<MultiImageUpload
|
||||||
|
bizType="STORE_CONTRACT"
|
||||||
|
mediaType="FILE"
|
||||||
|
accept="image/*,.pdf"
|
||||||
|
maxCount={20}
|
||||||
|
buttonText="批量上传合同"
|
||||||
|
tip="合同支持一次选择多张照片批量上传,最多 20 个"
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: createStep === 2 ? 'block' : 'none' }}>
|
<div style={{ display: createStep === 2 ? 'block' : 'none' }}>
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ type UserOrderRow = {
|
|||||||
createdAt: string;
|
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 = {
|
type UserBehaviorLog = {
|
||||||
id: string;
|
id: string;
|
||||||
eventName: string;
|
eventName: string;
|
||||||
@@ -315,6 +322,39 @@ export default function UsersPage() {
|
|||||||
render: (v) => (v ? <Tag color="blue">已合并</Tag> : '—'),
|
render: (v) => (v ? <Tag color="blue">已合并</Tag> : '—'),
|
||||||
},
|
},
|
||||||
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
|
{ 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: '注册时间',
|
title: '注册时间',
|
||||||
dataIndex: 'createdAt',
|
dataIndex: 'createdAt',
|
||||||
@@ -389,7 +429,7 @@ export default function UsersPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 1500 }}
|
scroll={{ x: 1830 }}
|
||||||
rowSelection={canDeleteUsers ? {
|
rowSelection={canDeleteUsers ? {
|
||||||
selectedRowKeys,
|
selectedRowKeys,
|
||||||
preserveSelectedRowKeys: true,
|
preserveSelectedRowKeys: true,
|
||||||
@@ -461,6 +501,18 @@ export default function UsersPage() {
|
|||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item>
|
<Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item>
|
||||||
<Descriptions.Item label="订单/地址">{detail.orderCount} / {detail.addressCount}</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="注册时间">
|
<Descriptions.Item label="注册时间">
|
||||||
{new Date(detail.createdAt).toLocaleString('zh-CN')}
|
{new Date(detail.createdAt).toLocaleString('zh-CN')}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|||||||
@@ -13,12 +13,20 @@ type Props = {
|
|||||||
maxCount?: number;
|
maxCount?: number;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
/** 系统文件选择器的 accept,默认仅图片 */
|
||||||
|
accept?: string;
|
||||||
|
/** 计量单位文案,如「张」「个」 */
|
||||||
|
unit?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function isCancelError(msg: string): boolean {
|
function isCancelError(msg: string): boolean {
|
||||||
return /cancel|取消/i.test(msg);
|
return /cancel|取消/i.test(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPdf(url: string): boolean {
|
||||||
|
return /\.pdf(\?|$)/i.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeUrls(value?: string[]) {
|
function normalizeUrls(value?: string[]) {
|
||||||
return (value ?? []).map((u) => String(u || '').trim()).filter(Boolean);
|
return (value ?? []).map((u) => String(u || '').trim()).filter(Boolean);
|
||||||
}
|
}
|
||||||
@@ -32,6 +40,8 @@ export default function MultiOssUploadField({
|
|||||||
maxCount = 20,
|
maxCount = 20,
|
||||||
disabled,
|
disabled,
|
||||||
label,
|
label,
|
||||||
|
accept = 'image/*',
|
||||||
|
unit = '张',
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const pickingRef = useRef(false);
|
const pickingRef = useRef(false);
|
||||||
@@ -67,7 +77,7 @@ export default function MultiOssUploadField({
|
|||||||
const room = Math.max(0, maxCount - current.length);
|
const room = Math.max(0, maxCount - current.length);
|
||||||
const picked = files.slice(0, room);
|
const picked = files.slice(0, room);
|
||||||
if (!picked.length) {
|
if (!picked.length) {
|
||||||
showUploadError(`最多 ${maxCount} 张`);
|
showUploadError(`最多 ${maxCount} ${unit}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
@@ -83,7 +93,7 @@ export default function MultiOssUploadField({
|
|||||||
const next = [...urlsRef.current, ...appended];
|
const next = [...urlsRef.current, ...appended];
|
||||||
urlsRef.current = next;
|
urlsRef.current = next;
|
||||||
onChangeRef.current?.(next);
|
onChangeRef.current?.(next);
|
||||||
toastSuccess(`已上传 ${appended.length} 张`);
|
toastSuccess(`已上传 ${appended.length} ${unit}`);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showUploadError(e instanceof Error ? e.message : '上传失败');
|
showUploadError(e instanceof Error ? e.message : '上传失败');
|
||||||
@@ -126,7 +136,7 @@ export default function MultiOssUploadField({
|
|||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept={accept}
|
||||||
multiple
|
multiple
|
||||||
className="partner-oss-upload-input"
|
className="partner-oss-upload-input"
|
||||||
disabled={disabled || uploading || remaining <= 0}
|
disabled={disabled || uploading || remaining <= 0}
|
||||||
@@ -140,11 +150,37 @@ export default function MultiOssUploadField({
|
|||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
|
||||||
{urls.map((url, index) => (
|
{urls.map((url, index) => (
|
||||||
<div key={`${url}-${index}`} style={{ position: 'relative', width: 88, height: 88 }}>
|
<div key={`${url}-${index}`} style={{ position: 'relative', width: 88, height: 88 }}>
|
||||||
<img
|
{isPdf(url) ? (
|
||||||
src={url}
|
<a
|
||||||
alt=""
|
href={url}
|
||||||
style={{ width: 88, height: 88, objectFit: 'cover', borderRadius: 8 }}
|
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 ? (
|
{!disabled ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -186,7 +222,7 @@ export default function MultiOssUploadField({
|
|||||||
{uploading
|
{uploading
|
||||||
? '上传中…'
|
? '上传中…'
|
||||||
: remaining <= 0
|
: remaining <= 0
|
||||||
? `已达上限 ${maxCount}`
|
? `已达上限 ${maxCount}${unit}`
|
||||||
: label ?? `批量上传(${urls.length}/${maxCount})`}
|
: label ?? `批量上传(${urls.length}/${maxCount})`}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ export type StoreDraftForm = {
|
|||||||
benefitUsageRule: string;
|
benefitUsageRule: string;
|
||||||
coverUrl: string;
|
coverUrl: string;
|
||||||
envPhotoUrls: string[];
|
envPhotoUrls: string[];
|
||||||
contractUrl: string;
|
/** 签约合同,支持多张照片 / PDF */
|
||||||
|
contractUrls: string[];
|
||||||
bankAccountName: string;
|
bankAccountName: string;
|
||||||
bankAccountNo: string;
|
bankAccountNo: string;
|
||||||
bankBranch: string;
|
bankBranch: string;
|
||||||
@@ -70,7 +71,7 @@ export const defaultStoreForm = (): StoreDraftForm => ({
|
|||||||
benefitUsageRule: '',
|
benefitUsageRule: '',
|
||||||
coverUrl: '',
|
coverUrl: '',
|
||||||
envPhotoUrls: ['', '', ''],
|
envPhotoUrls: ['', '', ''],
|
||||||
contractUrl: '',
|
contractUrls: [],
|
||||||
bankAccountName: '',
|
bankAccountName: '',
|
||||||
bankAccountNo: '',
|
bankAccountNo: '',
|
||||||
bankBranch: '',
|
bankBranch: '',
|
||||||
@@ -88,6 +89,9 @@ function timeToMinutes(hhmm: string): number {
|
|||||||
|
|
||||||
export const MIN_ENV_PHOTO_COUNT = 3;
|
export const MIN_ENV_PHOTO_COUNT = 3;
|
||||||
|
|
||||||
|
/** 签约合同最多可上传的照片 / PDF 数量 */
|
||||||
|
export const MAX_CONTRACT_COUNT = 20;
|
||||||
|
|
||||||
export function normalizeStringArray(urls: unknown, minLen: number): string[] {
|
export function normalizeStringArray(urls: unknown, minLen: number): string[] {
|
||||||
const arr = Array.isArray(urls) ? urls.map((u) => String(u ?? '')) : [];
|
const arr = Array.isArray(urls) ? urls.map((u) => String(u ?? '')) : [];
|
||||||
while (arr.length < minLen) arr.push('');
|
while (arr.length < minLen) arr.push('');
|
||||||
@@ -118,6 +122,22 @@ export function normalizeStoreDraftForm(raw: Partial<StoreDraftForm> | null | un
|
|||||||
closeTime2: String(raw.closeTime2 ?? base.closeTime2),
|
closeTime2: String(raw.closeTime2 ?? base.closeTime2),
|
||||||
avgPrice: String(raw.avgPrice ?? base.avgPrice),
|
avgPrice: String(raw.avgPrice ?? base.avgPrice),
|
||||||
envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, MIN_ENV_PHOTO_COUNT),
|
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)
|
packages: Array.isArray(raw.packages)
|
||||||
? raw.packages.map((p, i) => ({
|
? raw.packages.map((p, i) => ({
|
||||||
name: String((p as { name?: string }).name ?? ''),
|
name: String((p as { name?: string }).name ?? ''),
|
||||||
@@ -224,12 +244,13 @@ export function validateStoreStep1(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function validateStoreStep2(
|
export function validateStoreStep2(
|
||||||
form: Pick<StoreDraftForm, 'coverUrl' | 'envPhotoUrls' | 'contractUrl'>,
|
form: Pick<StoreDraftForm, 'coverUrl' | 'envPhotoUrls' | 'contractUrls'>,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!form.coverUrl.trim()) return '请上传门头照';
|
if (!form.coverUrl.trim()) return '请上传门头照';
|
||||||
const envCount = form.envPhotoUrls.filter((u) => u.trim()).length;
|
const envCount = form.envPhotoUrls.filter((u) => u.trim()).length;
|
||||||
if (envCount < MIN_ENV_PHOTO_COUNT) return `请上传至少 ${MIN_ENV_PHOTO_COUNT} 张环境照片`;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ import {
|
|||||||
validateStoreStep3,
|
validateStoreStep3,
|
||||||
|
|
||||||
MIN_ENV_PHOTO_COUNT,
|
MIN_ENV_PHOTO_COUNT,
|
||||||
|
|
||||||
|
MAX_CONTRACT_COUNT,
|
||||||
} from '../lib/storeDraft';
|
} from '../lib/storeDraft';
|
||||||
import StorePackagesForm from '../components/StorePackagesForm';
|
import StorePackagesForm from '../components/StorePackagesForm';
|
||||||
import { normalizePackageFormItems, validatePackageFormItems } from '../lib/storePackages';
|
import { normalizePackageFormItems, validatePackageFormItems } from '../lib/storePackages';
|
||||||
@@ -476,7 +478,11 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
const envPhotoUrls = Array.from(
|
const envPhotoUrls = Array.from(
|
||||||
new Set(form.envPhotoUrls.map((u) => u.trim()).filter(Boolean)),
|
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', {
|
const result = await request<{ store: { id: string } }>('PARTNER_H5', '/partner/stores', {
|
||||||
|
|
||||||
@@ -526,7 +532,7 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined,
|
envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined,
|
||||||
|
|
||||||
contractUrl: form.contractUrl.trim() || undefined,
|
contractUrls: contractUrls.length ? contractUrls : undefined,
|
||||||
|
|
||||||
bankAccountName: form.bankAccountName.trim(),
|
bankAccountName: form.bankAccountName.trim(),
|
||||||
|
|
||||||
@@ -1013,9 +1019,11 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
<h3 className="headline-md">签约合同 <span className="text-primary">*</span></h3>
|
<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"
|
bizType="STORE_CONTRACT"
|
||||||
|
|
||||||
@@ -1023,11 +1031,15 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
accept="image/*,.pdf"
|
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 { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
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 type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto';
|
||||||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||||
import { StoreCategoryService } from '../store/store-category.service';
|
import { StoreCategoryService } from '../store/store-category.service';
|
||||||
@@ -446,24 +447,23 @@ export class AdminStoresService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dto.contractUrl !== undefined) {
|
if (dto.contractUrls !== undefined || dto.contractUrl !== undefined) {
|
||||||
const contractUrl = dto.contractUrl?.trim() || '';
|
const contractUrls = normalizeContractUrls(dto.contractUrls, dto.contractUrl);
|
||||||
await tx.commonResource.updateMany({
|
await tx.commonResource.updateMany({
|
||||||
where: { ownerType: 'STORE', ownerId: id, bizType: 'CONTRACT', status: 'ACTIVE' },
|
where: { ownerType: 'STORE', ownerId: id, bizType: 'CONTRACT', status: 'ACTIVE' },
|
||||||
data: { status: 'DELETED' },
|
data: { status: 'DELETED' },
|
||||||
});
|
});
|
||||||
if (contractUrl) {
|
for (let i = 0; i < contractUrls.length; i++) {
|
||||||
const isPdf = /\.pdf(\?|$)/i.test(contractUrl);
|
|
||||||
await tx.commonResource.create({
|
await tx.commonResource.create({
|
||||||
data: {
|
data: {
|
||||||
ownerType: 'STORE',
|
ownerType: 'STORE',
|
||||||
ownerId: id,
|
ownerId: id,
|
||||||
bizType: 'CONTRACT',
|
bizType: 'CONTRACT',
|
||||||
mediaType: isPdf ? 'FILE' : 'IMAGE',
|
mediaType: contractMediaType(contractUrls[i]),
|
||||||
ossBucket: 'legacy',
|
ossBucket: 'legacy',
|
||||||
ossKey: contractUrl,
|
ossKey: contractUrls[i],
|
||||||
url: contractUrl,
|
url: contractUrls[i],
|
||||||
sortOrder: 0,
|
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({
|
await this.prisma.commonResource.create({
|
||||||
data: {
|
data: {
|
||||||
ownerType: 'STORE',
|
ownerType: 'STORE',
|
||||||
ownerId: store.id,
|
ownerId: store.id,
|
||||||
bizType: 'CONTRACT',
|
bizType: 'CONTRACT',
|
||||||
mediaType: 'FILE',
|
mediaType: contractMediaType(contractUrls[i]),
|
||||||
ossBucket: 'legacy',
|
ossBucket: 'legacy',
|
||||||
ossKey: dto.contractUrl,
|
ossKey: contractUrls[i],
|
||||||
url: dto.contractUrl,
|
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;
|
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: {
|
function mapAdminUserRow(u: {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
userNo: string;
|
userNo: string;
|
||||||
@@ -23,7 +45,7 @@ function mapAdminUserRow(u: {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
_count: { orders: number };
|
_count: { orders: number };
|
||||||
}) {
|
}, benefit: UserBenefitStat = EMPTY_BENEFIT_STAT) {
|
||||||
return {
|
return {
|
||||||
id: u.id,
|
id: u.id,
|
||||||
userNo: u.userNo,
|
userNo: u.userNo,
|
||||||
@@ -42,6 +64,7 @@ function mapAdminUserRow(u: {
|
|||||||
createdAt: u.createdAt,
|
createdAt: u.createdAt,
|
||||||
updatedAt: u.updatedAt,
|
updatedAt: u.updatedAt,
|
||||||
orderCount: u._count.orders,
|
orderCount: u._count.orders,
|
||||||
|
...benefit,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,14 +113,37 @@ export class AdminUsersService {
|
|||||||
this.prisma.user.count({ where }),
|
this.prisma.user.count({ where }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const benefitMap = await this.loadBenefitStats(items.map((u) => u.id));
|
||||||
|
|
||||||
return serializeBigInt({
|
return serializeBigInt({
|
||||||
items: items.map((u) => mapAdminUserRow(u)),
|
items: items.map((u) => mapAdminUserRow(u, benefitMap.get(u.id.toString()))),
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
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) {
|
async detail(id: bigint) {
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -128,8 +174,11 @@ export class AdminUsersService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const benefitMap = await this.loadBenefitStats([user.id]);
|
||||||
|
|
||||||
return serializeBigInt({
|
return serializeBigInt({
|
||||||
...user,
|
...user,
|
||||||
|
...(benefitMap.get(user.id.toString()) ?? EMPTY_BENEFIT_STAT),
|
||||||
wechatVerified: !!user.wxOpenId,
|
wechatVerified: !!user.wxOpenId,
|
||||||
mergedFromCount: user._count.mergedFrom,
|
mergedFromCount: user._count.mergedFrom,
|
||||||
orderCount: user._count.orders,
|
orderCount: user._count.orders,
|
||||||
|
|||||||
@@ -105,10 +105,18 @@ export class CreateStoreDto {
|
|||||||
@ArrayMaxSize(20)
|
@ArrayMaxSize(20)
|
||||||
envPhotoUrls?: string[];
|
envPhotoUrls?: string[];
|
||||||
|
|
||||||
|
/** @deprecated 用 contractUrls;保留兼容旧客户端 */
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
contractUrl?: string;
|
contractUrl?: string;
|
||||||
|
|
||||||
|
/** 签约合同(支持多张照片 / PDF) */
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
@ArrayMaxSize(20)
|
||||||
|
contractUrls?: string[];
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
@@ -190,10 +198,18 @@ export class UpdateStoreDto {
|
|||||||
@ArrayMaxSize(20)
|
@ArrayMaxSize(20)
|
||||||
envPhotoUrls?: string[];
|
envPhotoUrls?: string[];
|
||||||
|
|
||||||
|
/** @deprecated 用 contractUrls;保留兼容旧客户端 */
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
contractUrl?: string | null;
|
contractUrl?: string | null;
|
||||||
|
|
||||||
|
/** 签约合同(支持多张照片 / PDF),传空数组即清空 */
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
@ArrayMaxSize(20)
|
||||||
|
contractUrls?: string[];
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
address?: string;
|
address?: string;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { PrismaService } from '../../common/prisma/prisma.module';
|
|||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
||||||
import { parseBigIntParam } from '../../common/parse-bigint';
|
import { parseBigIntParam } from '../../common/parse-bigint';
|
||||||
|
import { contractMediaType, normalizeContractUrls } from '../../common/store-media/contract-urls.util';
|
||||||
import { AnalyticsService } from '../analytics/analytics.service';
|
import { AnalyticsService } from '../analytics/analytics.service';
|
||||||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||||
import { AuthService } from '../iam/auth.service';
|
import { AuthService } from '../iam/auth.service';
|
||||||
@@ -377,11 +378,14 @@ export class StoreService {
|
|||||||
const city = await this.resolvePartnerCity(partnerAccountId, body.cityId);
|
const city = await this.resolvePartnerCity(partnerAccountId, body.cityId);
|
||||||
const coverUrl = body.coverUrl ? String(body.coverUrl).trim() : '';
|
const coverUrl = body.coverUrl ? String(body.coverUrl).trim() : '';
|
||||||
const envPhotoUrls = this.normalizeEnvPhotoUrls(body.envPhotoUrls);
|
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 (!coverUrl) throw new BadRequestException('请上传门头照');
|
||||||
if (envPhotoUrls.length < 3) throw new BadRequestException('请上传至少 3 张环境照片');
|
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 bankAccountName = body.bankAccountName ? String(body.bankAccountName) : null;
|
||||||
const bankAccountNo = body.bankAccountNo ? String(body.bankAccountNo) : 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({
|
await this.prisma.commonResource.create({
|
||||||
data: {
|
data: {
|
||||||
ownerType: 'STORE',
|
ownerType: 'STORE',
|
||||||
ownerId: store.id,
|
ownerId: store.id,
|
||||||
bizType: 'CONTRACT',
|
bizType: 'CONTRACT',
|
||||||
mediaType: 'FILE',
|
mediaType: contractMediaType(contractUrls[i]),
|
||||||
ossBucket,
|
ossBucket,
|
||||||
ossKey: contractUrl,
|
ossKey: contractUrls[i],
|
||||||
url: contractUrl,
|
url: contractUrls[i],
|
||||||
|
sortOrder: i,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user