feat(v3.4.15): HQ store media edit and package images up to 20

Allow HQ to replace/delete store photos; support multi-image packages with a 20-image cap.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-07 19:24:07 +08:00
parent 0692bebf07
commit 76375eff83
19 changed files with 657 additions and 302 deletions
@@ -2,13 +2,22 @@ import { useEffect, useState } from 'react';
import { Alert, Button, Form, Input, InputNumber, Modal, Space, Typography, message } from 'antd';
import { DownOutlined, UpOutlined } from '@ant-design/icons';
import type { StorePackageItemDto } from '@dukang/shared-types';
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
import { STORE_PACKAGE_IMAGE_MAX_COUNT, STORE_PACKAGE_MAX_COUNT, normalizeStorePackageImageUrls } from '@dukang/shared-types';
import { request } from '../lib/api';
import OssUpload from './OssUpload';
import PackageImagesUpload from './PackageImagesUpload';
type PackageRow = StorePackageItemDto;
function emptyRow(index = 0): PackageRow {
return { name: '', price: '0', dishes: '', usableTime: '', otherNotes: '', imageUrl: '', sortOrder: index };
return {
name: '',
price: '0',
dishes: '',
usableTime: '',
otherNotes: '',
imageUrl: '',
imageUrls: [],
sortOrder: index,
};
}
export default function AdminStorePackagesSection({ storeId }: { storeId: string }) {
@@ -23,7 +32,16 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
.then((data) => {
setItems(
data.live?.length
? data.live.map((p, i) => ({ ...p, price: String(p.price), sortOrder: i }))
? data.live.map((p, i) => {
const imageUrls = normalizeStorePackageImageUrls(p);
return {
...p,
price: String(p.price),
imageUrl: imageUrls[0] ?? '',
imageUrls,
sortOrder: i,
};
})
: [emptyRow()],
);
})
@@ -74,16 +92,20 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
async function save() {
const filled = items
.map((item, index) => ({
name: item.name.trim(),
price: item.price.trim(),
dishes: item.dishes.trim(),
usableTime: item.usableTime?.trim() || null,
otherNotes: item.otherNotes?.trim() || null,
imageUrl: item.imageUrl?.trim() || null,
sortOrder: index,
}))
.filter((item) => item.name || item.dishes || item.price);
.map((item, index) => {
const imageUrls = normalizeStorePackageImageUrls(item);
return {
name: item.name.trim(),
price: item.price.trim(),
dishes: item.dishes.trim(),
usableTime: item.usableTime?.trim() || null,
otherNotes: item.otherNotes?.trim() || null,
imageUrl: imageUrls[0] ?? null,
imageUrls: imageUrls.length ? imageUrls : null,
sortOrder: index,
};
})
.filter((item) => item.name || item.dishes || item.price || (item.imageUrls?.length ?? 0) > 0);
for (let i = 0; i < filled.length; i++) {
const item = filled[i];
@@ -100,6 +122,10 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
message.warning(`${i + 1} 条套餐价格须为非负数字`);
return;
}
if ((item.imageUrls?.length ?? 0) > STORE_PACKAGE_IMAGE_MAX_COUNT) {
message.warning(`${i + 1} 条套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT}`);
return;
}
}
setSaving(true);
@@ -215,11 +241,14 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
</Form.Item>
<Form.Item label="套餐图片" style={{ marginBottom: 12 }}>
<OssUpload
bizType="STORE_PACKAGE"
mediaType="IMAGE"
value={item.imageUrl || undefined}
onChange={(url) => updateAt(index, { imageUrl: url })}
<PackageImagesUpload
value={normalizeStorePackageImageUrls(item)}
onChange={(imageUrls) =>
updateAt(index, {
imageUrls,
imageUrl: imageUrls[0] ?? '',
})
}
/>
</Form.Item>
@@ -0,0 +1,75 @@
import { Button, Image, Space, Typography } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { STORE_PACKAGE_IMAGE_MAX_COUNT } from '@dukang/shared-types';
import OssUpload from './OssUpload';
type Props = {
value?: string[];
onChange?: (urls: string[]) => void;
};
/** 套餐多图上传:最多 STORE_PACKAGE_IMAGE_MAX_COUNT 张,支持替换与删除 */
export default function PackageImagesUpload({ value, onChange }: Props) {
const urls = (value ?? []).map((u) => String(u || '').trim()).filter(Boolean);
function updateAt(index: number, url: string) {
const next = [...urls];
const trimmed = url.trim();
if (!trimmed) {
next.splice(index, 1);
} else {
next[index] = trimmed;
}
onChange?.(next);
}
function removeAt(index: number) {
onChange?.(urls.filter((_, i) => i !== index));
}
function addSlot() {
if (urls.length >= STORE_PACKAGE_IMAGE_MAX_COUNT) return;
onChange?.([...urls, '']);
}
const slots = urls.length ? urls : [''];
return (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Typography.Text type="secondary">
{STORE_PACKAGE_IMAGE_MAX_COUNT}
</Typography.Text>
{slots.map((url, index) => (
<div key={`${index}-${url || 'empty'}`} style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
<div style={{ flex: 1 }}>
<OssUpload
bizType="STORE_PACKAGE"
mediaType="IMAGE"
value={url || undefined}
onChange={(next) => updateAt(index, next)}
/>
</div>
{(urls.length > 0 || url) && (
<Button type="link" danger onClick={() => removeAt(index)} style={{ marginTop: 8 }}>
</Button>
)}
</div>
))}
{urls.length < STORE_PACKAGE_IMAGE_MAX_COUNT ? (
<Button type="dashed" icon={<PlusOutlined />} onClick={addSlot} block>
{urls.filter(Boolean).length}/{STORE_PACKAGE_IMAGE_MAX_COUNT}
</Button>
) : null}
{urls.length > 1 ? (
<Image.PreviewGroup>
<Space wrap size={8}>
{urls.map((u) => (
<Image key={u} src={u} width={64} height={64} style={{ objectFit: 'cover', borderRadius: 4 }} />
))}
</Space>
</Image.PreviewGroup>
) : null}
</Space>
);
}
+82 -176
View File
@@ -23,11 +23,10 @@ import {
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { FormInstance } from 'antd/es/form';
import { FilePdfOutlined, LinkOutlined, EnvironmentOutlined } from '@ant-design/icons';
import { EnvironmentOutlined } from '@ant-design/icons';
import { request, type Paginated } from '../lib/api';
import {
ADMIN_OPTIONS_PAGE_SIZE,
RESOURCE_BIZ_TYPE_LABELS,
STORE_AUDIT_STATUS_LABELS,
STORE_STATUS_LABELS,
fmtTime,
@@ -82,18 +81,6 @@ type StoreMediaItem = {
url?: string | null;
};
function isImageMedia(url: string, mediaType?: string) {
if (mediaType === 'IMAGE') return true;
if (mediaType === 'VIDEO' || mediaType === 'FILE') {
return /\.(png|jpe?g|gif|webp|bmp|heic)(\?|#|$)/i.test(url);
}
return /\.(png|jpe?g|gif|webp|bmp|heic)(\?|#|$)/i.test(url);
}
function isPdfUrl(url: string) {
return /\.pdf(\?|#|$)/i.test(url);
}
function collectMediaUrls(detail: Record<string, unknown>) {
const media = Array.isArray(detail.media) ? (detail.media as StoreMediaItem[]) : [];
const byType = (bizType: string) =>
@@ -119,161 +106,57 @@ function collectMediaUrls(detail: Record<string, unknown>) {
};
}
function StoreAuditMediaSection({ detail }: { detail: Record<string, unknown> }) {
const { covers, envs, contracts } = collectMediaUrls(detail);
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const gallery = [...covers, ...envs].filter((item) => isImageMedia(item.url, item.mediaType));
if (covers.length === 0 && envs.length === 0 && contracts.length === 0) {
return (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message="暂无门头照 / 环境照 / 签约合同,请谨慎审核"
/>
);
}
function StoreAuditMediaEditor() {
return (
<div style={{ marginBottom: 16 }}>
<Typography.Title level={5} style={{ marginTop: 0, marginBottom: 12 }}>
</Typography.Title>
{(covers.length > 0 || envs.length > 0) && (
<div style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
/
</Typography.Text>
<Image.PreviewGroup>
<Space wrap size={12}>
{gallery.map((item) => (
<div key={item.id} style={{ textAlign: 'center' }}>
<Image
src={item.url}
width={112}
height={84}
style={{ objectFit: 'cover', borderRadius: 6, border: '1px solid #f0f0f0' }}
/>
<div style={{ fontSize: 12, color: '#8c8c8c', marginTop: 4 }}>
{covers.some((c) => c.id === item.id) ? '门头照' : '环境照'}
</div>
</div>
))}
</Space>
</Image.PreviewGroup>
</div>
)}
{contracts.length > 0 && (
<div>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
</Typography.Text>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{contracts.map((item, index) => {
const imageLike = isImageMedia(item.url, item.mediaType);
const pdf = isPdfUrl(item.url);
return (
<div
key={item.id || `${item.url}-${index}`}
style={{
display: 'flex',
gap: 12,
alignItems: 'center',
padding: 12,
border: '1px solid #f0f0f0',
borderRadius: 8,
background: '#fafafa',
}}
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="可替换或删除门头照 / 环境照 / 签约合同,点击右上角「保存修改」后生效。环境照最多 20 张。"
/>
<Form.Item name="coverUrl" label="门头照">
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
</Form.Item>
<Typography.Text strong></Typography.Text>
<Typography.Paragraph type="secondary" style={{ marginTop: 4 }}>
3 20
</Typography.Paragraph>
<Form.List name="envPhotoUrls">
{(fields, { add, remove }) => (
<div style={{ marginTop: 8 }}>
{fields.map((field, index) => (
<div key={field.key} style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
<Form.Item
name={field.name}
label={`环境图 ${index + 1}`}
style={{ flex: 1, marginBottom: 12 }}
>
{imageLike ? (
<Image.PreviewGroup>
<Image
src={item.url}
width={96}
height={72}
style={{ objectFit: 'cover', borderRadius: 6 }}
/>
</Image.PreviewGroup>
) : (
<div
style={{
width: 96,
height: 72,
borderRadius: 6,
background: '#fff',
border: '1px dashed #d9d9d9',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#cf1322',
}}
>
<FilePdfOutlined style={{ fontSize: 28 }} />
</div>
)}
<Space direction="vertical" size={4} style={{ flex: 1, minWidth: 0 }}>
<Typography.Text strong>
{RESOURCE_BIZ_TYPE_LABELS.CONTRACT || '合同'}
{contracts.length > 1 ? ` ${index + 1}` : ''}
</Typography.Text>
<Typography.Text type="secondary" ellipsis style={{ maxWidth: '100%' }}>
{item.url}
</Typography.Text>
<Space wrap>
{imageLike ? (
<Typography.Text type="secondary"></Typography.Text>
) : null}
{pdf ? (
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => setPdfUrl(item.url)}>
PDF
</Button>
) : null}
<Button
type="link"
size="small"
icon={<LinkOutlined />}
style={{ padding: 0 }}
href={item.url}
target="_blank"
rel="noreferrer"
>
</Button>
</Space>
</Space>
</div>
);
})}
</Space>
</div>
)}
<Modal
title="合同预览"
open={!!pdfUrl}
onCancel={() => setPdfUrl(null)}
width={900}
footer={[
<Button key="open" href={pdfUrl || undefined} target="_blank" rel="noreferrer">
</Button>,
<Button key="close" type="primary" onClick={() => setPdfUrl(null)}>
</Button>,
]}
destroyOnClose
>
{pdfUrl ? (
<iframe
title="合同 PDF 预览"
src={pdfUrl}
style={{ width: '100%', height: '70vh', border: 'none', borderRadius: 8 }}
/>
) : null}
</Modal>
<OssUpload bizType="STORE_ENV" mediaType="IMAGE" />
</Form.Item>
<Button
type="link"
danger
style={{ marginTop: 30 }}
onClick={() => remove(field.name)}
>
</Button>
</div>
))}
{fields.length < 20 ? (
<Button type="dashed" onClick={() => add('')} block>
</Button>
) : (
<Typography.Text type="secondary"> 20 </Typography.Text>
)}
</div>
)}
</Form.List>
<Form.Item name="contractUrl" label="签约合同" style={{ marginTop: 16 }}>
<OssUpload bizType="STORE_CONTRACT" mediaType="FILE" accept="image/*,.pdf" />
</Form.Item>
</div>
);
}
@@ -518,6 +401,15 @@ export default function StoresPage() {
? String(d.benefitUsageRule)
: '',
coverUrl: d.coverUrl,
envPhotoUrls: (() => {
const { envs } = collectMediaUrls(d);
const urls = envs.map((item) => item.url).filter(Boolean);
return urls.length ? urls : [''];
})(),
contractUrl: (() => {
const { contracts } = collectMediaUrls(d);
return contracts[0]?.url || '';
})(),
province: d.province,
city: d.cityName,
district: d.district,
@@ -557,7 +449,11 @@ export default function StoresPage() {
const payload = {
name: v.name,
phone: v.phone,
coverUrl: v.coverUrl,
coverUrl: v.coverUrl ?? '',
envPhotoUrls: Array.isArray(v.envPhotoUrls)
? v.envPhotoUrls.map((u: string) => String(u || '').trim()).filter(Boolean)
: [],
contractUrl: v.contractUrl ?? '',
intro: v.intro,
benefitUsageRule:
typeof v.benefitUsageRule === 'string' &&
@@ -1174,7 +1070,8 @@ export default function StoresPage() {
{
key: 'media',
label: '审核材料',
children: <StoreAuditMediaSection detail={detail} />,
forceRender: true,
children: <StoreAuditMediaEditor />,
},
{
key: 'packages',
@@ -1366,19 +1263,28 @@ export default function StoresPage() {
</Form.Item>
<Typography.Text strong></Typography.Text>
<Typography.Paragraph type="secondary" style={{ marginTop: 4 }}>
3
3 20
</Typography.Paragraph>
<Form.List name="envPhotoUrls">
{(fields, { add }) => (
{(fields, { add, remove }) => (
<div style={{ marginTop: 8 }}>
{fields.map((field, index) => (
<Form.Item key={field.key} name={field.name} label={`环境图 ${index + 1}`}>
<OssUpload bizType="STORE_ENV" mediaType="IMAGE" />
</Form.Item>
<div key={field.key} style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
<Form.Item key={field.key} name={field.name} label={`环境图 ${index + 1}`} style={{ flex: 1 }}>
<OssUpload bizType="STORE_ENV" mediaType="IMAGE" />
</Form.Item>
<Button type="link" danger style={{ marginTop: 30 }} onClick={() => remove(field.name)}>
</Button>
</div>
))}
<Button type="dashed" onClick={() => add('')} block>
</Button>
{fields.length < 20 ? (
<Button type="dashed" onClick={() => add('')} block>
</Button>
) : (
<Typography.Text type="secondary"> 20 </Typography.Text>
)}
</div>
)}
</Form.List>