ffa753707b
Co-authored-by: Cursor <cursoragent@cursor.com>
218 lines
7.4 KiB
TypeScript
218 lines
7.4 KiB
TypeScript
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<PackageRow[]>([emptyRow()]);
|
|
const [collapsed, setCollapsed] = useState<Record<number, boolean>>({});
|
|
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 })));
|
|
setCollapsed((prev) => {
|
|
const next: Record<number, boolean> = {};
|
|
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 <Typography.Text type="secondary">加载套餐中…</Typography.Text>;
|
|
}
|
|
|
|
return (
|
|
<Form layout="vertical" requiredMark={false}>
|
|
<Alert
|
|
type="info"
|
|
showIcon
|
|
style={{ marginBottom: 16 }}
|
|
message={`总部直存立即生效,无需审核。同一门店最多 ${STORE_PACKAGE_MAX_COUNT} 条套餐。`}
|
|
/>
|
|
|
|
{items.map((item, index) => {
|
|
const isCollapsed = !!collapsed[index];
|
|
const displayName = item.name.trim() || `套餐 ${index + 1}`;
|
|
return (
|
|
<div
|
|
key={index}
|
|
style={{
|
|
marginBottom: 16,
|
|
padding: 16,
|
|
border: '1px solid #f0f0f0',
|
|
borderRadius: 8,
|
|
background: '#fafafa',
|
|
}}
|
|
>
|
|
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: isCollapsed ? 0 : 12 }}>
|
|
<Button
|
|
type="text"
|
|
icon={isCollapsed ? <DownOutlined /> : <UpOutlined />}
|
|
onClick={() => toggleCollapse(index)}
|
|
style={{ paddingLeft: 0, height: 'auto' }}
|
|
>
|
|
<Typography.Title level={5} style={{ margin: 0 }}>
|
|
{displayName}
|
|
</Typography.Title>
|
|
</Button>
|
|
{items.length > 1 ? (
|
|
<Button type="link" danger onClick={() => removeAt(index)}>
|
|
删除
|
|
</Button>
|
|
) : null}
|
|
</Space>
|
|
|
|
{!isCollapsed ? (
|
|
<>
|
|
<Form.Item label="套餐名称" required style={{ marginBottom: 12 }}>
|
|
<Input
|
|
placeholder="如:套餐A"
|
|
value={item.name}
|
|
onChange={(e) => updateAt(index, { name: e.target.value })}
|
|
/>
|
|
</Form.Item>
|
|
|
|
<Form.Item label="价格(元)" required style={{ marginBottom: 12 }}>
|
|
<InputNumber
|
|
min={0}
|
|
precision={2}
|
|
style={{ width: '100%' }}
|
|
addonAfter="元"
|
|
placeholder="198"
|
|
value={item.price === '' ? undefined : Number(item.price)}
|
|
onChange={(v) => updateAt(index, { price: v != null ? String(v) : '' })}
|
|
/>
|
|
</Form.Item>
|
|
|
|
<Form.Item label="菜品" required style={{ marginBottom: 12 }}>
|
|
<Input.TextArea
|
|
rows={2}
|
|
placeholder="红烧肉、红烧鱼、油焖茄子"
|
|
value={item.dishes}
|
|
onChange={(e) => updateAt(index, { dishes: e.target.value })}
|
|
/>
|
|
</Form.Item>
|
|
|
|
<Form.Item label="使用时间" style={{ marginBottom: 12 }}>
|
|
<Input
|
|
placeholder="节假日除外"
|
|
value={item.usableTime || ''}
|
|
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
|
|
/>
|
|
</Form.Item>
|
|
|
|
<Form.Item label="其他说明" style={{ marginBottom: 0 }}>
|
|
<Input
|
|
placeholder="不可叠加"
|
|
value={item.otherNotes || ''}
|
|
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
|
|
/>
|
|
</Form.Item>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{items.length < STORE_PACKAGE_MAX_COUNT ? (
|
|
<Button onClick={addRow} style={{ marginBottom: 16 }}>
|
|
添加套餐
|
|
</Button>
|
|
) : null}
|
|
|
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
|
修改后点击下方按钮保存,C 端将立即展示生效套餐。
|
|
</Typography.Paragraph>
|
|
|
|
<Button type="primary" loading={saving} onClick={() => void save()}>
|
|
保存套餐
|
|
</Button>
|
|
</Form>
|
|
);
|
|
}
|