merge(dev): save packages with store 保存修改
CI / verify (push) Has been cancelled

This commit is contained in:
2026-08-07 21:36:40 +08:00
3 changed files with 307 additions and 254 deletions
@@ -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,11 +26,22 @@ function emptyRow(index = 0): PackageRow {
}; };
} }
export default function AdminStorePackagesSection({ storeId }: { storeId: string }) { const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId: string }>(
function AdminStorePackagesSection({ storeId }, ref) {
const [items, setItems] = useState<PackageRow[]>([emptyRow()]); const [items, setItems] = useState<PackageRow[]>([emptyRow()]);
const [collapsed, setCollapsed] = useState<Record<number, boolean>>({}); const [collapsed, setCollapsed] = useState<Record<number, boolean>>({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const itemsRef = useRef(items);
const loadingRef = useRef(loading);
useEffect(() => {
itemsRef.current = items;
}, [items]);
useEffect(() => {
loadingRef.current = loading;
}, [loading]);
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
@@ -86,12 +103,14 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
} }
run(); run();
} }
function toggleCollapse(index: number) { function toggleCollapse(index: number) {
setCollapsed((prev) => ({ ...prev, [index]: !prev[index] })); setCollapsed((prev) => ({ ...prev, [index]: !prev[index] }));
} }
async function save() { async function save(opts?: { quiet?: boolean }) {
const filled = items const currentItems = itemsRef.current;
const filled = currentItems
.map((item, index) => { .map((item, index) => {
const imageUrls = normalizeStorePackageImageUrls(item); const imageUrls = normalizeStorePackageImageUrls(item);
return { return {
@@ -111,20 +130,20 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
const item = filled[i]; const item = filled[i];
if (!item.name) { if (!item.name) {
message.warning(`${i + 1} 条套餐名称不能为空`); message.warning(`${i + 1} 条套餐名称不能为空`);
return; throw new Error('套餐校验失败');
} }
if (!item.dishes) { if (!item.dishes) {
message.warning(`${i + 1} 条套餐菜品不能为空`); message.warning(`${i + 1} 条套餐菜品不能为空`);
return; throw new Error('套餐校验失败');
} }
const price = Number(item.price); const price = Number(item.price);
if (!Number.isFinite(price) || price < 0) { if (!Number.isFinite(price) || price < 0) {
message.warning(`${i + 1} 条套餐价格须为非负数字`); message.warning(`${i + 1} 条套餐价格须为非负数字`);
return; throw new Error('套餐校验失败');
} }
if ((item.imageUrls?.length ?? 0) > STORE_PACKAGE_IMAGE_MAX_COUNT) { if ((item.imageUrls?.length ?? 0) > STORE_PACKAGE_IMAGE_MAX_COUNT) {
message.warning(`${i + 1} 条套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT}`); message.warning(`${i + 1} 条套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT}`);
return; throw new Error('套餐校验失败');
} }
} }
@@ -136,7 +155,7 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
packages: filled.map((p) => ({ ...p, price: Number(p.price).toFixed(2) })), packages: filled.map((p) => ({ ...p, price: Number(p.price).toFixed(2) })),
}), }),
}); });
message.success('套餐已保存并生效'); if (!opts?.quiet) message.success('套餐已保存并生效');
setItems( setItems(
data.live?.length data.live?.length
? data.live.map((p, i) => { ? data.live.map((p, i) => {
@@ -152,12 +171,21 @@ 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 : '保存失败');
throw e;
} finally { } finally {
setSaving(false); setSaving(false);
} }
} }
useImperativeHandle(ref, () => ({
saveIfLoaded: async (opts) => {
if (loadingRef.current) return { skipped: true };
await save(opts);
return { skipped: false };
},
}));
if (loading) { if (loading) {
return <Typography.Text type="secondary"></Typography.Text>; return <Typography.Text type="secondary"></Typography.Text>;
} }
@@ -169,14 +197,15 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
<Button onClick={addRow} style={{ marginBottom: 16 }}> <Button onClick={addRow} style={{ marginBottom: 16 }}>
</Button> </Button>
<Button type="primary" loading={saving} onClick={() => void save()}> <Button type="primary" loading={saving} onClick={() => void save().catch(() => undefined)}>
</Button> </Button>
</Form> </Form>
); );
} }
return ( <Form layout="vertical" requiredMark={false}> return (
<Form layout="vertical" requiredMark={false}>
<Alert <Alert
type="info" type="info"
showIcon showIcon
@@ -213,7 +242,8 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
<Button type="link" danger onClick={() => removeAt(index)}> <Button type="link" danger onClick={() => removeAt(index)}>
</Button> </Button>
) : null} </Space> ) : null}
</Space>
{!isCollapsed ? ( {!isCollapsed ? (
<> <>
@@ -266,7 +296,8 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
/> />
</Form.Item> </Form.Item>
<Form.Item label="其他说明" style={{ marginBottom: 0 }}> <Input <Form.Item label="其他说明" style={{ marginBottom: 0 }}>
<Input
placeholder="不可叠加" placeholder="不可叠加"
value={item.otherNotes || ''} value={item.otherNotes || ''}
onChange={(e) => updateAt(index, { otherNotes: e.target.value })} onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
@@ -285,12 +316,15 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
) : null} ) : null}
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}> <Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
</Typography.Paragraph> </Typography.Paragraph>
<Button type="primary" loading={saving} onClick={() => void save()}> <Button type="primary" loading={saving} onClick={() => void save().catch(() => undefined)}>
</Button> </Button>
</Form> </Form>
); );
} },
);
export default AdminStorePackagesSection;
+12 -5
View File
@@ -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) {