feat(store): 门店套餐 v3.4.10 全端实现
新增 StorePackage 与变更审核流,覆盖总部直存、合伙/门店提审、C 端展示与套餐异议工单;同步 PRD/开发文档与 REQ 索引。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Space, Typography, message } from 'antd';
|
||||
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<PackageRow[]>([emptyRow()]);
|
||||
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<PackageRow>) {
|
||||
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 })));
|
||||
}
|
||||
|
||||
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 <Typography.Text type="secondary">加载套餐中…</Typography.Text>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Paragraph type="secondary">
|
||||
总部直存立即生效,无需审核。最多 {STORE_PACKAGE_MAX_COUNT} 条。
|
||||
</Typography.Paragraph>
|
||||
{items.map((item, index) => (
|
||||
<div key={index} style={{ border: '1px solid #f0f0f0', borderRadius: 8, padding: 12, marginBottom: 12 }}>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<Typography.Text strong>套餐 {index + 1}</Typography.Text>
|
||||
{items.length > 1 ? (
|
||||
<Button type="link" danger onClick={() => removeAt(index)}>
|
||||
删除
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<input
|
||||
placeholder="套餐名称"
|
||||
value={item.name}
|
||||
onChange={(e) => updateAt(index, { name: e.target.value })}
|
||||
style={{ width: '100%', padding: 8 }}
|
||||
/>
|
||||
<input
|
||||
placeholder="价格(元)"
|
||||
type="number"
|
||||
min={0}
|
||||
value={item.price}
|
||||
onChange={(e) => updateAt(index, { price: e.target.value })}
|
||||
style={{ width: '100%', padding: 8 }}
|
||||
/>
|
||||
<textarea
|
||||
placeholder="菜品"
|
||||
rows={2}
|
||||
value={item.dishes}
|
||||
onChange={(e) => updateAt(index, { dishes: e.target.value })}
|
||||
style={{ width: '100%', padding: 8 }}
|
||||
/>
|
||||
<input
|
||||
placeholder="使用时间"
|
||||
value={item.usableTime || ''}
|
||||
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
|
||||
style={{ width: '100%', padding: 8 }}
|
||||
/>
|
||||
<input
|
||||
placeholder="其他说明"
|
||||
value={item.otherNotes || ''}
|
||||
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
|
||||
style={{ width: '100%', padding: 8 }}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
))}
|
||||
{items.length < STORE_PACKAGE_MAX_COUNT ? (
|
||||
<Button onClick={addRow} style={{ marginBottom: 12 }}>
|
||||
添加套餐
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="primary" loading={saving} onClick={() => void save()}>
|
||||
保存套餐
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user