diff --git a/apps/admin-web/src/components/AdminStorePackagesSection.tsx b/apps/admin-web/src/components/AdminStorePackagesSection.tsx index 29ed6c8..df78188 100644 --- a/apps/admin-web/src/components/AdminStorePackagesSection.tsx +++ b/apps/admin-web/src/components/AdminStorePackagesSection.tsx @@ -1,12 +1,18 @@ -import { useEffect, useState } from 'react'; +import { forwardRef, useEffect, useImperativeHandle, useRef, 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_IMAGE_MAX_COUNT, STORE_PACKAGE_MAX_COUNT, normalizeStorePackageImageUrls } from '@dukang/shared-types'; import { request } from '../lib/api'; import PackageImagesUpload from './PackageImagesUpload'; + type PackageRow = StorePackageItemDto; +export type AdminStorePackagesHandle = { + /** 套餐已加载时写入;加载中则跳过,避免空数据覆盖线上套餐 */ + saveIfLoaded: (opts?: { quiet?: boolean }) => Promise<{ skipped: boolean }>; +}; + function emptyRow(index = 0): PackageRow { return { name: '', @@ -20,16 +26,136 @@ function emptyRow(index = 0): PackageRow { }; } -export default function AdminStorePackagesSection({ storeId }: { storeId: string }) { - const [items, setItems] = useState([emptyRow()]); - const [collapsed, setCollapsed] = useState>({}); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); +const AdminStorePackagesSection = forwardRef( + function AdminStorePackagesSection({ storeId }, ref) { + const [items, setItems] = useState([emptyRow()]); + const [collapsed, setCollapsed] = useState>({}); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const itemsRef = useRef(items); + const loadingRef = useRef(loading); - useEffect(() => { - setLoading(true); - request<{ live: PackageRow[] }>(`/admin/stores/${storeId}/packages`) - .then((data) => { + useEffect(() => { + itemsRef.current = items; + }, [items]); + + useEffect(() => { + loadingRef.current = loading; + }, [loading]); + + useEffect(() => { + setLoading(true); + request<{ live: PackageRow[] }>(`/admin/stores/${storeId}/packages`) + .then((data) => { + setItems( + data.live?.length + ? data.live.map((p, i) => { + const imageUrls = normalizeStorePackageImageUrls(p); + return { + ...p, + price: String(p.price), + imageUrl: imageUrls[0] ?? '', + imageUrls, + sortOrder: i, + }; + }) + : [emptyRow()], + ); + }) + .catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败')) + .finally(() => setLoading(false)); + }, [storeId]); + + function updateAt(index: number, patch: Partial) { + setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item))); + } + + function addRow() { + if (items.length >= STORE_PACKAGE_MAX_COUNT) return; + setItems((prev) => [...prev, emptyRow(prev.length)]); + } + + function removeAt(index: number) { + const run = () => { + setItems((prev) => { + const next = prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i })); + return next.length ? next : []; + }); + setCollapsed((prev) => { + const next: Record = {}; + Object.entries(prev).forEach(([k, v]) => { + const i = Number(k); + if (i < index) next[i] = v; + else if (i > index) next[i - 1] = v; + }); + return next; + }); + }; + if (items.length === 1) { + Modal.confirm({ + title: '清空门店套餐', + content: '删除最后一条套餐后,该门店将无展示套餐,确认继续?', + okText: '确认删除', + cancelText: '取消', + onOk: run, + }); + return; + } + run(); + } + + function toggleCollapse(index: number) { + setCollapsed((prev) => ({ ...prev, [index]: !prev[index] })); + } + + async function save(opts?: { quiet?: boolean }) { + const currentItems = itemsRef.current; + const filled = currentItems + .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, + sortOrder: index, + }; + }) + .filter((item) => item.name || item.dishes || item.price || item.imageUrls.length > 0); + + for (let i = 0; i < filled.length; i++) { + const item = filled[i]; + if (!item.name) { + message.warning(`第 ${i + 1} 条套餐名称不能为空`); + throw new Error('套餐校验失败'); + } + if (!item.dishes) { + message.warning(`第 ${i + 1} 条套餐菜品不能为空`); + throw new Error('套餐校验失败'); + } + const price = Number(item.price); + if (!Number.isFinite(price) || price < 0) { + message.warning(`第 ${i + 1} 条套餐价格须为非负数字`); + throw new Error('套餐校验失败'); + } + if ((item.imageUrls?.length ?? 0) > STORE_PACKAGE_IMAGE_MAX_COUNT) { + message.warning(`第 ${i + 1} 条套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT} 张`); + throw new Error('套餐校验失败'); + } + } + + setSaving(true); + try { + const data = await request<{ live: PackageRow[] }>(`/admin/stores/${storeId}/packages`, { + method: 'PUT', + body: JSON.stringify({ + packages: filled.map((p) => ({ ...p, price: Number(p.price).toFixed(2) })), + }), + }); + if (!opts?.quiet) message.success('套餐已保存并生效'); setItems( data.live?.length ? data.live.map((p, i) => { @@ -44,253 +170,161 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string }) : [emptyRow()], ); - }) - .catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败')) - .finally(() => setLoading(false)); - }, [storeId]); - - function updateAt(index: number, patch: Partial) { - setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item))); - } - - function addRow() { - if (items.length >= STORE_PACKAGE_MAX_COUNT) return; - setItems((prev) => [...prev, emptyRow(prev.length)]); - } - - function removeAt(index: number) { - const run = () => { - setItems((prev) => { - const next = prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i })); - return next.length ? next : []; - }); - setCollapsed((prev) => { - const next: Record = {}; - Object.entries(prev).forEach(([k, v]) => { - const i = Number(k); - if (i < index) next[i] = v; - else if (i > index) next[i - 1] = v; - }); - return next; - }); - }; - if (items.length === 1) { - Modal.confirm({ - title: '清空门店套餐', - content: '删除最后一条套餐后,该门店将无展示套餐,确认继续?', - okText: '确认删除', - cancelText: '取消', - onOk: run, - }); - return; - } - run(); - } - function toggleCollapse(index: number) { - setCollapsed((prev) => ({ ...prev, [index]: !prev[index] })); - } - - async function save() { - const filled = items - .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, - sortOrder: index, - }; - }) - .filter((item) => item.name || item.dishes || item.price || item.imageUrls.length > 0); - - for (let i = 0; i < filled.length; i++) { - const item = filled[i]; - if (!item.name) { - message.warning(`第 ${i + 1} 条套餐名称不能为空`); - return; - } - if (!item.dishes) { - message.warning(`第 ${i + 1} 条套餐菜品不能为空`); - return; - } - const price = Number(item.price); - if (!Number.isFinite(price) || price < 0) { - 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; + } catch (e) { + if (!opts?.quiet) message.error(e instanceof Error ? e.message : '保存失败'); + throw e; + } finally { + setSaving(false); } } - setSaving(true); - try { - const data = await request<{ live: PackageRow[] }>(`/admin/stores/${storeId}/packages`, { - method: 'PUT', - body: JSON.stringify({ - packages: filled.map((p) => ({ ...p, price: Number(p.price).toFixed(2) })), - }), - }); - message.success('套餐已保存并生效'); - setItems( - data.live?.length - ? data.live.map((p, i) => { - const imageUrls = normalizeStorePackageImageUrls(p); - return { - ...p, - price: String(p.price), - imageUrl: imageUrls[0] ?? '', - imageUrls, - sortOrder: i, - }; - }) - : [emptyRow()], + useImperativeHandle(ref, () => ({ + saveIfLoaded: async (opts) => { + if (loadingRef.current) return { skipped: true }; + await save(opts); + return { skipped: false }; + }, + })); + + if (loading) { + return 加载套餐中…; + } + + if (!items.length) { + return ( +
+ + + + ); - } catch (e) { - message.error(e instanceof Error ? e.message : '保存失败'); - } finally { - setSaving(false); } - } - if (loading) { - return 加载套餐中…; - } - - if (!items.length) { return (
- - - + {items.length > 0 ? ( + + ) : null} + + + {!isCollapsed ? ( + <> + + updateAt(index, { name: e.target.value })} + /> + + + + updateAt(index, { price: v != null ? String(v) : '' })} + /> + + + + updateAt(index, { dishes: e.target.value })} + /> + + + + updateAt(index, { usableTime: e.target.value })} + /> + + + + + updateAt(index, { + imageUrls, + imageUrl: imageUrls[0] ?? '', + }) + } + /> + + + + updateAt(index, { otherNotes: e.target.value })} + /> + + + ) : null} + + ); + })} + + {items.length < STORE_PACKAGE_MAX_COUNT ? ( + + ) : null} + + + 上传套餐图后请点右上角「保存修改」(会连同套餐一起保存),或点下方「保存套餐」。仅上传不保存,刷新会丢失。 + + + ); - } + }, +); - return (
- - - {items.map((item, index) => { - const isCollapsed = !!collapsed[index]; - const displayName = item.name.trim() || `套餐 ${index + 1}`; - return ( -
- - - {items.length > 0 ? ( - - ) : null} - - {!isCollapsed ? ( - <> - - updateAt(index, { name: e.target.value })} - /> - - - - updateAt(index, { price: v != null ? String(v) : '' })} - /> - - - - updateAt(index, { dishes: e.target.value })} - /> - - - - updateAt(index, { usableTime: e.target.value })} - /> - - - - - updateAt(index, { - imageUrls, - imageUrl: imageUrls[0] ?? '', - }) - } - /> - - - updateAt(index, { otherNotes: e.target.value })} - /> - - - ) : null} -
- ); - })} - - {items.length < STORE_PACKAGE_MAX_COUNT ? ( - - ) : null} - - - 上传图片后须点击下方「保存套餐」才会写入数据库;仅上传未保存,刷新后会丢失。 - - - - - ); -} +export default AdminStorePackagesSection; diff --git a/apps/admin-web/src/pages/StoresPage.tsx b/apps/admin-web/src/pages/StoresPage.tsx index 0d580cf..cbddec2 100644 --- a/apps/admin-web/src/pages/StoresPage.tsx +++ b/apps/admin-web/src/pages/StoresPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { Alert, @@ -42,7 +42,9 @@ import ChinaRegionCascader from '../components/ChinaRegionCascader'; import OssUpload from '../components/OssUpload'; import MultiImageUpload from '../components/MultiImageUpload'; import TencentLocPickerModal from '../components/TencentLocPickerModal'; -import AdminStorePackagesSection from '../components/AdminStorePackagesSection'; +import AdminStorePackagesSection, { + type AdminStorePackagesHandle, +} from '../components/AdminStorePackagesSection'; const CREATE_STEPS = [ { title: '基本信息' }, @@ -114,7 +116,7 @@ function StoreAuditMediaEditor() { type="info" showIcon style={{ marginBottom: 16 }} - message="可替换或删除门头照 / 环境照 / 签约合同,点击右上角「保存修改」后生效。环境照最多 20 张。" + message="可替换或删除门头照 / 环境照 / 签约合同,点击右上角「保存修改」后生效。环境照最多 20 张。套餐请在「套餐」页签编辑,同样由「保存修改」一并提交。" /> @@ -251,6 +253,7 @@ export default function StoresPage() { const [filterPartners, setFilterPartners] = useState([]); const [detail, setDetail] = useState | null>(null); const [drawerOpen, setDrawerOpen] = useState(false); + const packagesRef = useRef(null); const [rejectOpen, setRejectOpen] = useState(false); const [rejectReason, setRejectReason] = useState(''); const [auditing, setAuditing] = useState(false); @@ -465,7 +468,10 @@ export default function StoresPage() { method: 'PUT', body: JSON.stringify(payload), }); - message.success('门店信息已保存'); + const packagesResult = await packagesRef.current?.saveIfLoaded({ quiet: true }); + message.success( + packagesResult?.skipped === false ? '门店信息与套餐已保存' : '门店信息已保存', + ); setDetail(updated); setPhoneMismatch(null); void reload(); @@ -1066,7 +1072,8 @@ export default function StoresPage() { { key: 'packages', label: '套餐', - children: , + forceRender: true, + children: , }, ]} /> diff --git a/apps/h5-partner/src/components/MultiOssUploadField.tsx b/apps/h5-partner/src/components/MultiOssUploadField.tsx index bb980b2..3bdcab6 100644 --- a/apps/h5-partner/src/components/MultiOssUploadField.tsx +++ b/apps/h5-partner/src/components/MultiOssUploadField.tsx @@ -39,9 +39,19 @@ export default function MultiOssUploadField({ const [error, setError] = useState(''); const urls = normalizeUrls(value); + const urlsRef = useRef(urls); + const onChangeRef = useRef(onChange); const remaining = Math.max(0, maxCount - urls.length); const inWechat = isWechatEnv(); + useEffect(() => { + urlsRef.current = urls; + }, [urls]); + + useEffect(() => { + onChangeRef.current = onChange; + }, [onChange]); + useEffect(() => { if (!inWechat) return; void weixinSdk.init().catch(() => {}); @@ -53,7 +63,7 @@ export default function MultiOssUploadField({ } async function uploadFiles(files: File[]) { - const current = normalizeUrls(value); + const current = urlsRef.current; const room = Math.max(0, maxCount - current.length); const picked = files.slice(0, room); if (!picked.length) { @@ -70,7 +80,9 @@ export default function MultiOssUploadField({ appended.push(result.url); } if (appended.length) { - onChange?.([...current, ...appended]); + const next = [...urlsRef.current, ...appended]; + urlsRef.current = next; + onChangeRef.current?.(next); toastSuccess(`已上传 ${appended.length} 张`); } } catch (e) {