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
+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>