diff --git a/apps/admin-web/src/components/AdminStorePackagesSection.tsx b/apps/admin-web/src/components/AdminStorePackagesSection.tsx
index 0fb8465..ae92c5d 100644
--- a/apps/admin-web/src/components/AdminStorePackagesSection.tsx
+++ b/apps/admin-web/src/components/AdminStorePackagesSection.tsx
@@ -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
- updateAt(index, { imageUrl: url })}
+
+ updateAt(index, {
+ imageUrls,
+ imageUrl: imageUrls[0] ?? '',
+ })
+ }
/>
diff --git a/apps/admin-web/src/components/PackageImagesUpload.tsx b/apps/admin-web/src/components/PackageImagesUpload.tsx
new file mode 100644
index 0000000..656f55a
--- /dev/null
+++ b/apps/admin-web/src/components/PackageImagesUpload.tsx
@@ -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 (
+
+
+ 最多 {STORE_PACKAGE_IMAGE_MAX_COUNT} 张,可替换或删除
+
+ {slots.map((url, index) => (
+
+
+ updateAt(index, next)}
+ />
+
+ {(urls.length > 0 || url) && (
+
+ )}
+
+ ))}
+ {urls.length < STORE_PACKAGE_IMAGE_MAX_COUNT ? (
+ } onClick={addSlot} block>
+ 添加图片({urls.filter(Boolean).length}/{STORE_PACKAGE_IMAGE_MAX_COUNT})
+
+ ) : null}
+ {urls.length > 1 ? (
+
+
+ {urls.map((u) => (
+
+ ))}
+
+
+ ) : null}
+
+ );
+}
diff --git a/apps/admin-web/src/pages/StoresPage.tsx b/apps/admin-web/src/pages/StoresPage.tsx
index d2815cc..29f8eda 100644
--- a/apps/admin-web/src/pages/StoresPage.tsx
+++ b/apps/admin-web/src/pages/StoresPage.tsx
@@ -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) {
const media = Array.isArray(detail.media) ? (detail.media as StoreMediaItem[]) : [];
const byType = (bizType: string) =>
@@ -119,161 +106,57 @@ function collectMediaUrls(detail: Record) {
};
}
-function StoreAuditMediaSection({ detail }: { detail: Record }) {
- const { covers, envs, contracts } = collectMediaUrls(detail);
- const [pdfUrl, setPdfUrl] = useState(null);
- const gallery = [...covers, ...envs].filter((item) => isImageMedia(item.url, item.mediaType));
-
- if (covers.length === 0 && envs.length === 0 && contracts.length === 0) {
- return (
-
- );
- }
-
+function StoreAuditMediaEditor() {
return (
-
- 审核材料
-
-
- {(covers.length > 0 || envs.length > 0) && (
-
-
- 门头照 / 环境照(点击可放大浏览)
-
-
-
- {gallery.map((item) => (
-
-
-
- {covers.some((c) => c.id === item.id) ? '门头照' : '环境照'}
-
-
- ))}
-
-
-
- )}
-
- {contracts.length > 0 && (
-
-
- 签约合同
-
-
- {contracts.map((item, index) => {
- const imageLike = isImageMedia(item.url, item.mediaType);
- const pdf = isPdfUrl(item.url);
- return (
-
+
+
+
+ 环境照片
+
+ 建议至少 3 张;可替换、删除,最多 20 张
+
+
+ {(fields, { add, remove }) => (
+
+ {fields.map((field, index) => (
+
+
- {imageLike ? (
-
-
-
- ) : (
-
-
-
- )}
-
-
- {RESOURCE_BIZ_TYPE_LABELS.CONTRACT || '合同'}
- {contracts.length > 1 ? ` ${index + 1}` : ''}
-
-
- {item.url}
-
-
- {imageLike ? (
- 点击缩略图放大查看
- ) : null}
- {pdf ? (
-
- ) : null}
- }
- style={{ padding: 0 }}
- href={item.url}
- target="_blank"
- rel="noreferrer"
- >
- 新窗口打开
-
-
-
-
- );
- })}
-
-
- )}
-
- setPdfUrl(null)}
- width={900}
- footer={[
- ,
- ,
- ]}
- destroyOnClose
- >
- {pdfUrl ? (
-
- ) : null}
-
+
+
+
+
+ ))}
+ {fields.length < 20 ? (
+
+ ) : (
+
已达上限 20 张
+ )}
+
+ )}
+
+
+
+
);
}
@@ -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: ,
+ forceRender: true,
+ children: ,
},
{
key: 'packages',
@@ -1366,19 +1263,28 @@ export default function StoresPage() {
环境照片
- 至少 3 张,可继续添加
+ 至少 3 张,可继续添加,最多 20 张
- {(fields, { add }) => (
+ {(fields, { add, remove }) => (
{fields.map((field, index) => (
-
-
-
+
+
+
+
+
+
))}
-
+ {fields.length < 20 ? (
+
+ ) : (
+
已达上限 20 张
+ )}
)}
diff --git a/apps/h5-partner/src/components/StorePackagesForm.tsx b/apps/h5-partner/src/components/StorePackagesForm.tsx
index 011e99c..962939a 100644
--- a/apps/h5-partner/src/components/StorePackagesForm.tsx
+++ b/apps/h5-partner/src/components/StorePackagesForm.tsx
@@ -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
-
-
updateAt(index, { imageUrl: url })}
- />
+
+ {(() => {
+ 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 (
+
+ {slots.map((url, imgIndex) => (
+
+ {
+ 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) ? (
+
+ ) : null}
+
+ ))}
+ {!disabled && filled.length < STORE_PACKAGE_IMAGE_MAX_COUNT && slots.length < STORE_PACKAGE_IMAGE_MAX_COUNT ? (
+
+ ) : null}
+
+ );
+ })()}
diff --git a/apps/h5-partner/src/lib/storePackages.ts b/apps/h5-partner/src/lib/storePackages.ts
index bd9e7d6..7a7f841 100644
--- a/apps/h5-partner/src/lib/storePackages.ts
+++ b/apps/h5-partner/src/lib/storePackages.ts
@@ -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;
}
diff --git a/apps/h5-partner/src/pages/StorePackagesPage.tsx b/apps/h5-partner/src/pages/StorePackagesPage.tsx
index d49c7d3..e975c72 100644
--- a/apps/h5-partner/src/pages/StorePackagesPage.tsx
+++ b/apps/h5-partner/src/pages/StorePackagesPage.tsx
@@ -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 : '加载失败'))
diff --git a/apps/h5-shop/src/components/ShopPackagesForm.tsx b/apps/h5-shop/src/components/ShopPackagesForm.tsx
index 812c7b6..a805655 100644
--- a/apps/h5-shop/src/components/ShopPackagesForm.tsx
+++ b/apps/h5-shop/src/components/ShopPackagesForm.tsx
@@ -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
>({});
- const [uploadingIndex, setUploadingIndex] = useState(null);
- const fileRefs = useRef>({});
+ const [uploadingKey, setUploadingKey] = useState(null);
+ const fileRefs = useRef>({});
- 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 (
@@ -83,7 +105,7 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
{!isCollapsed ? (
<>