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>
@@ -1,6 +1,6 @@
import { useState } from 'react';
import type { PackageFormItem } from '../lib/storePackages';
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
import { STORE_PACKAGE_IMAGE_MAX_COUNT, STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
import { emptyPackage } from '../lib/storePackages';
import OssUploadField from './OssUploadField';
@@ -131,13 +131,70 @@ export default function StorePackagesForm({ items, onChange, disabled, embedded
</div>
<div className="partner-field">
<label></label>
<OssUploadField
bizType="STORE_PACKAGE"
value={item.imageUrl || ''}
disabled={disabled}
onChange={(url) => updateAt(index, { imageUrl: url })}
/>
<label> {STORE_PACKAGE_IMAGE_MAX_COUNT} </label>
{(() => {
const slots =
Array.isArray(item.imageUrls) && item.imageUrls.length > 0
? item.imageUrls.map((u) => String(u ?? ''))
: item.imageUrl
? [String(item.imageUrl)]
: [''];
const filled = slots.map((u) => u.trim()).filter(Boolean);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{slots.map((url, imgIndex) => (
<div key={`${imgIndex}-${url || 'empty'}`}>
<OssUploadField
bizType="STORE_PACKAGE"
value={url || ''}
onChange={(nextUrl) => {
const next = [...slots];
next[imgIndex] = nextUrl?.trim() || '';
const cleaned = next.map((u) => u.trim()).filter(Boolean);
updateAt(index, {
imageUrls: next,
imageUrl: cleaned[0] ?? '',
});
}}
/>
{!disabled && (filled.length > 0 || url) ? (
<button
type="button"
className="partner-packages-remove"
style={{ marginTop: 6 }}
onClick={() => {
const next = slots.filter((_, i) => i !== imgIndex);
const cleaned = next.map((u) => u.trim()).filter(Boolean);
updateAt(index, {
imageUrls: next.length ? next : [],
imageUrl: cleaned[0] ?? '',
});
}}
>
</button>
) : null}
</div>
))}
{!disabled && filled.length < STORE_PACKAGE_IMAGE_MAX_COUNT && slots.length < STORE_PACKAGE_IMAGE_MAX_COUNT ? (
<button
type="button"
className="partner-packages-add"
style={{ marginTop: 0 }}
onClick={() =>
updateAt(index, {
imageUrls: [...slots, ''],
imageUrl: filled[0] ?? '',
})
}
>
<span className="material-symbols-outlined">add_a_photo</span>
{filled.length}/{STORE_PACKAGE_IMAGE_MAX_COUNT}
</button>
) : null}
</div>
);
})()}
</div>
<div className="partner-field">
+28 -11
View File
@@ -1,5 +1,9 @@
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';
export type PackageFormItem = StorePackageItemDto;
@@ -11,24 +15,34 @@ export function emptyPackage(index = 0): PackageFormItem {
usableTime: '',
otherNotes: '',
imageUrl: '',
imageUrls: [],
sortOrder: index,
};
}
export function normalizePackageFormItems(raw: PackageFormItem[]): PackageFormItem[] {
return raw
.map((item, index) => ({
name: item.name.trim(),
price: item.price.trim(),
dishes: item.dishes.trim(),
usableTime: item.usableTime?.trim() || '',
otherNotes: item.otherNotes?.trim() || '',
imageUrl: item.imageUrl?.trim() || '',
sortOrder: index,
}))
.map((item, index) => {
const imageUrls = normalizeStorePackageImageUrls(item);
return {
name: item.name.trim(),
price: item.price.trim(),
dishes: item.dishes.trim(),
usableTime: item.usableTime?.trim() || '',
otherNotes: item.otherNotes?.trim() || '',
imageUrl: imageUrls[0] ?? '',
imageUrls,
sortOrder: index,
};
})
.filter(
(item) =>
item.name || item.price || item.dishes || item.usableTime || item.otherNotes || item.imageUrl,
item.name ||
item.price ||
item.dishes ||
item.usableTime ||
item.otherNotes ||
item.imageUrls.length > 0,
);
}
@@ -43,6 +57,9 @@ export function validatePackageFormItems(items: PackageFormItem[]): string | nul
const price = Number(item.price);
if (!Number.isFinite(price) || price < 0) return `${i + 1} 条套餐价格须为非负数字`;
if (!item.dishes) return `${i + 1} 条套餐菜品不能为空`;
if ((item.imageUrls?.length ?? 0) > STORE_PACKAGE_IMAGE_MAX_COUNT) {
return `${i + 1} 条套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT}`;
}
}
return null;
}
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import type { StorePackagesResponse } from '@dukang/shared-types';
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
import StorePackagesForm from '../components/StorePackagesForm';
import { request } from '../lib/api';
import { toastError, toastSuccess } from '../lib/toast';
@@ -30,7 +31,18 @@ export default function StorePackagesPage() {
: data.live?.length
? data.live
: [emptyPackage()];
setItems(base.map((p, i) => ({ ...p, price: String(p.price), sortOrder: i })));
setItems(
base.map((p, i) => {
const imageUrls = normalizeStorePackageImageUrls(p);
return {
...p,
price: String(p.price),
imageUrl: imageUrls[0] ?? '',
imageUrls,
sortOrder: i,
};
}),
);
setPending(data.pendingRequest ?? null);
})
.catch((e) => setError(e instanceof Error ? e.message : '加载失败'))
+106 -41
View File
@@ -1,5 +1,8 @@
import { useRef, useState } from 'react';
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
import {
STORE_PACKAGE_IMAGE_MAX_COUNT,
STORE_PACKAGE_MAX_COUNT,
} from '@dukang/shared-types';
import { emptyPackage, type PackageFormItem } from '../lib/storePackages';
import { uploadFileToOss } from '../lib/upload';
@@ -11,17 +14,29 @@ type Props = {
export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
const [collapsed, setCollapsed] = useState<Record<number, boolean>>({});
const [uploadingIndex, setUploadingIndex] = useState<number | null>(null);
const fileRefs = useRef<Record<number, HTMLInputElement | null>>({});
const [uploadingKey, setUploadingKey] = useState<string | null>(null);
const fileRefs = useRef<Record<string, HTMLInputElement | null>>({});
async function pickPackageImage(index: number, file?: File | null) {
async function pickPackageImage(pkgIndex: number, imgIndex: number, file?: File | null) {
if (!file || disabled) return;
setUploadingIndex(index);
const key = `${pkgIndex}-${imgIndex}`;
setUploadingKey(key);
try {
const result = await uploadFileToOss(file, 'STORE_PACKAGE');
updateAt(index, { imageUrl: result.url });
const current = items[pkgIndex];
const imageUrls =
Array.isArray(current.imageUrls) && current.imageUrls.length > 0
? current.imageUrls.map((u) => String(u ?? ''))
: current.imageUrl
? [String(current.imageUrl)]
: [];
const next = [...imageUrls];
if (imgIndex < next.length) next[imgIndex] = result.url;
else next.push(result.url);
const cleaned = next.map((u) => u.trim()).filter(Boolean);
updateAt(pkgIndex, { imageUrls: next, imageUrl: cleaned[0] ?? '' });
} finally {
setUploadingIndex(null);
setUploadingKey(null);
}
}
@@ -58,6 +73,13 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
{list.map((item, index) => {
const isCollapsed = !!collapsed[index];
const displayName = item.name.trim() || `套餐 ${index + 1}`;
const imageUrls = Array.isArray(item.imageUrls) && item.imageUrls.length > 0
? item.imageUrls.map((u) => String(u ?? ''))
: item.imageUrl
? [String(item.imageUrl)]
: [''];
const slots = imageUrls;
const filled = slots.map((u) => u.trim()).filter(Boolean);
return (
<section key={index} className={`shop-packages-card${isCollapsed ? ' shop-packages-card--collapsed' : ''}`}>
<div className="shop-packages-card-head">
@@ -83,7 +105,7 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
{!isCollapsed ? (
<>
<label className="shop-packages-field">
<span className="shop-packages-label"> *</span>
<span className="shop-packages-label"></span>
<input
className="shop-packages-input"
placeholder="如:套餐A"
@@ -94,12 +116,12 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
</label>
<label className="shop-packages-field">
<span className="shop-packages-label"> *</span>
<span className="shop-packages-label"></span>
<input
className="shop-packages-input"
type="number"
min={0}
step={0.01}
step="0.01"
placeholder="198"
value={item.price}
disabled={disabled}
@@ -108,10 +130,10 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
</label>
<label className="shop-packages-field">
<span className="shop-packages-label"> *</span>
<span className="shop-packages-label"></span>
<textarea
className="shop-packages-textarea"
rows={3}
className="shop-packages-input"
rows={2}
placeholder="红烧肉、红烧鱼、油焖茄子"
value={item.dishes}
disabled={disabled}
@@ -130,35 +152,78 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
/>
</label>
<label className="shop-packages-field">
<span className="shop-packages-label"></span>
{item.imageUrl ? (
<img
src={item.imageUrl}
alt=""
style={{ width: '100%', maxHeight: 160, objectFit: 'cover', borderRadius: 8, marginBottom: 8 }}
/>
<div className="shop-packages-field">
<span className="shop-packages-label"> {STORE_PACKAGE_IMAGE_MAX_COUNT} </span>
{slots.map((url, imgIndex) => {
const key = `${index}-${imgIndex}`;
return (
<div key={key} style={{ marginBottom: 12 }}>
{url ? (
<img
src={url}
alt=""
style={{
width: '100%',
maxHeight: 160,
objectFit: 'cover',
borderRadius: 8,
marginBottom: 8,
}}
/>
) : null}
<input
ref={(el) => {
fileRefs.current[key] = el;
}}
type="file"
accept="image/*"
hidden
disabled={disabled}
onChange={(e) => void pickPackageImage(index, imgIndex, e.target.files?.[0])}
/>
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
className="shop-packages-add"
style={{ marginTop: 0, flex: 1 }}
disabled={disabled || uploadingKey === key}
onClick={() => fileRefs.current[key]?.click()}
>
{uploadingKey === key ? '上传中…' : url ? '更换图片' : '上传图片'}
</button>
{!disabled && (filled.length > 0 || url) ? (
<button
type="button"
className="shop-packages-remove"
onClick={() => {
const next = slots.filter((_, i) => i !== imgIndex);
const cleaned = next.map((u) => u.trim()).filter(Boolean);
updateAt(index, { imageUrls: next, imageUrl: cleaned[0] ?? '' });
}}
>
</button>
) : null}
</div>
</div>
);
})}
{!disabled && filled.length < STORE_PACKAGE_IMAGE_MAX_COUNT && slots.length < STORE_PACKAGE_IMAGE_MAX_COUNT ? (
<button
type="button"
className="shop-packages-add"
style={{ marginTop: 0 }}
onClick={() =>
updateAt(index, {
imageUrls: [...slots, ''],
imageUrl: filled[0] ?? '',
})
}
>
{filled.length}/{STORE_PACKAGE_IMAGE_MAX_COUNT}
</button>
) : null}
<input
ref={(el) => {
fileRefs.current[index] = el;
}}
type="file"
accept="image/*"
hidden
disabled={disabled}
onChange={(e) => void pickPackageImage(index, e.target.files?.[0])}
/>
<button
type="button"
className="shop-packages-add"
style={{ marginTop: 0 }}
disabled={disabled || uploadingIndex === index}
onClick={() => fileRefs.current[index]?.click()}
>
{uploadingIndex === index ? '上传中…' : item.imageUrl ? '更换图片' : '上传图片'}
</button>
</label>
</div>
<label className="shop-packages-field">
<span className="shop-packages-label"></span>
+34 -11
View File
@@ -1,5 +1,9 @@
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';
export type PackageFormItem = StorePackageItemDto;
@@ -11,24 +15,34 @@ export function emptyPackage(index = 0): PackageFormItem {
usableTime: '',
otherNotes: '',
imageUrl: '',
imageUrls: [],
sortOrder: index,
};
}
export function normalizePackageFormItems(raw: PackageFormItem[]): PackageFormItem[] {
return raw
.map((item, index) => ({
name: item.name.trim(),
price: item.price.trim(),
dishes: item.dishes.trim(),
usableTime: item.usableTime?.trim() || '',
otherNotes: item.otherNotes?.trim() || '',
imageUrl: item.imageUrl?.trim() || '',
sortOrder: index,
}))
.map((item, index) => {
const imageUrls = normalizeStorePackageImageUrls(item);
return {
name: item.name.trim(),
price: item.price.trim(),
dishes: item.dishes.trim(),
usableTime: item.usableTime?.trim() || '',
otherNotes: item.otherNotes?.trim() || '',
imageUrl: imageUrls[0] ?? '',
imageUrls,
sortOrder: index,
};
})
.filter(
(item) =>
item.name || item.price || item.dishes || item.usableTime || item.otherNotes || item.imageUrl,
item.name ||
item.price ||
item.dishes ||
item.usableTime ||
item.otherNotes ||
item.imageUrls.length > 0,
);
}
@@ -43,6 +57,15 @@ export function validatePackageFormItems(items: PackageFormItem[]): string | nul
const price = Number(item.price);
if (!Number.isFinite(price) || price < 0) return `${i + 1} 条套餐价格须为非负数字`;
if (!item.dishes) return `${i + 1} 条套餐菜品不能为空`;
if ((item.imageUrls?.length ?? 0) > STORE_PACKAGE_IMAGE_MAX_COUNT) {
return `${i + 1} 条套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT}`;
}
}
return null;
}
export function formatPackagePrice(price: string | number) {
const n = typeof price === 'number' ? price : Number(price);
if (!Number.isFinite(n)) return String(price);
return n % 1 === 0 ? String(n) : n.toFixed(2);
}
+13 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import type { StorePackagesResponse } from '@dukang/shared-types';
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import ShopPackagesForm from '../components/ShopPackagesForm';
import { request } from '../lib/api';
@@ -30,7 +31,18 @@ export default function PackagesPage() {
: data.live?.length
? data.live
: [emptyPackage()];
setItems(base.map((p, i) => ({ ...p, price: String(p.price), sortOrder: i })));
setItems(
base.map((p, i) => {
const imageUrls = normalizeStorePackageImageUrls(p);
return {
...p,
price: String(p.price),
imageUrl: imageUrls[0] ?? '',
imageUrls,
sortOrder: i,
};
}),
);
setPending(data.pendingRequest ?? null);
setMsg('');
} catch (e) {
@@ -1,6 +1,7 @@
import { useCallback, useState } from 'react';
import { View, Text, Image } from '@tarojs/components';
import Taro, { useLoad, useRouter } from '@tarojs/taro';
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
import PageShell from '../../components/PageShell';
import PageNavBar from '../../components/PageNavBar';
import { request, toast } from '../../lib/api';
@@ -12,6 +13,7 @@ type StorePackage = {
usableTime?: string | null;
otherNotes?: string | null;
imageUrl?: string | null;
imageUrls?: string[] | null;
};
type Store = {
@@ -97,8 +99,8 @@ export default function StorePackageDetailPage() {
}
}
function previewImage(url: string) {
Taro.previewImage({ urls: [url], current: url }).catch(() => toast('无法预览图片'));
function previewImage(urls: string[], current: string) {
Taro.previewImage({ urls, current }).catch(() => toast('无法预览图片'));
}
if (loading) {
@@ -119,7 +121,7 @@ export default function StorePackageDetailPage() {
);
}
const imageUrl = (pkg.imageUrl || '').trim();
const imageUrls = normalizeStorePackageImageUrls(pkg);
return (
<PageShell variant="scroll" className="store-package-detail-page">
@@ -137,18 +139,19 @@ export default function StorePackageDetailPage() {
) : null}
</View>
{imageUrl ? (
{imageUrls.map((url) => (
<View
key={url}
className="store-package-detail-photo"
onClick={() => previewImage(imageUrl)}
onClick={() => previewImage(imageUrls, url)}
>
<Image
className="store-package-detail-photo-image"
src={imageUrl}
src={url}
mode="widthFix"
/>
</View>
) : null}
))}
<View className="store-package-detail-content">
<View className="store-detail-package-field">
@@ -5,7 +5,10 @@ export interface StorePackageItemDto {
dishes: string;
usableTime?: string | null;
otherNotes?: string | null;
/** 首图(兼容旧字段;多图时等于 imageUrls[0] */
imageUrl?: string | null;
/** 套餐图片列表,最多 STORE_PACKAGE_IMAGE_MAX_COUNT 张 */
imageUrls?: string[] | null;
sortOrder: number;
}
@@ -25,6 +28,32 @@ export const STORE_PACKAGE_CHANGE_STATUS_LABELS: Record<StorePackageChangeStatus
export const STORE_PACKAGE_MAX_COUNT = 10;
/** 单条套餐最多上传图片数 */
export const STORE_PACKAGE_IMAGE_MAX_COUNT = 20;
/** 门店环境照最多张数(总部/合伙人上传) */
export const STORE_ENV_PHOTO_MAX_COUNT = 20;
/** 归一化套餐图片:兼容 imageUrl / imageUrls,去重后截断上限 */
export function normalizeStorePackageImageUrls(input: {
imageUrl?: string | null;
imageUrls?: unknown;
}): string[] {
const seen = new Set<string>();
const out: string[] = [];
const push = (raw: unknown) => {
const url = String(raw ?? '').trim();
if (!url || seen.has(url)) return;
seen.add(url);
out.push(url);
};
if (Array.isArray(input.imageUrls)) {
for (const item of input.imageUrls) push(item);
}
if (out.length === 0) push(input.imageUrl);
return out.slice(0, STORE_PACKAGE_IMAGE_MAX_COUNT);
}
export interface StorePackagesResponse {
live: StorePackageViewDto[];
pendingRequest?: {
+2
View File
@@ -1325,6 +1325,8 @@ model StorePackage {
usableTime String? @map("usable_time") @db.VarChar(256)
otherNotes String? @map("other_notes") @db.VarChar(512)
imageUrl String? @map("image_url") @db.VarChar(512)
/// 套餐多图 URL 列表(JSON string[]),最多 20 张;imageUrl 同步为首图
imageUrls Json? @map("image_urls")
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@ -373,11 +373,24 @@ export class AdminStoresService {
// 分实体手机号已废弃,忽略写入
}
if (dto.coverUrl) {
if (current.coverResourceId) {
if (dto.coverUrl !== undefined) {
const coverUrl = dto.coverUrl?.trim() || '';
if (!coverUrl) {
if (current.coverResourceId) {
await tx.commonResource.update({
where: { id: current.coverResourceId },
data: { status: 'DELETED' },
});
await tx.store.update({ where: { id }, data: { coverResourceId: null } });
}
await tx.commonResource.updateMany({
where: { ownerType: 'STORE', ownerId: id, bizType: 'COVER', status: 'ACTIVE' },
data: { status: 'DELETED' },
});
} else if (current.coverResourceId) {
await tx.commonResource.update({
where: { id: current.coverResourceId },
data: { url: dto.coverUrl, ossKey: dto.coverUrl },
data: { url: coverUrl, ossKey: coverUrl, status: 'ACTIVE' },
});
} else {
const cover = await tx.commonResource.create({
@@ -387,14 +400,61 @@ export class AdminStoresService {
bizType: 'COVER',
mediaType: 'IMAGE',
ossBucket: 'legacy',
ossKey: dto.coverUrl,
url: dto.coverUrl,
ossKey: coverUrl,
url: coverUrl,
},
});
await tx.store.update({ where: { id }, data: { coverResourceId: cover.id } });
}
}
if (dto.envPhotoUrls !== undefined) {
const envUrls = [...new Set(
(dto.envPhotoUrls ?? []).map((u) => String(u ?? '').trim()).filter(Boolean),
)].slice(0, 20);
await tx.commonResource.updateMany({
where: { ownerType: 'STORE', ownerId: id, bizType: 'ENV', status: 'ACTIVE' },
data: { status: 'DELETED' },
});
for (let i = 0; i < envUrls.length; i++) {
await tx.commonResource.create({
data: {
ownerType: 'STORE',
ownerId: id,
bizType: 'ENV',
mediaType: 'IMAGE',
ossBucket: 'legacy',
ossKey: envUrls[i],
url: envUrls[i],
sortOrder: i,
},
});
}
}
if (dto.contractUrl !== undefined) {
const contractUrl = dto.contractUrl?.trim() || '';
await tx.commonResource.updateMany({
where: { ownerType: 'STORE', ownerId: id, bizType: 'CONTRACT', status: 'ACTIVE' },
data: { status: 'DELETED' },
});
if (contractUrl) {
const isPdf = /\.pdf(\?|$)/i.test(contractUrl);
await tx.commonResource.create({
data: {
ownerType: 'STORE',
ownerId: id,
bizType: 'CONTRACT',
mediaType: isPdf ? 'FILE' : 'IMAGE',
ossBucket: 'legacy',
ossKey: contractUrl,
url: contractUrl,
sortOrder: 0,
},
});
}
}
if (primaryBinding?.storeAccount) {
const account = primaryBinding.storeAccount;
const accountData: {
@@ -545,7 +605,7 @@ export class AdminStoresService {
await this.prisma.store.update({ where: { id: store.id }, data: { coverResourceId: cover.id } });
}
const envUrls = (dto.envPhotoUrls ?? []).filter(Boolean);
const envUrls = [...new Set((dto.envPhotoUrls ?? []).map((u) => String(u ?? '').trim()).filter(Boolean))].slice(0, 20);
for (let i = 0; i < envUrls.length; i++) {
await this.prisma.commonResource.create({
data: {
@@ -1,5 +1,6 @@
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsIn,
@@ -96,6 +97,7 @@ export class CreateStoreDto {
@IsOptional()
@IsArray()
@IsString({ each: true })
@ArrayMaxSize(20)
envPhotoUrls?: string[];
@IsOptional()
@@ -164,7 +166,17 @@ export class UpdateStoreDto {
@IsOptional()
@IsString()
coverUrl?: string;
coverUrl?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
@ArrayMaxSize(20)
envPhotoUrls?: string[];
@IsOptional()
@IsString()
contractUrl?: string | null;
@IsOptional()
@IsString()
@@ -4,7 +4,12 @@ import {
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { STORE_PACKAGE_MAX_COUNT, type StorePackageItemDto } from '@dukang/shared-types';
import {
STORE_PACKAGE_IMAGE_MAX_COUNT,
STORE_PACKAGE_MAX_COUNT,
normalizeStorePackageImageUrls,
type StorePackageItemDto,
} from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { StoreService } from './store.service';
@@ -44,9 +49,18 @@ export class StorePackageService {
const otherNotes = item.otherNotes != null && String(item.otherNotes).trim()
? String(item.otherNotes).trim()
: null;
const imageUrl = item.imageUrl != null && String(item.imageUrl).trim()
? String(item.imageUrl).trim()
: null;
const imageUrls = normalizeStorePackageImageUrls({
imageUrl: item.imageUrl as string | null | undefined,
imageUrls: item.imageUrls,
});
if (
Array.isArray(item.imageUrls) &&
item.imageUrls.filter((u) => String(u ?? '').trim()).length > STORE_PACKAGE_IMAGE_MAX_COUNT
) {
throw new BadRequestException(
`${index + 1} 条套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT}`,
);
}
const sortOrder = item.sortOrder != null ? Number(item.sortOrder) : index;
return {
name,
@@ -54,7 +68,8 @@ export class StorePackageService {
dishes,
usableTime,
otherNotes,
imageUrl,
imageUrl: imageUrls[0] ?? null,
imageUrls: imageUrls.length ? imageUrls : null,
sortOrder: Number.isFinite(sortOrder) ? sortOrder : index,
};
}
@@ -67,8 +82,13 @@ export class StorePackageService {
usableTime: string | null;
otherNotes: string | null;
imageUrl: string | null;
imageUrls: Prisma.JsonValue | null;
sortOrder: number;
}) {
const imageUrls = normalizeStorePackageImageUrls({
imageUrl: row.imageUrl,
imageUrls: row.imageUrls,
});
return {
id: row.id.toString(),
name: row.name,
@@ -76,7 +96,8 @@ export class StorePackageService {
dishes: row.dishes,
usableTime: row.usableTime,
otherNotes: row.otherNotes,
imageUrl: row.imageUrl,
imageUrl: imageUrls[0] ?? null,
imageUrls: imageUrls.length ? imageUrls : null,
sortOrder: row.sortOrder,
};
}
@@ -221,6 +242,9 @@ export class StorePackageService {
usableTime: pkg.usableTime ?? null,
otherNotes: pkg.otherNotes ?? null,
imageUrl: pkg.imageUrl ?? null,
imageUrls: pkg.imageUrls?.length
? (pkg.imageUrls as Prisma.InputJsonValue)
: Prisma.JsonNull,
sortOrder: pkg.sortOrder ?? index,
},
}),
@@ -6,6 +6,7 @@ import {
} from '@nestjs/common';
import { loadAppConfig, ClientApp, SmsScene } from '@dukang/shared-types';
import { PARTNER_STAFF_ROLE_LABELS, PartnerStaffRole, type PartnerLeaderboardPeriod } from '@dukang/shared-types';
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
import { validateBusinessHours } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
@@ -230,15 +231,22 @@ export class StoreService {
latitude: coords?.latitude ?? store.latitude,
longitude: coords?.longitude ?? store.longitude,
media,
packages: packageRows.map((p) => ({
name: p.name,
price: p.price.toFixed(2),
dishes: p.dishes,
usableTime: p.usableTime,
otherNotes: p.otherNotes,
imageUrl: p.imageUrl,
sortOrder: p.sortOrder,
})),
packages: packageRows.map((p) => {
const imageUrls = normalizeStorePackageImageUrls({
imageUrl: p.imageUrl,
imageUrls: p.imageUrls,
});
return {
name: p.name,
price: p.price.toFixed(2),
dishes: p.dishes,
usableTime: p.usableTime,
otherNotes: p.otherNotes,
imageUrl: imageUrls[0] ?? null,
imageUrls: imageUrls.length ? imageUrls : null,
sortOrder: p.sortOrder,
};
}),
}),
);
}
@@ -1292,6 +1300,7 @@ export class StoreService {
if (!url || seen.has(url)) continue;
seen.add(url);
urls.push(url);
if (urls.length >= 20) break;
}
return urls;
}
+1 -1
View File
@@ -20,7 +20,7 @@
| 3.4.12 | 08-04 | 退款回滚/财务/批量任务·工单/套餐 imageUrl | [`v3.4.12`](./杜康好客-v3.4.12-工单迭代开发文档.md) |
| 3.4.13 | 08-05 | metrics/核销详情/版本联动 PUBLISHED/mini 体验/H5 OAuth | [`v3.4.13`](./杜康好客-v3.4.13-体验优化开发文档.md) |
| 3.4.14 | 08-06 | mini-user 门头/套餐详情;小程序可配置;**统一测试白名单(不计账+限测可见+Mock旁路)** | [`v3.4.14`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) |
| 3.4.15 | 08-06 | mini-user 门店列表卡片:去核销改箭头、地址/距离/营业时间重排、营业中角标 | [`v3.4.15`](./杜康好客-v3.4.15-mini-user门店列表优化.md) |
| 3.4.15 | 08-07 | mini-user 门店列表卡片;HQ 门店照片替换/删除;套餐多图上限 20 | [`v3.4.15`](./杜康好客-v3.4.15-mini-user门店列表优化.md) |
---
+2 -1
View File
@@ -56,13 +56,14 @@
| 3.4.12 | [`工单迭代`](./杜康好客-v3.4.12-工单迭代开发文档.md) | ✅ |
| 3.4.13 | [`体验优化`](./杜康好客-v3.4.13-体验优化开发文档.md) | ✅ 生产 |
| 3.4.14 | [`mini-user 门店体验 + 小程序可配置 + 测试白名单`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) | 🔶 开发中 |
| 3.4.15 | [`mini-user 门店列表优化`](./杜康好客-v3.4.15-mini-user门店列表优化.md) | 🔶 开发中 |
| 3.4.15 | [`mini-user 门店列表 + HQ 照片/套餐多图`](./杜康好客-v3.4.15-mini-user门店列表优化.md) | 🔶 开发中 |
## 5. 变更记录
| 日期 | 说明 |
|------|------|
| 2026-08-07 | v3.4.14 增补统一测试白名单(不计账 / 限测可见 / Mock 旁路) |
| 2026-08-07 | v3.4.15 增补:HQ 门店照片替换/删除、套餐多图上限 20 |
| 2026-08-06 | v3.4.15 mini-user 门店列表卡片优化(开发中) |
| 2026-08-06 | v3.4.14 mini-user 门头/套餐详情(开发中) |
| 2026-08-05 | v3.4.13 |
@@ -1,21 +1,37 @@
# 杜康好客 · v3.4.15 mini-user 门店列表卡片优化
# 杜康好客 · v3.4.15 mini-user 门店列表 + HQ 门店照片 / 套餐多图
> **2026-08-06** · **开发中** · mini-user `3.4.15` · **未发版**
> **2026-08-07** · **开发中** · mini-user `3.4.15` · **未发版**
## 更新内容
### A. mini-user 门店列表卡片
- 门店列表卡片去掉「去核销」按钮,整卡最右侧改为小箭头(进入详情)
- 第 1 行:门店名单行截断(不显示省略号),宽度顶到文案区最右
- 第 2 行:地址最多两行;右侧显示距离
- 第 3 行:营业时间同行展示(多段时段空格拼接)
- 店铺封面右上角叠加斜角「营业中」标签图
### B. HQ 门店照片支持替换与删除
- 门店详情「审核材料」Tab:门头照 / 环境照 / 签约合同可上传替换、删除
- 环境照最多 **20** 张;保存后写回 `CommonResource`(软删旧图再写入)
- `PUT /admin/stores/:id` 支持 `coverUrl` / `envPhotoUrls` / `contractUrl`(空值=删除)
### C. 门店套餐图片最多 20 张
- 单条套餐支持多图:`imageUrls`JSON+ `imageUrl` 同步为首图(兼容旧数据)
- 上限 `STORE_PACKAGE_IMAGE_MAX_COUNT = 20`
- HQ / 合伙人 H5 / 门店 H5 均可增删换图;C 端套餐详情按列表展示并可预览
## 范围
| 项 | 交付 |
|----|------|
| 门店列表卡片 | 布局与交互如上 |
| 版本号 | `mini-user` `3.4.15``APP_VERSION` / package.json |
| HQ 门店照片 | 审核材料可替换/删除 |
| 套餐多图 | 最多 20 张 / 条 |
| 版本号 | `mini-user` `3.4.15` |
## ACC
@@ -24,3 +40,6 @@
- [ ] 地址 ≤2 行,距离在第二行右侧
- [ ] 营业时间单行;双时段同行显示
- [ ] 封面右上角可见「营业中」斜角标签
- [ ] HQ 门店详情可替换/删除门头照、环境照、合同并保存生效
- [ ] 环境照无法超过 20 张
- [ ] 套餐单条可上传至多 20 张图;C 端详情可浏览全部