import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Alert, Button, Form, Input, InputNumber, Modal, Space, Typography, message } from 'antd'; import { DownOutlined, UpOutlined } from '@ant-design/icons'; import type { StorePackageItemDto, StorePackagesResponse } 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 { PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events'; import PackageImagesUpload from './PackageImagesUpload'; type PackageRow = StorePackageItemDto; export type AdminStorePackagesHandle = { /** 套餐已加载时写入;加载中则跳过,避免空数据覆盖线上套餐 */ saveIfLoaded: (opts?: { quiet?: boolean }) => Promise<{ skipped: boolean }>; }; function emptyRow(index = 0): PackageRow { return { name: '', price: '0', dishes: '', usableTime: '', otherNotes: '', imageUrl: '', imageUrls: [], sortOrder: index, }; } 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 [pendingRequest, setPendingRequest] = useState(null); const itemsRef = useRef(items); const loadingRef = useRef(loading); const navigate = useNavigate(); useEffect(() => { itemsRef.current = items; }, [items]); useEffect(() => { loadingRef.current = loading; }, [loading]); useEffect(() => { setLoading(true); setPendingRequest(null); request(`/admin/stores/${storeId}/packages`) .then((data) => { setPendingRequest(data.pendingRequest ?? null); 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, }; }) : [], ); }) .catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败')) .finally(() => setLoading(false)); }, [storeId]); // 在审核页完成审核后,自动刷新本页「有待审核套餐」提醒 useEffect(() => { const onChanged = () => { request(`/admin/stores/${storeId}/packages`) .then((data) => setPendingRequest(data.pendingRequest ?? null)) .catch(() => undefined); }; window.addEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged); return () => window.removeEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged); }, [storeId]); function goAudit() { if (pendingRequest) navigate(`/store-package-audits?requestId=${pendingRequest.id}`); } const pendingReminder = pendingRequest && pendingRequest.status === 'PENDING' ? ( } /> ) : null; 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, }; }) // 允许整店无套餐:忽略空白占位行(默认 price=0 不算已填) .filter((item) => { const hasText = !!(item.name || item.dishes || item.usableTime || item.otherNotes); const hasImages = item.imageUrls.length > 0; const hasNonZeroPrice = item.price !== '' && Number(item.price) !== 0; return hasText || hasImages || hasNonZeroPrice; }); 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) => { const imageUrls = normalizeStorePackageImageUrls(p); return { ...p, price: String(p.price), imageUrl: imageUrls[0] ?? '', imageUrls, sortOrder: i, }; }) : [], ); } catch (e) { if (!opts?.quiet) message.error(e instanceof Error ? e.message : '保存失败'); throw e; } finally { setSaving(false); } } useImperativeHandle(ref, () => ({ saveIfLoaded: async (opts) => { if (loadingRef.current) return { skipped: true }; await save(opts); return { skipped: false }; }, })); if (loading) { return 加载套餐中…; } if (!items.length) { return (
{pendingReminder} ); } return (
{pendingReminder} {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;