382 lines
14 KiB
TypeScript
382 lines
14 KiB
TypeScript
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<AdminStorePackagesHandle, { storeId: string }>(
|
|
function AdminStorePackagesSection({ storeId }, ref) {
|
|
const [items, setItems] = useState<PackageRow[]>([emptyRow()]);
|
|
const [collapsed, setCollapsed] = useState<Record<number, boolean>>({});
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [pendingRequest, setPendingRequest] = useState<StorePackagesResponse['pendingRequest']>(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<StorePackagesResponse>(`/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<StorePackagesResponse>(`/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' ? (
|
|
<Alert
|
|
type="warning"
|
|
showIcon
|
|
style={{ marginBottom: 16 }}
|
|
message="该门店有待审核套餐"
|
|
description={
|
|
<Space>
|
|
<Button size="small" type="primary" onClick={goAudit}>
|
|
审核
|
|
</Button>
|
|
<Button size="small" onClick={goAudit}>
|
|
对比
|
|
</Button>
|
|
</Space>
|
|
}
|
|
/>
|
|
) : null;
|
|
|
|
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) {
|
|
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<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;
|
|
});
|
|
};
|
|
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 <Typography.Text type="secondary">加载套餐中…</Typography.Text>;
|
|
}
|
|
|
|
if (!items.length) {
|
|
return (
|
|
<Form layout="vertical" requiredMark={false}>
|
|
<Alert type="info" showIcon style={{ marginBottom: 16 }} message="该门店暂无套餐,可添加或保存为空。" />
|
|
{pendingReminder}
|
|
<Button onClick={addRow} style={{ marginBottom: 16 }}>
|
|
添加套餐
|
|
</Button>
|
|
<Button type="primary" loading={saving} onClick={() => void save().catch(() => undefined)}>
|
|
保存套餐
|
|
</Button>
|
|
</Form>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Form layout="vertical" requiredMark={false}>
|
|
{pendingReminder}
|
|
<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 > 0 ? (
|
|
<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.TextArea
|
|
rows={2}
|
|
placeholder="节假日除外"
|
|
value={item.usableTime || ''}
|
|
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
|
|
/>
|
|
</Form.Item>
|
|
|
|
<Form.Item label="套餐图片" style={{ marginBottom: 12 }}>
|
|
<PackageImagesUpload
|
|
value={normalizeStorePackageImageUrls(item)}
|
|
onChange={(imageUrls) =>
|
|
updateAt(index, {
|
|
imageUrls,
|
|
imageUrl: imageUrls[0] ?? '',
|
|
})
|
|
}
|
|
/>
|
|
</Form.Item>
|
|
|
|
<Form.Item label="其他说明" style={{ marginBottom: 0 }}>
|
|
<Input.TextArea
|
|
rows={2}
|
|
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 }}>
|
|
上传套餐图后请点右上角「保存修改」(会连同套餐一起保存),或点下方「保存套餐」。仅上传不保存,刷新会丢失。
|
|
</Typography.Paragraph>
|
|
|
|
<Button type="primary" loading={saving} onClick={() => void save().catch(() => undefined)}>
|
|
保存套餐
|
|
</Button>
|
|
</Form>
|
|
);
|
|
},
|
|
);
|
|
|
|
export default AdminStorePackagesSection;
|