merge(dev): 用户权益列 + 门店合同多图上传(需求1/2/3)
CI / verify (push) Has been cancelled

This commit is contained in:
2026-08-12 22:50:53 +08:00
13 changed files with 356 additions and 59 deletions
@@ -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>
+6
View File
@@ -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 = {
+2 -1
View File
@@ -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;
+35 -9
View File
@@ -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' }}>
+53 -1
View File
@@ -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>
+25 -4
View File
@@ -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;
} }
+19 -7
View File
@@ -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,
}, },
}); });
} }