feat(store): add two-level store categories for admin and partner open-store
CI / verify (pull_request) Has been cancelled

Admin CRUD under stores menu; partner picks leaf category on create.
Default sync only inserts missing rows and never updates existing ones.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-22 12:10:02 +08:00
parent 823e439101
commit 9a550cbaaf
18 changed files with 848 additions and 26 deletions
+2
View File
@@ -20,6 +20,7 @@ import CityPartnersPage from './pages/CityPartnersPage';
import CityWarehousesPage from './pages/CityWarehousesPage';
import FulfillmentProvidersPage from './pages/FulfillmentProvidersPage';
import StoreMediaPage from './pages/StoreMediaPage';
import StoreCategoriesPage from './pages/StoreCategoriesPage';
import PromoCodesPage from './pages/PromoCodesPage';
import PromoCodeDetailLayout from './pages/promo/PromoCodeDetailLayout';
import PromoCodeDetailPage from './pages/promo/PromoCodeDetailPage';
@@ -69,6 +70,7 @@ export default function App() {
<Route path="/products" element={<ProductsPage />} />
<Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} />
<Route path="/stores" element={<StoresPage />} />
<Route path="/store-categories" element={<StoreCategoriesPage />} />
<Route path="/store-accounts" element={<StoreAccountsPage />} />
<Route path="/store-media" element={<StoreMediaPage />} />
<Route path="/resources" element={<ResourcesPage />} />
@@ -44,6 +44,7 @@ const MENU_ITEMS: MenuProps['items'] = [
label: '门店',
children: [
{ key: '/stores', label: '门店列表' },
{ key: '/store-categories', label: '门店分类' },
{ key: '/store-accounts', label: '门店账户' },
{ key: '/store-media', label: '门店资源' },
],
+4
View File
@@ -27,6 +27,10 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
{ value: 'STORE_AUDIT', label: '门店审核' },
{ value: 'STORE_ACCOUNT_CREATE', label: '新增门店账户' },
{ value: 'STORE_ACCOUNT_UPDATE', label: '编辑门店账户' },
{ value: 'STORE_CATEGORY_CREATE', label: '新增门店分类' },
{ value: 'STORE_CATEGORY_UPDATE', label: '编辑门店分类' },
{ value: 'STORE_CATEGORY_DELETE', label: '删除门店分类' },
{ value: 'STORE_CATEGORY_ENSURE', label: '初始化默认门店分类' },
{ value: 'PRODUCT_CREATE', label: '新增商品' },
{ value: 'PRODUCT_UPDATE', label: '编辑商品' },
{ value: 'PRODUCT_DELETE', label: '删除商品' },
@@ -0,0 +1,233 @@
import { useEffect, useMemo, useState } from 'react';
import {
Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request } from '../lib/api';
type CategoryNode = {
id: string;
code: string;
name: string;
sort: number;
parentId: string | null;
status: string;
children?: CategoryNode[];
};
type FlatRow = CategoryNode & { level: 1 | 2; parentName?: string };
function flattenTree(tree: CategoryNode[]): FlatRow[] {
const rows: FlatRow[] = [];
for (const root of tree) {
rows.push({ ...root, level: 1, children: undefined });
for (const child of root.children ?? []) {
rows.push({
...child,
level: 2,
parentName: root.name,
children: undefined,
});
}
}
return rows;
}
export default function StoreCategoriesPage() {
const [tree, setTree] = useState<CategoryNode[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<FlatRow | null>(null);
const [form] = Form.useForm();
const rows = useMemo(() => flattenTree(tree), [tree]);
const rootOptions = useMemo(
() => tree.filter((n) => n.status === 'ACTIVE').map((n) => ({ value: n.id, label: n.name })),
[tree],
);
async function reload() {
setLoading(true);
try {
const data = await request<CategoryNode[]>('/admin/store-categories');
setTree(Array.isArray(data) ? data : []);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载失败');
} finally {
setLoading(false);
}
}
useEffect(() => {
void reload();
}, []);
function openCreate(parentId?: string) {
setEditing(null);
form.setFieldsValue({
code: '',
name: '',
sort: 0,
parentId: parentId || undefined,
status: 'ACTIVE',
});
setModalOpen(true);
}
function openEdit(row: FlatRow) {
setEditing(row);
form.setFieldsValue({
code: row.code,
name: row.name,
sort: row.sort,
parentId: row.parentId || undefined,
status: row.status,
});
setModalOpen(true);
}
async function submit() {
const values = await form.validateFields();
const payload = {
code: String(values.code).trim().toUpperCase(),
name: String(values.name).trim(),
sort: Number(values.sort ?? 0),
parentId: values.parentId || null,
status: values.status || 'ACTIVE',
};
if (editing) {
await request(`/admin/store-categories/${editing.id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
message.success('已保存');
} else {
await request('/admin/store-categories', {
method: 'POST',
body: JSON.stringify(payload),
});
message.success('已创建');
}
setModalOpen(false);
void reload();
}
const columns: ColumnsType<FlatRow> = [
{
title: '层级',
dataIndex: 'level',
width: 80,
render: (level) => (level === 1 ? <Tag color="blue"></Tag> : <Tag></Tag>),
},
{
title: '名称',
dataIndex: 'name',
render: (name, row) => (
<span style={{ paddingLeft: row.level === 2 ? 24 : 0 }}>
{row.level === 2 ? `${row.parentName || ''} / ` : ''}
{name}
</span>
),
},
{ title: '编码', dataIndex: 'code', width: 140 },
{ title: '排序', dataIndex: 'sort', width: 80 },
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s) => (
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{s === 'ACTIVE' ? '启用' : '停用'}</Tag>
),
},
{
title: '操作',
width: 220,
render: (_, row) => (
<Space wrap>
<Button type="link" size="small" onClick={() => openEdit(row)}></Button>
{row.level === 1 ? (
<Button type="link" size="small" onClick={() => openCreate(row.id)}></Button>
) : null}
<Popconfirm
title={row.level === 1 ? '删除一级分类?若有门店占用将改为停用' : '删除该分类?若有门店占用将改为停用'}
onConfirm={async () => {
await request(`/admin/store-categories/${row.id}`, { method: 'DELETE' });
message.success('已处理');
void reload();
}}
>
<Button type="link" size="small" danger></Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Space direction="vertical" size={0}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary">/宿/ </Typography.Text>
</Space>
<Space>
<Button
onClick={async () => {
await request('/admin/store-categories/ensure-defaults', { method: 'POST' });
message.success('已同步默认分类');
void reload();
}}
>
</Button>
<Button type="primary" onClick={() => openCreate()}></Button>
</Space>
</Space>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={rows}
pagination={false}
className="admin-table-nowrap"
/>
<Modal
title={editing ? '编辑分类' : '新增分类'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => void submit()}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="code" label="编码" rules={[{ required: true, message: '请填写编码' }]}>
<Input placeholder="如 DINING / HOTPOT" disabled={!!editing} />
</Form.Item>
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
<Input placeholder="分类名称" />
</Form.Item>
<Form.Item name="parentId" label="上级分类(空=一级)">
<Select
allowClear
placeholder="不选则为一级分类"
options={rootOptions.filter((o) => o.value !== editing?.id)}
disabled={editing?.level === 1 && (tree.find((t) => t.id === editing.id)?.children?.length ?? 0) > 0}
/>
</Form.Item>
<Form.Item name="sort" label="排序" initialValue={0}>
<InputNumber style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="status" label="状态" initialValue="ACTIVE">
<Select
options={[
{ value: 'ACTIVE', label: '启用' },
{ value: 'DISABLED', label: '停用' },
]}
/>
</Form.Item>
</Form>
</Modal>
</div>
);
}
+15 -1
View File
@@ -10,6 +10,8 @@ export type StoreDraftForm = {
address: string;
openTime: string;
closeTime: string;
categoryParentId: string;
categoryId: string;
intro: string;
coverUrl: string;
envPhotoUrls: string[];
@@ -41,6 +43,8 @@ export const defaultStoreForm = (): StoreDraftForm => ({
address: '',
openTime: '10:00',
closeTime: '22:00',
categoryParentId: '',
categoryId: '',
intro: '',
coverUrl: '',
envPhotoUrls: ['', '', ''],
@@ -74,6 +78,8 @@ function normalizeForm(raw: Record<string, unknown>): StoreDraftForm {
address: String(raw.address ?? base.address),
openTime: String(raw.openTime ?? base.openTime),
closeTime: String(raw.closeTime ?? base.closeTime),
categoryParentId: String(raw.categoryParentId ?? base.categoryParentId),
categoryId: String(raw.categoryId ?? base.categoryId),
intro: String(raw.intro ?? base.intro),
coverUrl: String(raw.coverUrl ?? base.coverUrl),
envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, 3),
@@ -135,7 +141,14 @@ function timeToMinutes(value: string): number {
export function validateStoreStep1(
form: Pick<
StoreDraftForm,
'regionCodes' | 'cityId' | 'name' | 'address' | 'openTime' | 'closeTime' | 'intro'
| 'regionCodes'
| 'cityId'
| 'name'
| 'address'
| 'openTime'
| 'closeTime'
| 'categoryId'
| 'intro'
>,
): string | null {
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
@@ -149,6 +162,7 @@ export function validateStoreStep1(
if (timeToMinutes(form.openTime.trim()) >= timeToMinutes(form.closeTime.trim())) {
return '营业结束时间须晚于开始时间';
}
if (!form.categoryId.trim()) return '请选择店铺类型';
if (form.intro.trim()) {
const len = form.intro.trim().length;
if (len < 10 || len > 500) return '门店简介须为 10~500 字';
+85 -4
View File
@@ -45,14 +45,15 @@ import {
const STEPS = ['基本信息', '照片上传', '结算资质'] as const;
type StoreCategoryNode = {
id: string;
name: string;
children?: StoreCategoryNode[];
};
type FieldErrors = {
phone?: string;
storeSmsCode?: string;
};
@@ -101,6 +102,8 @@ export default function StoreCreatePage() {
const [smsHint, setSmsHint] = useState('');
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
const draftSaveDisabledRef = useRef(false);
function reportFormError(message: string) {
@@ -151,6 +154,26 @@ export default function StoreCreatePage() {
}, []);
useEffect(() => {
void request<StoreCategoryNode[]>('PARTNER_H5', '/partner/store-categories')
.then((list) => {
const tree = Array.isArray(list) ? list : [];
setCategoryTree(tree);
if (form.categoryId && !form.categoryParentId) {
const parent = tree.find((root) =>
(root.children ?? []).some((child) => child.id === form.categoryId),
);
if (parent) patchForm({ categoryParentId: parent.id });
}
})
.catch(() => setCategoryTree([]));
}, []);
const categoryChildren = useMemo(() => {
const parent = categoryTree.find((item) => item.id === form.categoryParentId);
return Array.isArray(parent?.children) ? parent!.children! : [];
}, [categoryTree, form.categoryParentId]);
useEffect(() => {
@@ -491,6 +514,8 @@ export default function StoreCreatePage() {
closeTime: form.closeTime.trim(),
categoryId: form.categoryId.trim(),
intro: form.intro.trim() || undefined,
coverUrl: form.coverUrl.trim() || undefined,
@@ -653,6 +678,62 @@ export default function StoreCreatePage() {
</div>
<div className="partner-field">
<label> <span className="text-primary">*</span></label>
<div className="partner-input-row" style={{ gap: 8 }}>
<select
className="partner-field-input partner-field-input--block"
value={form.categoryParentId}
onChange={(e) => patchForm({ categoryParentId: e.target.value, categoryId: '' })}
aria-label="一级店铺类型"
>
<option value=""></option>
{categoryTree.map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
<select
className="partner-field-input partner-field-input--block"
value={form.categoryId}
onChange={(e) => patchForm({ categoryId: e.target.value })}
disabled={!form.categoryParentId}
aria-label="二级店铺类型"
>
<option value="">{form.categoryParentId ? '选择细类' : '请先选大类'}</option>
{categoryChildren.map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
</div>
</div>
<div className="partner-field">
<label> <span className="text-primary">*</span></label>