fix(admin): save packages when clicking store 保存修改
HQ users were uploading package images then clicking drawer 保存修改, which only persisted store media and never PUT packages. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,12 +1,18 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
||||||
import { Alert, Button, Form, Input, InputNumber, Modal, Space, Typography, message } from 'antd';
|
import { Alert, Button, Form, Input, InputNumber, Modal, Space, Typography, message } from 'antd';
|
||||||
import { DownOutlined, UpOutlined } from '@ant-design/icons';
|
import { DownOutlined, UpOutlined } from '@ant-design/icons';
|
||||||
import type { StorePackageItemDto } from '@dukang/shared-types';
|
import type { StorePackageItemDto } from '@dukang/shared-types';
|
||||||
import { STORE_PACKAGE_IMAGE_MAX_COUNT, STORE_PACKAGE_MAX_COUNT, normalizeStorePackageImageUrls } 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 { request } from '../lib/api';
|
||||||
import PackageImagesUpload from './PackageImagesUpload';
|
import PackageImagesUpload from './PackageImagesUpload';
|
||||||
|
|
||||||
type PackageRow = StorePackageItemDto;
|
type PackageRow = StorePackageItemDto;
|
||||||
|
|
||||||
|
export type AdminStorePackagesHandle = {
|
||||||
|
/** 套餐已加载时写入;加载中则跳过,避免空数据覆盖线上套餐 */
|
||||||
|
saveIfLoaded: (opts?: { quiet?: boolean }) => Promise<{ skipped: boolean }>;
|
||||||
|
};
|
||||||
|
|
||||||
function emptyRow(index = 0): PackageRow {
|
function emptyRow(index = 0): PackageRow {
|
||||||
return {
|
return {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -20,16 +26,136 @@ function emptyRow(index = 0): PackageRow {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminStorePackagesSection({ storeId }: { storeId: string }) {
|
const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId: string }>(
|
||||||
const [items, setItems] = useState<PackageRow[]>([emptyRow()]);
|
function AdminStorePackagesSection({ storeId }, ref) {
|
||||||
const [collapsed, setCollapsed] = useState<Record<number, boolean>>({});
|
const [items, setItems] = useState<PackageRow[]>([emptyRow()]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [collapsed, setCollapsed] = useState<Record<number, boolean>>({});
|
||||||
const [saving, setSaving] = useState(false);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const itemsRef = useRef(items);
|
||||||
|
const loadingRef = useRef(loading);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
itemsRef.current = items;
|
||||||
request<{ live: PackageRow[] }>(`/admin/stores/${storeId}/packages`)
|
}, [items]);
|
||||||
.then((data) => {
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadingRef.current = loading;
|
||||||
|
}, [loading]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
request<{ live: PackageRow[] }>(`/admin/stores/${storeId}/packages`)
|
||||||
|
.then((data) => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: [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) {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item) => item.name || item.dishes || item.price || item.imageUrls.length > 0);
|
||||||
|
|
||||||
|
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(
|
setItems(
|
||||||
data.live?.length
|
data.live?.length
|
||||||
? data.live.map((p, i) => {
|
? data.live.map((p, i) => {
|
||||||
@@ -44,253 +170,161 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
|
|||||||
})
|
})
|
||||||
: [emptyRow()],
|
: [emptyRow()],
|
||||||
);
|
);
|
||||||
})
|
} catch (e) {
|
||||||
.catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败'))
|
if (!opts?.quiet) message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
.finally(() => setLoading(false));
|
throw e;
|
||||||
}, [storeId]);
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
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() {
|
|
||||||
const filled = items
|
|
||||||
.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,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((item) => item.name || item.dishes || item.price || item.imageUrls.length > 0);
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
if ((item.imageUrls?.length ?? 0) > STORE_PACKAGE_IMAGE_MAX_COUNT) {
|
|
||||||
message.warning(`第 ${i + 1} 条套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT} 张`);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaving(true);
|
useImperativeHandle(ref, () => ({
|
||||||
try {
|
saveIfLoaded: async (opts) => {
|
||||||
const data = await request<{ live: PackageRow[] }>(`/admin/stores/${storeId}/packages`, {
|
if (loadingRef.current) return { skipped: true };
|
||||||
method: 'PUT',
|
await save(opts);
|
||||||
body: JSON.stringify({
|
return { skipped: false };
|
||||||
packages: filled.map((p) => ({ ...p, price: Number(p.price).toFixed(2) })),
|
},
|
||||||
}),
|
}));
|
||||||
});
|
|
||||||
message.success('套餐已保存并生效');
|
if (loading) {
|
||||||
setItems(
|
return <Typography.Text type="secondary">加载套餐中…</Typography.Text>;
|
||||||
data.live?.length
|
}
|
||||||
? data.live.map((p, i) => {
|
|
||||||
const imageUrls = normalizeStorePackageImageUrls(p);
|
if (!items.length) {
|
||||||
return {
|
return (
|
||||||
...p,
|
<Form layout="vertical" requiredMark={false}>
|
||||||
price: String(p.price),
|
<Alert type="info" showIcon style={{ marginBottom: 16 }} message="该门店暂无套餐,可添加或保存为空。" />
|
||||||
imageUrl: imageUrls[0] ?? '',
|
<Button onClick={addRow} style={{ marginBottom: 16 }}>
|
||||||
imageUrls,
|
添加套餐
|
||||||
sortOrder: i,
|
</Button>
|
||||||
};
|
<Button type="primary" loading={saving} onClick={() => void save().catch(() => undefined)}>
|
||||||
})
|
保存套餐
|
||||||
: [emptyRow()],
|
</Button>
|
||||||
|
</Form>
|
||||||
);
|
);
|
||||||
} catch (e) {
|
|
||||||
message.error(e instanceof Error ? e.message : '保存失败');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <Typography.Text type="secondary">加载套餐中…</Typography.Text>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!items.length) {
|
|
||||||
return (
|
return (
|
||||||
<Form layout="vertical" requiredMark={false}>
|
<Form layout="vertical" requiredMark={false}>
|
||||||
<Alert type="info" showIcon style={{ marginBottom: 16 }} message="该门店暂无套餐,可添加或保存为空。" />
|
<Alert
|
||||||
<Button onClick={addRow} style={{ marginBottom: 16 }}>
|
type="info"
|
||||||
添加套餐
|
showIcon
|
||||||
</Button>
|
style={{ marginBottom: 16 }}
|
||||||
<Button type="primary" loading={saving} onClick={() => void save()}>
|
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
|
||||||
|
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
|
||||||
|
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>
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return ( <Form layout="vertical" requiredMark={false}>
|
export default AdminStorePackagesSection;
|
||||||
<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
|
|
||||||
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
|
|
||||||
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()}>
|
|
||||||
保存套餐
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -42,7 +42,9 @@ import ChinaRegionCascader from '../components/ChinaRegionCascader';
|
|||||||
import OssUpload from '../components/OssUpload';
|
import OssUpload from '../components/OssUpload';
|
||||||
import MultiImageUpload from '../components/MultiImageUpload';
|
import MultiImageUpload from '../components/MultiImageUpload';
|
||||||
import TencentLocPickerModal from '../components/TencentLocPickerModal';
|
import TencentLocPickerModal from '../components/TencentLocPickerModal';
|
||||||
import AdminStorePackagesSection from '../components/AdminStorePackagesSection';
|
import AdminStorePackagesSection, {
|
||||||
|
type AdminStorePackagesHandle,
|
||||||
|
} from '../components/AdminStorePackagesSection';
|
||||||
|
|
||||||
const CREATE_STEPS = [
|
const CREATE_STEPS = [
|
||||||
{ title: '基本信息' },
|
{ title: '基本信息' },
|
||||||
@@ -114,7 +116,7 @@ function StoreAuditMediaEditor() {
|
|||||||
type="info"
|
type="info"
|
||||||
showIcon
|
showIcon
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
message="可替换或删除门头照 / 环境照 / 签约合同,点击右上角「保存修改」后生效。环境照最多 20 张。"
|
message="可替换或删除门头照 / 环境照 / 签约合同,点击右上角「保存修改」后生效。环境照最多 20 张。套餐请在「套餐」页签编辑,同样由「保存修改」一并提交。"
|
||||||
/>
|
/>
|
||||||
<Form.Item name="coverUrl" label="门头照">
|
<Form.Item name="coverUrl" label="门头照">
|
||||||
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
|
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
|
||||||
@@ -251,6 +253,7 @@ export default function StoresPage() {
|
|||||||
const [filterPartners, setFilterPartners] = useState<PartnerOption[]>([]);
|
const [filterPartners, setFilterPartners] = useState<PartnerOption[]>([]);
|
||||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const packagesRef = useRef<AdminStorePackagesHandle>(null);
|
||||||
const [rejectOpen, setRejectOpen] = useState(false);
|
const [rejectOpen, setRejectOpen] = useState(false);
|
||||||
const [rejectReason, setRejectReason] = useState('');
|
const [rejectReason, setRejectReason] = useState('');
|
||||||
const [auditing, setAuditing] = useState(false);
|
const [auditing, setAuditing] = useState(false);
|
||||||
@@ -465,7 +468,10 @@ export default function StoresPage() {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
message.success('门店信息已保存');
|
const packagesResult = await packagesRef.current?.saveIfLoaded({ quiet: true });
|
||||||
|
message.success(
|
||||||
|
packagesResult?.skipped === false ? '门店信息与套餐已保存' : '门店信息已保存',
|
||||||
|
);
|
||||||
setDetail(updated);
|
setDetail(updated);
|
||||||
setPhoneMismatch(null);
|
setPhoneMismatch(null);
|
||||||
void reload();
|
void reload();
|
||||||
@@ -1066,7 +1072,8 @@ export default function StoresPage() {
|
|||||||
{
|
{
|
||||||
key: 'packages',
|
key: 'packages',
|
||||||
label: '套餐',
|
label: '套餐',
|
||||||
children: <AdminStorePackagesSection storeId={String(detail.id)} />,
|
forceRender: true,
|
||||||
|
children: <AdminStorePackagesSection ref={packagesRef} storeId={String(detail.id)} />,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -39,9 +39,19 @@ export default function MultiOssUploadField({
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const urls = normalizeUrls(value);
|
const urls = normalizeUrls(value);
|
||||||
|
const urlsRef = useRef(urls);
|
||||||
|
const onChangeRef = useRef(onChange);
|
||||||
const remaining = Math.max(0, maxCount - urls.length);
|
const remaining = Math.max(0, maxCount - urls.length);
|
||||||
const inWechat = isWechatEnv();
|
const inWechat = isWechatEnv();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
urlsRef.current = urls;
|
||||||
|
}, [urls]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onChangeRef.current = onChange;
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!inWechat) return;
|
if (!inWechat) return;
|
||||||
void weixinSdk.init().catch(() => {});
|
void weixinSdk.init().catch(() => {});
|
||||||
@@ -53,7 +63,7 @@ export default function MultiOssUploadField({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function uploadFiles(files: File[]) {
|
async function uploadFiles(files: File[]) {
|
||||||
const current = normalizeUrls(value);
|
const current = urlsRef.current;
|
||||||
const room = Math.max(0, maxCount - current.length);
|
const room = Math.max(0, maxCount - current.length);
|
||||||
const picked = files.slice(0, room);
|
const picked = files.slice(0, room);
|
||||||
if (!picked.length) {
|
if (!picked.length) {
|
||||||
@@ -70,7 +80,9 @@ export default function MultiOssUploadField({
|
|||||||
appended.push(result.url);
|
appended.push(result.url);
|
||||||
}
|
}
|
||||||
if (appended.length) {
|
if (appended.length) {
|
||||||
onChange?.([...current, ...appended]);
|
const next = [...urlsRef.current, ...appended];
|
||||||
|
urlsRef.current = next;
|
||||||
|
onChangeRef.current?.(next);
|
||||||
toastSuccess(`已上传 ${appended.length} 张`);
|
toastSuccess(`已上传 ${appended.length} 张`);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
Reference in New Issue
Block a user