From 9a550cbaaf8cd33707b2a8f61aa58fbfaba15bdc Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Wed, 22 Jul 2026 12:10:02 +0800 Subject: [PATCH] feat(store): add two-level store categories for admin and partner open-store 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 --- apps/admin-web/src/App.tsx | 2 + apps/admin-web/src/layouts/AdminLayout.tsx | 1 + apps/admin-web/src/lib/hq-log.ts | 4 + .../src/pages/StoreCategoriesPage.tsx | 233 +++++++++++++++ apps/h5-partner/src/lib/storeDraft.ts | 16 +- apps/h5-partner/src/pages/StoreCreatePage.tsx | 89 +++++- server/dukang-api/prisma/schema.prisma | 16 +- server/dukang-api/prisma/schema.v31.prisma | 16 +- server/dukang-api/prisma/seed-v31.ts | 37 ++- .../hq-operation/hq-operation.constants.ts | 8 + .../ops/admin-store-categories.controller.ts | 68 +++++ .../src/modules/ops/dto/admin-mutate.dto.ts | 50 ++++ .../dukang-api/src/modules/ops/ops.module.ts | 5 +- .../modules/store/store-category.defaults.ts | 33 +++ .../modules/store/store-category.service.ts | 267 ++++++++++++++++++ .../src/modules/store/store.controller.ts | 12 + .../src/modules/store/store.module.ts | 7 +- .../src/modules/store/store.service.ts | 10 +- 18 files changed, 848 insertions(+), 26 deletions(-) create mode 100644 apps/admin-web/src/pages/StoreCategoriesPage.tsx create mode 100644 server/dukang-api/src/modules/ops/admin-store-categories.controller.ts create mode 100644 server/dukang-api/src/modules/store/store-category.defaults.ts create mode 100644 server/dukang-api/src/modules/store/store-category.service.ts diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx index 57fd00b..c40a53a 100644 --- a/apps/admin-web/src/App.tsx +++ b/apps/admin-web/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index 1ed8f0d..5e4270a 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -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: '门店资源' }, ], diff --git a/apps/admin-web/src/lib/hq-log.ts b/apps/admin-web/src/lib/hq-log.ts index ff535d2..cccd121 100644 --- a/apps/admin-web/src/lib/hq-log.ts +++ b/apps/admin-web/src/lib/hq-log.ts @@ -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: '删除商品' }, diff --git a/apps/admin-web/src/pages/StoreCategoriesPage.tsx b/apps/admin-web/src/pages/StoreCategoriesPage.tsx new file mode 100644 index 0000000..7838554 --- /dev/null +++ b/apps/admin-web/src/pages/StoreCategoriesPage.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(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('/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 = [ + { + title: '层级', + dataIndex: 'level', + width: 80, + render: (level) => (level === 1 ? 一级 : 二级), + }, + { + title: '名称', + dataIndex: 'name', + render: (name, row) => ( + + {row.level === 2 ? `${row.parentName || ''} / ` : ''} + {name} + + ), + }, + { title: '编码', dataIndex: 'code', width: 140 }, + { title: '排序', dataIndex: 'sort', width: 80 }, + { + title: '状态', + dataIndex: 'status', + width: 90, + render: (s) => ( + {s === 'ACTIVE' ? '启用' : '停用'} + ), + }, + { + title: '操作', + width: 220, + render: (_, row) => ( + + + {row.level === 1 ? ( + + ) : null} + { + await request(`/admin/store-categories/${row.id}`, { method: 'DELETE' }); + message.success('已处理'); + void reload(); + }} + > + + + + ), + }, + ]; + + return ( +
+ + + 门店分类 + 两级分类:一级(餐饮/住宿/娱乐)→ 二级业态,供合伙人开店选择 + + + + + + + + + + setModalOpen(false)} + onOk={() => void submit()} + destroyOnClose + > +
+ + + + + + + + + + +
+ + ); +} diff --git a/apps/h5-partner/src/lib/storeDraft.ts b/apps/h5-partner/src/lib/storeDraft.ts index c8d67a4..f4285e7 100644 --- a/apps/h5-partner/src/lib/storeDraft.ts +++ b/apps/h5-partner/src/lib/storeDraft.ts @@ -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): 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 字'; diff --git a/apps/h5-partner/src/pages/StoreCreatePage.tsx b/apps/h5-partner/src/pages/StoreCreatePage.tsx index 02b6f83..9c163a3 100644 --- a/apps/h5-partner/src/pages/StoreCreatePage.tsx +++ b/apps/h5-partner/src/pages/StoreCreatePage.tsx @@ -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([]); + const draftSaveDisabledRef = useRef(false); function reportFormError(message: string) { @@ -151,6 +154,26 @@ export default function StoreCreatePage() { }, []); + useEffect(() => { + void request('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() { +
+ + + +
+ + + + + +
+ +
+
diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index 0004882..521a622 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -477,12 +477,18 @@ model CommonProductDetailTemplate { } model CommonStoreCategory { - id BigInt @id @default(autoincrement()) @db.UnsignedBigInt - code String @unique @db.VarChar(32) - name String @db.VarChar(64) - sort Int @default(0) - stores Store[] + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + code String @unique @db.VarChar(32) + name String @db.VarChar(64) + sort Int @default(0) + parentId BigInt? @map("parent_id") @db.UnsignedBigInt + status String @default("ACTIVE") @db.VarChar(16) + parent CommonStoreCategory? @relation("StoreCategoryTree", fields: [parentId], references: [id], onDelete: Restrict) + children CommonStoreCategory[] @relation("StoreCategoryTree") + stores Store[] + @@index([parentId, sort]) + @@index([status]) @@map("common_store_category") } diff --git a/server/dukang-api/prisma/schema.v31.prisma b/server/dukang-api/prisma/schema.v31.prisma index e5f3b83..2ebe59f 100644 --- a/server/dukang-api/prisma/schema.v31.prisma +++ b/server/dukang-api/prisma/schema.v31.prisma @@ -335,12 +335,18 @@ model CommonProductItem { } model CommonStoreCategory { - id BigInt @id @default(autoincrement()) @db.UnsignedBigInt - code String @unique @db.VarChar(32) - name String @db.VarChar(64) - sort Int @default(0) - stores Store[] + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + code String @unique @db.VarChar(32) + name String @db.VarChar(64) + sort Int @default(0) + parentId BigInt? @map("parent_id") @db.UnsignedBigInt + status String @default("ACTIVE") @db.VarChar(16) + parent CommonStoreCategory? @relation("StoreCategoryTree", fields: [parentId], references: [id], onDelete: Restrict) + children CommonStoreCategory[] @relation("StoreCategoryTree") + stores Store[] + @@index([parentId, sort]) + @@index([status]) @@map("common_store_category") } diff --git a/server/dukang-api/prisma/seed-v31.ts b/server/dukang-api/prisma/seed-v31.ts index 026dd37..0010a31 100644 --- a/server/dukang-api/prisma/seed-v31.ts +++ b/server/dukang-api/prisma/seed-v31.ts @@ -266,13 +266,36 @@ async function main() { - const categories = await Promise.all([ - - prisma.commonStoreCategory.create({ data: { code: 'HOTPOT', name: '火锅', sort: 1 } }), - - prisma.commonStoreCategory.create({ data: { code: 'LOCAL', name: '地方菜', sort: 2 } }), - - ]); + const { DEFAULT_STORE_CATEGORY_TREE } = await import('../src/modules/store/store-category.defaults'); + const categoryByCode = new Map(); + for (const root of DEFAULT_STORE_CATEGORY_TREE) { + const parent = await prisma.commonStoreCategory.create({ + data: { + code: root.code, + name: root.name, + sort: root.sort, + parentId: null, + status: 'ACTIVE', + }, + }); + categoryByCode.set(root.code, parent); + for (const child of root.children) { + const row = await prisma.commonStoreCategory.create({ + data: { + code: child.code, + name: child.name, + sort: child.sort, + parentId: parent.id, + status: 'ACTIVE', + }, + }); + categoryByCode.set(child.code, row); + } + } + const categories = [ + categoryByCode.get('HOTPOT')!, + categoryByCode.get('LOCAL')!, + ]; diff --git a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts index f490360..079ffaa 100644 --- a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts +++ b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts @@ -31,6 +31,10 @@ export const HqOperationAction = { STORE_MEDIA_CREATE: 'STORE_MEDIA_CREATE', STORE_MEDIA_UPDATE: 'STORE_MEDIA_UPDATE', STORE_MEDIA_DELETE: 'STORE_MEDIA_DELETE', + STORE_CATEGORY_CREATE: 'STORE_CATEGORY_CREATE', + STORE_CATEGORY_UPDATE: 'STORE_CATEGORY_UPDATE', + STORE_CATEGORY_DELETE: 'STORE_CATEGORY_DELETE', + STORE_CATEGORY_ENSURE: 'STORE_CATEGORY_ENSURE', PRODUCT_CREATE: 'PRODUCT_CREATE', PRODUCT_UPDATE: 'PRODUCT_UPDATE', PRODUCT_DELETE: 'PRODUCT_DELETE', @@ -94,6 +98,10 @@ export const HQ_OPERATION_ACTION_LABELS: Record = { [HqOperationAction.STORE_MEDIA_CREATE]: '新增门店资源', [HqOperationAction.STORE_MEDIA_UPDATE]: '编辑门店资源', [HqOperationAction.STORE_MEDIA_DELETE]: '删除门店资源', + [HqOperationAction.STORE_CATEGORY_CREATE]: '新增门店分类', + [HqOperationAction.STORE_CATEGORY_UPDATE]: '编辑门店分类', + [HqOperationAction.STORE_CATEGORY_DELETE]: '删除门店分类', + [HqOperationAction.STORE_CATEGORY_ENSURE]: '初始化默认门店分类', [HqOperationAction.PRODUCT_CREATE]: '新增商品', [HqOperationAction.PRODUCT_UPDATE]: '编辑商品', [HqOperationAction.PRODUCT_DELETE]: '删除商品', diff --git a/server/dukang-api/src/modules/ops/admin-store-categories.controller.ts b/server/dukang-api/src/modules/ops/admin-store-categories.controller.ts new file mode 100644 index 0000000..0285678 --- /dev/null +++ b/server/dukang-api/src/modules/ops/admin-store-categories.controller.ts @@ -0,0 +1,68 @@ +import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common'; +import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; +import { HqOperation } from '../../common/hq-operation/hq-operation.decorator'; +import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants'; +import { StoreCategoryService } from '../store/store-category.service'; +import { + CreateStoreCategoryDto, + UpdateStoreCategoryDto, +} from './dto/admin-mutate.dto'; + +@Controller('admin/store-categories') +@UseGuards(HqAuthGuard) +export class AdminStoreCategoriesController { + constructor(private readonly categories: StoreCategoryService) {} + + @Get() + list() { + return this.categories.listTree({ includeDisabled: true, ensure: true }); + } + + @Get('flat') + listFlat() { + return this.categories.listFlat({ includeDisabled: true, ensure: true }); + } + + @Post('ensure-defaults') + @HqOperation({ + action: HqOperationAction.STORE_CATEGORY_ENSURE, + refType: 'STORE_CATEGORY', + includeBody: false, + }) + async ensureDefaults() { + await this.categories.ensureDefaults(); + return this.categories.listTree({ includeDisabled: true, ensure: false }); + } + + @Post() + @HqOperation({ + action: HqOperationAction.STORE_CATEGORY_CREATE, + refType: 'STORE_CATEGORY', + refIdField: 'id', + includeBody: true, + }) + create(@Body() dto: CreateStoreCategoryDto) { + return this.categories.create(dto); + } + + @Put(':id') + @HqOperation({ + action: HqOperationAction.STORE_CATEGORY_UPDATE, + refType: 'STORE_CATEGORY', + refIdParam: 'id', + includeBody: true, + }) + update(@Param('id') id: string, @Body() dto: UpdateStoreCategoryDto) { + return this.categories.update(BigInt(id), dto); + } + + @Delete(':id') + @HqOperation({ + action: HqOperationAction.STORE_CATEGORY_DELETE, + refType: 'STORE_CATEGORY', + refIdParam: 'id', + }) + remove(@Param('id') id: string) { + return this.categories.remove(BigInt(id)); + } +} diff --git a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts index e8fe9c5..72dd471 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts @@ -1150,3 +1150,53 @@ export class AdminBenefitGrantDto { @IsString() remark?: string; } + +export class CreateStoreCategoryDto { + @IsString() + @IsNotEmpty() + code: string; + + @IsString() + @IsNotEmpty() + name: string; + + @IsOptional() + @Type(() => Number) + @IsNumber() + sort?: number; + + /** 空/不传 = 一级分类;传入一级 id = 二级分类 */ + @IsOptional() + @IsString() + parentId?: string | null; + + @IsOptional() + @IsIn(['ACTIVE', 'DISABLED']) + status?: string; +} + +export class UpdateStoreCategoryDto { + @IsOptional() + @IsString() + @IsNotEmpty() + code?: string; + + @IsOptional() + @IsString() + @IsNotEmpty() + name?: string; + + @IsOptional() + @Type(() => Number) + @IsNumber() + sort?: number; + + @IsOptional() + @ValidateIf((_, v) => v !== null && v !== undefined) + @IsString() + parentId?: string | null; + + @IsOptional() + @IsIn(['ACTIVE', 'DISABLED']) + status?: string; +} diff --git a/server/dukang-api/src/modules/ops/ops.module.ts b/server/dukang-api/src/modules/ops/ops.module.ts index b936a10..84fe912 100644 --- a/server/dukang-api/src/modules/ops/ops.module.ts +++ b/server/dukang-api/src/modules/ops/ops.module.ts @@ -3,6 +3,7 @@ import { CityScopeModule } from '../city-scope/city-scope.module'; import { IamModule } from '../iam/iam.module'; import { TradeModule } from '../trade/trade.module'; import { FulfillmentModule } from '../fulfillment/fulfillment.module'; +import { StoreModule } from '../store/store.module'; import { AdminDashboardController } from './admin-dashboard.controller'; import { AdminDashboardService } from './admin-dashboard.service'; import { AdminUsersController } from './admin-users.controller'; @@ -10,6 +11,7 @@ import { AdminUsersService } from './admin-users.service'; import { AdminOrdersController } from './admin-orders.controller'; import { AdminOrdersService } from './admin-orders.service'; import { AdminStoresController, AdminStoreAccountsController, AdminStoreMediaController } from './admin-stores.controller'; +import { AdminStoreCategoriesController } from './admin-store-categories.controller'; import { AdminStoresService } from './admin-stores.service'; import { AdminPartnersController, AdminPartnerAccountsController } from './admin-partners.controller'; import { AdminPartnersService } from './admin-partners.service'; @@ -59,7 +61,7 @@ import { AdminSystemConfigController } from './admin-system-config.controller'; import { AdminFulfillmentProvidersController } from './admin-fulfillment-providers.controller'; @Module({ - imports: [CityScopeModule, IamModule, TradeModule, FulfillmentModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule], + imports: [CityScopeModule, IamModule, TradeModule, FulfillmentModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule, StoreModule], controllers: [ AdminDashboardController, AdminDeployController, @@ -68,6 +70,7 @@ import { AdminFulfillmentProvidersController } from './admin-fulfillment-provide AdminStoresController, AdminStoreAccountsController, AdminStoreMediaController, + AdminStoreCategoriesController, AdminPartnersController, AdminPartnerAccountsController, AdminCitiesController, diff --git a/server/dukang-api/src/modules/store/store-category.defaults.ts b/server/dukang-api/src/modules/store/store-category.defaults.ts new file mode 100644 index 0000000..c7527a0 --- /dev/null +++ b/server/dukang-api/src/modules/store/store-category.defaults.ts @@ -0,0 +1,33 @@ +/** 门店两级分类默认树(一级 + 二级) */ +export const DEFAULT_STORE_CATEGORY_TREE = [ + { + code: 'DINING', + name: '餐饮', + sort: 1, + children: [ + { code: 'LOCAL', name: '地方菜', sort: 1 }, + { code: 'WESTERN', name: '西餐', sort: 2 }, + { code: 'BBQ', name: '烧烤', sort: 3 }, + { code: 'HOTPOT', name: '火锅', sort: 4 }, + ], + }, + { + code: 'LODGING', + name: '住宿', + sort: 2, + children: [ + { code: 'BUDGET_HOTEL', name: '快捷酒店', sort: 1 }, + { code: 'INN', name: '旅馆', sort: 2 }, + { code: 'LUXURY_HOTEL', name: '豪华酒店', sort: 3 }, + ], + }, + { + code: 'ENTERTAINMENT', + name: '娱乐', + sort: 3, + children: [ + { code: 'KTV', name: 'KTV', sort: 1 }, + { code: 'CLUB', name: '会所', sort: 2 }, + ], + }, +] as const; diff --git a/server/dukang-api/src/modules/store/store-category.service.ts b/server/dukang-api/src/modules/store/store-category.service.ts new file mode 100644 index 0000000..efd2cf3 --- /dev/null +++ b/server/dukang-api/src/modules/store/store-category.service.ts @@ -0,0 +1,267 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../../common/prisma/prisma.module'; +import { serializeBigInt } from '../../common/decorators/current-user.decorator'; +import { DEFAULT_STORE_CATEGORY_TREE } from './store-category.defaults'; + +export type StoreCategoryNode = { + id: string; + code: string; + name: string; + sort: number; + parentId: string | null; + status: string; + children: StoreCategoryNode[]; +}; + +@Injectable() +export class StoreCategoryService { + constructor(private readonly prisma: PrismaService) {} + + /** + * 仅补齐缺失的默认分类,绝不 update 已有行(避免改动线上历史数据)。 + * 已存在的 HOTPOT/LOCAL 等保持原样;缺的一级/二级才 create。 + */ + async ensureDefaults() { + for (const root of DEFAULT_STORE_CATEGORY_TREE) { + let parent = await this.prisma.commonStoreCategory.findUnique({ + where: { code: root.code }, + }); + if (!parent) { + parent = await this.prisma.commonStoreCategory.create({ + data: { + code: root.code, + name: root.name, + sort: root.sort, + parentId: null, + status: 'ACTIVE', + }, + }); + } + for (const child of root.children) { + const existing = await this.prisma.commonStoreCategory.findUnique({ + where: { code: child.code }, + }); + if (existing) continue; + await this.prisma.commonStoreCategory.create({ + data: { + code: child.code, + name: child.name, + sort: child.sort, + parentId: parent.id, + status: 'ACTIVE', + }, + }); + } + } + } + + private mapNode( + row: { + id: bigint; + code: string; + name: string; + sort: number; + parentId: bigint | null; + status: string; + children?: Array<{ + id: bigint; + code: string; + name: string; + sort: number; + parentId: bigint | null; + status: string; + }>; + }, + includeDisabledChildren: boolean, + ): StoreCategoryNode { + const children = (row.children ?? []) + .filter((c) => includeDisabledChildren || c.status === 'ACTIVE') + .sort((a, b) => a.sort - b.sort || Number(a.id - b.id)) + .map((c) => this.mapNode(c, includeDisabledChildren)); + return { + id: String(row.id), + code: row.code, + name: row.name, + sort: row.sort, + parentId: row.parentId != null ? String(row.parentId) : null, + status: row.status, + children, + }; + } + + async listTree(options?: { includeDisabled?: boolean; ensure?: boolean }) { + if (options?.ensure !== false) { + await this.ensureDefaults(); + } + const includeDisabled = options?.includeDisabled === true; + const roots = await this.prisma.commonStoreCategory.findMany({ + where: { + parentId: null, + ...(includeDisabled ? {} : { status: 'ACTIVE' }), + }, + include: { + children: { + orderBy: [{ sort: 'asc' }, { id: 'asc' }], + }, + }, + orderBy: [{ sort: 'asc' }, { id: 'asc' }], + }); + return serializeBigInt(roots.map((r) => this.mapNode(r, includeDisabled))); + } + + async listFlat(options?: { includeDisabled?: boolean; ensure?: boolean }) { + if (options?.ensure !== false) { + await this.ensureDefaults(); + } + const includeDisabled = options?.includeDisabled === true; + const rows = await this.prisma.commonStoreCategory.findMany({ + where: includeDisabled ? undefined : { status: 'ACTIVE' }, + orderBy: [{ parentId: 'asc' }, { sort: 'asc' }, { id: 'asc' }], + }); + return serializeBigInt( + rows.map((row) => ({ + id: String(row.id), + code: row.code, + name: row.name, + sort: row.sort, + parentId: row.parentId != null ? String(row.parentId) : null, + status: row.status, + level: row.parentId == null ? 1 : 2, + })), + ); + } + + async assertLeafCategoryId(categoryId: bigint) { + const row = await this.prisma.commonStoreCategory.findUnique({ where: { id: categoryId } }); + if (!row || row.status !== 'ACTIVE') { + throw new BadRequestException('店铺类型不存在或已停用'); + } + if (row.parentId == null) { + throw new BadRequestException('请选择二级店铺类型'); + } + return row; + } + + async create(dto: { + code: string; + name: string; + sort?: number; + parentId?: string | null; + status?: string; + }) { + const code = dto.code.trim().toUpperCase(); + const name = dto.name.trim(); + if (!code) throw new BadRequestException('请填写分类编码'); + if (!name) throw new BadRequestException('请填写分类名称'); + let parentId: bigint | null = null; + if (dto.parentId) { + const parent = await this.prisma.commonStoreCategory.findUnique({ + where: { id: BigInt(dto.parentId) }, + }); + if (!parent || parent.parentId != null) { + throw new BadRequestException('父级必须是一级分类'); + } + parentId = parent.id; + } + try { + const row = await this.prisma.commonStoreCategory.create({ + data: { + code, + name, + sort: dto.sort ?? 0, + parentId, + status: dto.status === 'DISABLED' ? 'DISABLED' : 'ACTIVE', + }, + }); + return serializeBigInt(row); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + throw new BadRequestException('分类编码已存在'); + } + throw e; + } + } + + async update( + id: bigint, + dto: { + code?: string; + name?: string; + sort?: number; + parentId?: string | null; + status?: string; + }, + ) { + const existing = await this.prisma.commonStoreCategory.findUnique({ where: { id } }); + if (!existing) throw new NotFoundException('分类不存在'); + + let parentId: bigint | null | undefined = undefined; + if (dto.parentId !== undefined) { + if (dto.parentId == null || dto.parentId === '') { + parentId = null; + } else { + if (BigInt(dto.parentId) === id) { + throw new BadRequestException('不能将分类设为自己的子级'); + } + const parent = await this.prisma.commonStoreCategory.findUnique({ + where: { id: BigInt(dto.parentId) }, + }); + if (!parent || parent.parentId != null) { + throw new BadRequestException('父级必须是一级分类'); + } + // 一级分类若已有子级,不允许变成二级 + if (existing.parentId == null) { + const childCount = await this.prisma.commonStoreCategory.count({ + where: { parentId: id }, + }); + if (childCount > 0) { + throw new BadRequestException('该一级分类下仍有二级分类,不能改为二级'); + } + } + parentId = parent.id; + } + } + + try { + const row = await this.prisma.commonStoreCategory.update({ + where: { id }, + data: { + ...(dto.code !== undefined ? { code: dto.code.trim().toUpperCase() } : {}), + ...(dto.name !== undefined ? { name: dto.name.trim() } : {}), + ...(dto.sort !== undefined ? { sort: dto.sort } : {}), + ...(parentId !== undefined ? { parentId } : {}), + ...(dto.status !== undefined + ? { status: dto.status === 'DISABLED' ? 'DISABLED' : 'ACTIVE' } + : {}), + }, + }); + return serializeBigInt(row); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + throw new BadRequestException('分类编码已存在'); + } + throw e; + } + } + + async remove(id: bigint) { + const existing = await this.prisma.commonStoreCategory.findUnique({ where: { id } }); + if (!existing) throw new NotFoundException('分类不存在'); + const childCount = await this.prisma.commonStoreCategory.count({ where: { parentId: id } }); + if (childCount > 0) { + throw new BadRequestException('请先删除或停用下级分类'); + } + const storeCount = await this.prisma.store.count({ where: { categoryId: id } }); + if (storeCount > 0) { + // 软停用,避免破坏已有门店关联 + const row = await this.prisma.commonStoreCategory.update({ + where: { id }, + data: { status: 'DISABLED' }, + }); + return serializeBigInt({ ...row, softDisabled: true }); + } + await this.prisma.commonStoreCategory.delete({ where: { id } }); + return { id: String(id), deleted: true }; + } +} diff --git a/server/dukang-api/src/modules/store/store.controller.ts b/server/dukang-api/src/modules/store/store.controller.ts index c14ea9c..b11064a 100644 --- a/server/dukang-api/src/modules/store/store.controller.ts +++ b/server/dukang-api/src/modules/store/store.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common'; import { StoreService } from './store.service'; +import { StoreCategoryService } from './store-category.service'; import { RedeemService } from '../redeem/redeem.service'; import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard'; import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard'; @@ -23,6 +24,17 @@ export class PublicStoreController { } } +@Controller('partner/store-categories') +@UseGuards(JwtAuthGuard) +export class PartnerStoreCategoriesController { + constructor(private readonly categories: StoreCategoryService) {} + + @Get() + list() { + return this.categories.listTree({ includeDisabled: false, ensure: true }); + } +} + @Controller('partner/stores') @UseGuards(JwtAuthGuard) export class PartnerStoreController { diff --git a/server/dukang-api/src/modules/store/store.module.ts b/server/dukang-api/src/modules/store/store.module.ts index 30db0ec..11a9739 100644 --- a/server/dukang-api/src/modules/store/store.module.ts +++ b/server/dukang-api/src/modules/store/store.module.ts @@ -4,9 +4,11 @@ import { RedeemModule } from '../redeem/redeem.module'; import { AnalyticsModule } from '../analytics/analytics.module'; import { CityScopeModule } from '../city-scope/city-scope.module'; import { StoreService } from './store.service'; +import { StoreCategoryService } from './store-category.service'; import { PartnerDashboardController, PartnerReportController, + PartnerStoreCategoriesController, PartnerStoreController, PublicStoreController, ShopDashboardController, @@ -17,13 +19,14 @@ import { imports: [IamModule, AnalyticsModule, CityScopeModule, forwardRef(() => RedeemModule)], controllers: [ PublicStoreController, + PartnerStoreCategoriesController, PartnerStoreController, PartnerDashboardController, PartnerReportController, ShopStoreController, ShopDashboardController, ], - providers: [StoreService], - exports: [StoreService], + providers: [StoreService, StoreCategoryService], + exports: [StoreService, StoreCategoryService], }) export class StoreModule {} diff --git a/server/dukang-api/src/modules/store/store.service.ts b/server/dukang-api/src/modules/store/store.service.ts index ff95efc..27a9242 100644 --- a/server/dukang-api/src/modules/store/store.service.ts +++ b/server/dukang-api/src/modules/store/store.service.ts @@ -13,6 +13,7 @@ import { parseBigIntParam } from '../../common/parse-bigint'; import { AnalyticsService } from '../analytics/analytics.service'; import { PartnerCityService } from '../city-scope/partner-city.service'; import { AuthService } from '../iam/auth.service'; +import { StoreCategoryService } from './store-category.service'; @Injectable() export class StoreService { @@ -23,6 +24,7 @@ export class StoreService { private readonly analyticsService: AnalyticsService, private readonly partnerCityService: PartnerCityService, private readonly authService: AuthService, + private readonly storeCategoryService: StoreCategoryService, ) {} async listOpenStores(cityCode?: string) { @@ -172,11 +174,17 @@ export class StoreService { const bankAccountNo = body.bankAccountNo ? String(body.bankAccountNo) : null; const bankBranch = body.bankBranch ? String(body.bankBranch) : null; + if (!body.categoryId) { + throw new BadRequestException('请选择店铺类型'); + } + const categoryId = parseBigIntParam(body.categoryId, '分类ID'); + await this.storeCategoryService.assertLeafCategoryId(categoryId); + const store = await this.prisma.store.create({ data: { cityId: city.id, partnerAccountId: primaryId, - categoryId: body.categoryId ? parseBigIntParam(body.categoryId, '分类ID') : null, + categoryId, name: String(body.name), phone: normalizedPhone, province: String(body.province ?? city.province ?? '河南省'),