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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 : '加载失败'))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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
@@ -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
@@ -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 端详情可浏览全部
|
||||
|
||||
Reference in New Issue
Block a user