diff --git a/.cursor/skills/dukang-v3/reference-req-index.md b/.cursor/skills/dukang-v3/reference-req-index.md index 6e9287c..0715e2c 100644 --- a/.cursor/skills/dukang-v3/reference-req-index.md +++ b/.cursor/skills/dukang-v3/reference-req-index.md @@ -32,6 +32,7 @@ | REQ-U-024 | 推广码 | 扫码归因;现场提货有码归码 | | REQ-U-025 | 分享 | 微信商品分享+渠道参数 | | REQ-U-026 | 门店规则 | 附件一完整展示 | +| REQ-U-027 | 门店套餐 **3.4.10** | 门店详情展示生效套餐;套餐异议申诉入口 | ## 门店端 REQ-S @@ -58,6 +59,7 @@ | REQ-S-018 | 权限 | §4.6 矩阵 | | REQ-S-019 | 多店选店 | 记住上次所选 | | REQ-S-020 | 待处理核销 | Wave 3 弱网单 | +| REQ-S-021 | 门店套餐 **3.4.10** | 套餐列表/编辑/提审 | ## 合伙人端 REQ-P @@ -89,6 +91,7 @@ | REQ-P-024 | 负责人复核 | 待复核→待总部审 | | REQ-P-025 | 合同同日 | 同日提交校验 | | REQ-P-026 | 我的仓库 | Wave 3 管仓只读 | +| REQ-P-027 | 门店套餐 **3.4.10** | 拓店套餐页;门店套餐列表/编辑/提审 | ## 总部端 REQ-H @@ -122,3 +125,4 @@ | REQ-H-022 | 签约主体 | 山西领势酒业 | | REQ-H-023 | 热力图 | 腾讯位置服务 | | REQ-H-024 | 结算异议 | 门店 3 日异议处理 | +| REQ-H-025 | 门店套餐 **3.4.10** | 门店详情套餐 Tab 直存;套餐变更审核通过/驳回 | diff --git a/AGENTS.md b/AGENTS.md index 1b2ceb7..24c262b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,7 +40,7 @@ node scripts/smoke-prev1.mjs pnpm lint && pnpm test ``` -Mock 验证码:`123456`。测试账号见 [`README.md`](./README.md)。 +Mock 验证码:`999888`。测试账号见 [`README.md`](./README.md)。 > **端口冲突**:`h5-partner` 与 `admin-web` 同为 5175,勿同时 `dev:partner` + `dev:admin`。 diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx index 4f5adba..da8497b 100644 --- a/apps/admin-web/src/App.tsx +++ b/apps/admin-web/src/App.tsx @@ -5,6 +5,7 @@ import LoginPage from './pages/LoginPage'; import DashboardPage from './pages/DashboardPage'; import UsersPage from './pages/UsersPage'; import OrdersPage from './pages/OrdersPage'; +import StorePackageAuditsPage from './pages/StorePackageAuditsPage'; import StoresPage from './pages/StoresPage'; import StoreRatingsPage from './pages/StoreRatingsPage'; import StoreAccountsPage from './pages/StoreAccountsPage'; @@ -37,6 +38,7 @@ import TicketsPage from './pages/TicketsPage'; import SupportTicketsPage from './pages/SupportTicketsPage'; import InvoicesPage from './pages/InvoicesPage'; import UserLogsPage from './pages/UserLogsPage'; +import DomainEventsPage from './pages/DomainEventsPage'; import HqLogsPage from './pages/HqLogsPage'; import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage'; import StoreLogsPage from './pages/StoreLogsPage'; @@ -79,6 +81,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> @@ -118,6 +121,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/admin-web/src/components/AdminStorePackagesSection.tsx b/apps/admin-web/src/components/AdminStorePackagesSection.tsx new file mode 100644 index 0000000..dd1166a --- /dev/null +++ b/apps/admin-web/src/components/AdminStorePackagesSection.tsx @@ -0,0 +1,217 @@ +import { useEffect, useState } from 'react'; +import { Alert, Button, Form, Input, InputNumber, 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 { request } from '../lib/api'; + +type PackageRow = StorePackageItemDto; + +function emptyRow(index = 0): PackageRow { + return { name: '', price: '0', dishes: '', usableTime: '', otherNotes: '', sortOrder: index }; +} + +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); + + useEffect(() => { + setLoading(true); + request<{ live: PackageRow[] }>(`/admin/stores/${storeId}/packages`) + .then((data) => { + setItems( + data.live?.length + ? data.live.map((p, i) => ({ ...p, price: String(p.price), 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) { + setItems((prev) => prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i }))); + 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; + }); + } + + function toggleCollapse(index: number) { + setCollapsed((prev) => ({ ...prev, [index]: !prev[index] })); + } + + 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, + sortOrder: index, + })) + .filter((item) => item.name || item.dishes || item.price); + + 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; + } + } + + setSaving(true); + try { + await request(`/admin/stores/${storeId}/packages`, { + method: 'PUT', + body: JSON.stringify({ + packages: filled.map((p) => ({ ...p, price: Number(p.price).toFixed(2) })), + }), + }); + message.success('套餐已保存并生效'); + } catch (e) { + message.error(e instanceof Error ? e.message : '保存失败'); + } finally { + setSaving(false); + } + } + + if (loading) { + return 加载套餐中…; + } + + return ( +
+ + + {items.map((item, index) => { + const isCollapsed = !!collapsed[index]; + const displayName = item.name.trim() || `套餐 ${index + 1}`; + return ( +
+ + + {items.length > 1 ? ( + + ) : 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, { otherNotes: e.target.value })} + /> + + + ) : null} +
+ ); + })} + + {items.length < STORE_PACKAGE_MAX_COUNT ? ( + + ) : null} + + + 修改后点击下方按钮保存,C 端将立即展示生效套餐。 + + + + + ); +} diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index 525490f..c7d0fac 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -51,6 +51,7 @@ const MENU_ITEMS: MenuProps['items'] = [ label: '门店', children: [ { key: '/stores', label: '门店列表' }, + { key: '/store-package-audits', label: '套餐审核' }, { key: '/store-ratings', label: '门店评价' }, { key: '/store-categories', label: '门店分类' }, { key: '/store-accounts', label: '门店账户' }, @@ -121,6 +122,7 @@ const MENU_ITEMS: MenuProps['items'] = [ { key: '/logs/partners', label: '合伙人日志' }, { key: '/logs/hq', label: 'HQ 操作日志' }, { key: '/logs/third-party', label: '第三方日志' }, + { key: '/logs/domain-events', label: '领域事件' }, ], }, { key: '/hq-permissions', icon: , label: '权限分配' }, @@ -146,6 +148,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean { '/knowledge-bases': 'knowledge_bases', 'stores-group': 'stores', '/stores': 'stores', + '/store-package-audits': 'stores', '/store-ratings': 'stores', '/store-categories': 'stores', '/store-accounts': 'stores', @@ -180,6 +183,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean { '/logs/partners': 'logs', '/logs/hq': 'logs', '/logs/third-party': 'logs', + '/logs/domain-events': 'logs', '/hq-permissions': 'hq_permissions', '/system-settings': 'system_settings_any', '/hq-accounts': 'hq_accounts', diff --git a/apps/admin-web/src/lib/user-log.ts b/apps/admin-web/src/lib/user-log.ts index 9114728..3a8c6e9 100644 --- a/apps/admin-web/src/lib/user-log.ts +++ b/apps/admin-web/src/lib/user-log.ts @@ -1,45 +1,12 @@ -export type UserLogCategory = - | 'login' - | 'browse_product' - | 'order' - | 'pay' - | 'wechat_auth' - | 'browse_store' - | 'redeem' - | 'profile'; +export { + USER_LOG_CATEGORY_OPTIONS, + resolveUserLogCategory, + eventNamesForUserLogCategory, + type UserLogCategory, +} from '@dukang/shared-types'; -const USER_LOG_EVENT_CATEGORIES: Record = { - login: ['login_success', 'sms_login', 'sms_send', 'sms_verify_fail'], - browse_product: ['home_view', 'product_click', 'product_detail_view'], - order: ['order_confirm_view', 'order_submit'], - pay: ['pay_success', 'pay_fail'], - wechat_auth: ['wechat_login', 'wechat_phone', 'wechat_location', 'wechat_album'], - browse_store: ['store_list_view', 'store_detail_view'], - redeem: ['benefit_redeem_start', 'benefit_redeem_success'], - profile: ['profile_update', 'bind_phone'], -}; +import { USER_LOG_CATEGORY_OPTIONS } from '@dukang/shared-types'; -export const USER_LOG_CATEGORY_OPTIONS: Array<{ value: UserLogCategory | ''; label: string }> = [ - { value: '', label: '全部' }, - { value: 'login', label: '登录' }, - { value: 'browse_product', label: '浏览商品' }, - { value: 'order', label: '下单' }, - { value: 'pay', label: '支付' }, - { value: 'wechat_auth', label: '微信授权' }, - { value: 'browse_store', label: '浏览门店' }, - { value: 'redeem', label: '核销' }, - { value: 'profile', label: '信息修改' }, -]; - -export function resolveUserLogCategory(eventName: string): UserLogCategory | null { - for (const [category, events] of Object.entries(USER_LOG_EVENT_CATEGORIES) as Array< - [UserLogCategory, readonly string[]] - >) { - if (events.includes(eventName)) return category; - } - return null; -} - -export const USER_LOG_CATEGORY_LABELS = Object.fromEntries( +export const USER_LOG_CATEGORY_LABELS: Record = Object.fromEntries( USER_LOG_CATEGORY_OPTIONS.filter((o) => o.value).map((o) => [o.value, o.label]), -) as Record; +); diff --git a/apps/admin-web/src/pages/DomainEventsPage.tsx b/apps/admin-web/src/pages/DomainEventsPage.tsx new file mode 100644 index 0000000..4e663cf --- /dev/null +++ b/apps/admin-web/src/pages/DomainEventsPage.tsx @@ -0,0 +1,127 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { Card, Descriptions, Drawer, Select, Space, Table, Tag } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { request } from '../lib/api'; +import { fmtTime } from '../lib/constants'; + +type Row = { + id: string; + eventType: string; + refType: string; + refId: string; + status?: string | null; + param1?: string | null; + param2?: string | null; + param3?: string | null; + remark?: string | null; + createdAt: string; +}; + +const EVENT_OPTIONS = [ + { value: '', label: '全部领域事件' }, + { value: 'ORDER_STATUS', label: '订单状态' }, + { value: 'BENEFIT_LEDGER', label: '权益流水' }, + { value: 'STORE_AUDIT', label: '门店审核' }, + { value: 'TICKET_COLLAB', label: '工单协作' }, + { value: 'PROMO_TOUCH', label: '推广触达' }, +]; + +export default function DomainEventsPage() { + const [params, setParams] = useSearchParams(); + const eventType = params.get('eventType') ?? ''; + const refType = params.get('refType') ?? ''; + const refId = params.get('refId') ?? ''; + const [data, setData] = useState<{ items: Row[]; total: number; page: number; pageSize: number } | null>(null); + const [loading, setLoading] = useState(false); + const [detail, setDetail] = useState(null); + + const load = useCallback(() => { + setLoading(true); + const q = new URLSearchParams(); + if (eventType) q.set('eventType', eventType); + if (refType) q.set('refType', refType); + if (refId) q.set('refId', refId); + q.set('page', params.get('page') ?? '1'); + q.set('pageSize', '20'); + request<{ items: Row[]; total: number; page: number; pageSize: number }>( + `/admin/logs/domain-events?${q.toString()}`, + ) + .then(setData) + .finally(() => setLoading(false)); + }, [eventType, refType, refId, params]); + + useEffect(() => { + load(); + }, [load]); + + const columns: ColumnsType = [ + { title: '时间', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v) }, + { title: '类型', dataIndex: 'eventType', width: 130, render: (v) => {v} }, + { title: '关联', render: (_, r) => `${r.refType} #${r.refId}` }, + { title: '状态', dataIndex: 'status', width: 100 }, + { title: '摘要', render: (_, r) => r.param1 || r.remark || '—' }, + { + title: '操作', + width: 80, + render: (_, r) => ( + { + setDetail(await request(`/admin/logs/domain-events/${r.id}`)); + }} + > + 详情 + + ), + }, + ]; + + return ( + + + 0} onClick={() => void sendCode()}> {codeCooldown > 0 ? `${codeCooldown}s` : '获取验证码'} diff --git a/apps/admin-web/src/pages/RedeemDebugPage.tsx b/apps/admin-web/src/pages/RedeemDebugPage.tsx index d48aaa1..690265a 100644 --- a/apps/admin-web/src/pages/RedeemDebugPage.tsx +++ b/apps/admin-web/src/pages/RedeemDebugPage.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { Button, Card, Col, Form, Input, InputNumber, Row, Space, Tabs, Typography, message, } from 'antd'; +import { MOCK_SMS_FIXED_CODE } from '@dukang/shared-types'; import { request } from '../lib/api'; type ApiResult = Record; @@ -255,7 +256,7 @@ export default function RedeemDebugPage() { - + + {row.status === 'PENDING' ? ( + <> + + + + ) : ( + row.rejectReason || null + )} + + ), + }, + ]; + + return ( +
+ 套餐变更审核 + + {(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => ( + + ))} + + void reload(p, status), + }} + /> + + setDetailOpen(false)} + extra={ + detail?.status === 'PENDING' ? ( + + + + + ) : null + } + > + {detailLoading ? ( + 加载中… + ) : detail ? ( + <> + + {STORE_PACKAGE_CHANGE_STATUS_LABELS[detail.status]} + + 提交方:{detail.submitterType === 'PARTNER' ? '合伙人' : '门店'} · {fmtTime(detail.createdAt)} + + + {detail.rejectReason ? ( + 驳回原因:{detail.rejectReason} + ) : null} + + 线上 {detail.livePackages?.length ?? 0} 条 → 申请 {detail.packages?.length ?? 0} 条 + +
+ + ) : null} + + + setRejectOpen(false)} + onOk={() => { + if (!activeId) return; + if (!rejectReason.trim()) { + message.warning('请填写驳回原因'); + return; + } + void audit(activeId, 'REJECT', rejectReason.trim()); + setRejectOpen(false); + }} + > + setRejectReason(e.target.value)} + /> + + + ); +} diff --git a/apps/admin-web/src/pages/StoresPage.tsx b/apps/admin-web/src/pages/StoresPage.tsx index 1785d61..8d64d01 100644 --- a/apps/admin-web/src/pages/StoresPage.tsx +++ b/apps/admin-web/src/pages/StoresPage.tsx @@ -41,6 +41,7 @@ import { resolveRegionBinding } from '../lib/china-region'; import ChinaRegionCascader from '../components/ChinaRegionCascader'; import OssUpload from '../components/OssUpload'; import TencentLocPickerModal from '../components/TencentLocPickerModal'; +import AdminStorePackagesSection from '../components/AdminStorePackagesSection'; const CREATE_STEPS = [ { title: '基本信息' }, @@ -1214,6 +1215,11 @@ export default function StoresPage() { label: '审核材料', children: , }, + { + key: 'packages', + label: '套餐', + children: , + }, ]} /> @@ -1398,14 +1404,20 @@ export default function StoresPage() { 环境照片 + + 至少 3 张,可继续添加 + - {(fields) => ( + {(fields, { add }) => (
{fields.map((field, index) => ( ))} +
)}
diff --git a/apps/admin-web/src/pages/TicketsPage.tsx b/apps/admin-web/src/pages/TicketsPage.tsx index ac2ee88..6659924 100644 --- a/apps/admin-web/src/pages/TicketsPage.tsx +++ b/apps/admin-web/src/pages/TicketsPage.tsx @@ -159,6 +159,7 @@ export default function TicketsPage() { { value: 'RESHIPMENT', label: '破损补发' }, { value: 'DAMAGE_RETURN', label: '破损退货' }, { value: 'RETURN_REFUND', label: '退货退款' }, + { value: 'PACKAGE_DISPUTE', label: '套餐异议' }, { value: 'ALERT', label: '异常' }, ]} /> diff --git a/apps/admin-web/src/pages/UsersPage.tsx b/apps/admin-web/src/pages/UsersPage.tsx index 18661b6..9d1e458 100644 --- a/apps/admin-web/src/pages/UsersPage.tsx +++ b/apps/admin-web/src/pages/UsersPage.tsx @@ -17,7 +17,7 @@ import { message, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; -import { USER_SOURCE_TYPE_LABELS, type UserSourceType } from '@dukang/shared-types'; +import { USER_SOURCE_TYPE_LABELS, resolveUserLogCategory, type UserSourceType } from '@dukang/shared-types'; import { request, type AdminUserRow, type HqProfile, type Paginated } from '../lib/api'; import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants'; @@ -30,6 +30,14 @@ type UserOrderRow = { createdAt: string; }; +type UserBehaviorLog = { + id: string; + eventName: string; + clientApp?: string | null; + createdAt: string; + extraJson?: Record | null; +}; + type UserDetail = AdminUserRow & { wxUnionId?: string | null; cityPref?: Record | null; @@ -72,6 +80,7 @@ export default function UsersPage() { const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); const [detail, setDetail] = useState(null); + const [behaviorLogs, setBehaviorLogs] = useState([]); const [drawerOpen, setDrawerOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState(''); @@ -123,8 +132,14 @@ export default function UsersPage() { }, [location.state, location.pathname, navigate]); async function openDetail(id: string) { - const res = await request(`/admin/users/${id}`); + const [res, logs] = await Promise.all([ + request(`/admin/users/${id}`), + request<{ items: UserBehaviorLog[] }>(`/admin/logs/users?userId=${id}&pageSize=50`).catch( + () => ({ items: [] as UserBehaviorLog[] }), + ), + ]); setDetail(res); + setBehaviorLogs(logs.items ?? []); setDrawerOpen(true); } @@ -437,6 +452,28 @@ export default function UsersPage() { + + 行为时间线(最近 {behaviorLogs.length} 条) + +
fmtTime(v) }, + { + title: '分类', + width: 100, + render: (_, r) => resolveUserLogCategory(r.eventName) ?? '—', + }, + { title: '事件', dataIndex: 'eventName' }, + { title: '端', dataIndex: 'clientApp', width: 90 }, + ]} + /> + 全部订单({detail.orders?.length ?? 0}) diff --git a/apps/h5-partner/package.json b/apps/h5-partner/package.json index c241d9e..f188aea 100644 --- a/apps/h5-partner/package.json +++ b/apps/h5-partner/package.json @@ -9,6 +9,7 @@ "lint": "echo ok" }, "dependencies": { + "@dukang/client-logging": "workspace:*", "@dukang/shared-types": "workspace:*", "@dukang/shared-ui": "workspace:*", "@dukang/weixin-sdk": "workspace:*", diff --git a/apps/h5-partner/src/PartnerAppRoutes.tsx b/apps/h5-partner/src/PartnerAppRoutes.tsx index 240db12..2440233 100644 --- a/apps/h5-partner/src/PartnerAppRoutes.tsx +++ b/apps/h5-partner/src/PartnerAppRoutes.tsx @@ -6,6 +6,7 @@ import SubAccountLayout from './layouts/SubAccountLayout'; import HomePage from './pages/HomePage'; import StoreListPage from './pages/StoreListPage'; import StoreDetailPage from './pages/StoreDetailPage'; +import StorePackagesPage from './pages/StorePackagesPage'; import StoreCreatePage from './pages/StoreCreatePage'; import OrderListPage from './pages/OrderListPage'; import OrderDetailPage from './pages/OrderDetailPage'; @@ -31,6 +32,7 @@ function PrimaryRoutes() { } /> } /> + } /> } /> } /> } /> @@ -68,6 +70,7 @@ function SubAccountRoutes() { {!isWarehouse && ( <> } /> + } /> } /> )} diff --git a/apps/h5-partner/src/components/StorePackagesForm.tsx b/apps/h5-partner/src/components/StorePackagesForm.tsx new file mode 100644 index 0000000..6e505c5 --- /dev/null +++ b/apps/h5-partner/src/components/StorePackagesForm.tsx @@ -0,0 +1,158 @@ +import { useState } from 'react'; +import type { PackageFormItem } from '../lib/storePackages'; +import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types'; +import { emptyPackage } from '../lib/storePackages'; + +type Props = { + items: PackageFormItem[]; + onChange: (items: PackageFormItem[]) => void; + disabled?: boolean; + /** 嵌入拓店流程时不重复外层卡片边距 */ + embedded?: boolean; +}; + +export default function StorePackagesForm({ items, onChange, disabled, embedded }: Props) { + const [collapsed, setCollapsed] = useState>({}); + + function updateAt(index: number, patch: Partial) { + const next = items.map((item, i) => (i === index ? { ...item, ...patch } : item)); + onChange(next); + } + + function addItem() { + if (items.length >= STORE_PACKAGE_MAX_COUNT) return; + onChange([...items, emptyPackage(items.length)]); + } + + function removeAt(index: number) { + onChange(items.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i }))); + 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; + }); + } + + function toggleCollapse(index: number) { + setCollapsed((prev) => ({ ...prev, [index]: !prev[index] })); + } + + const list = items.length ? items : [emptyPackage(0)]; + + return ( +
+ {list.map((item, index) => { + const isCollapsed = !!collapsed[index]; + const displayName = item.name.trim() || `套餐 ${index + 1}`; + return ( +
+
+ + {!disabled && list.length > 1 ? ( + + ) : null} +
+ + {!isCollapsed ? ( + <> +
+ +
+ restaurant + updateAt(index, { name: e.target.value })} + /> +
+
+ +
+ +
+ payments + updateAt(index, { price: e.target.value })} + /> +
+
+ +
+ +