diff --git a/apps/admin-web/src/lib/storeCreate.ts b/apps/admin-web/src/lib/storeCreate.ts index ced40d4..a7d3635 100644 --- a/apps/admin-web/src/lib/storeCreate.ts +++ b/apps/admin-web/src/lib/storeCreate.ts @@ -8,8 +8,11 @@ export type StoreCreateForm = { city?: string; district: string; districtCode?: string; + /** @deprecated 使用 categoryIds */ categoryParentId?: string; - categoryId: string; + /** @deprecated 使用 categoryIds */ + categoryId?: string; + categoryIds: string[]; name: string; phone: string; address: string; @@ -52,7 +55,7 @@ export function validateStoreCreateStep1( | 'partnerAccountId' | 'cityId' | 'regionCodes' - | 'categoryId' + | 'categoryIds' | 'name' | 'phone' | 'address' @@ -69,7 +72,7 @@ export function validateStoreCreateStep1( if (!form.partnerAccountId) return '请选择开城合伙人'; if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县'; if (!form.cityId) return '所选地区未匹配到开城城市,请先在「开城 → 城市」配置对应区划'; - if (!form.categoryId?.trim()) return '请选择门店分类(细类)'; + if (!form.categoryIds?.length) return '请至少选择一个门店分类(细类)'; if (!form.name?.trim()) return '请填写门店名称'; if (!form.phone?.trim()) return '请填写门店手机号'; if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号'; diff --git a/apps/admin-web/src/pages/StoresPage.tsx b/apps/admin-web/src/pages/StoresPage.tsx index 6f74233..14007b7 100644 --- a/apps/admin-web/src/pages/StoresPage.tsx +++ b/apps/admin-web/src/pages/StoresPage.tsx @@ -224,6 +224,7 @@ type StoreRow = { settlementRate?: number; sortOrder?: number; category?: { id: string; name: string; parentId?: string | null } | null; + categories?: { id: string; name: string; parentId?: string | null }[] | null; }; type PartnerOption = { id: string; companyName?: string | null; name?: string | null; phone?: string | null }; @@ -350,29 +351,17 @@ export default function StoresPage() { const selectedPartnerId = Form.useWatch('partnerAccountId', createForm); const selectedRegionCodes = Form.useWatch('regionCodes', createForm); const selectedCityId = Form.useWatch('cityId', createForm); - const selectedCategoryParentId = Form.useWatch('categoryParentId', createForm); - const editCategoryParentId = Form.useWatch('categoryParentId', editForm); - - const categoryParentOptions = useMemo( - () => - categoryTree - .filter((n) => n.status !== 'INACTIVE') - .map((n) => ({ value: n.id, label: n.name })), - [categoryTree], - ); - const categoryChildOptions = useMemo(() => { - const parent = categoryTree.find((n) => n.id === selectedCategoryParentId); - return (parent?.children ?? []) - .filter((n) => n.status !== 'INACTIVE') - .map((n) => ({ value: n.id, label: n.name })); - }, [categoryTree, selectedCategoryParentId]); - - const editCategoryChildOptions = useMemo(() => { - const parent = categoryTree.find((n) => n.id === editCategoryParentId); - return (parent?.children ?? []) - .filter((n) => n.status !== 'INACTIVE') - .map((n) => ({ value: n.id, label: n.name })); - }, [categoryTree, editCategoryParentId]); + const categoryLeafOptions = useMemo(() => { + const options: { value: string; label: string }[] = []; + for (const parent of categoryTree) { + if (parent.status === 'INACTIVE') continue; + for (const child of parent.children ?? []) { + if (child.status === 'INACTIVE') continue; + options.push({ value: child.id, label: `${parent.name} / ${child.name}` }); + } + } + return options; + }, [categoryTree]); async function deleteStore(id: string) { setDeleting(true); @@ -414,20 +403,14 @@ export default function StoresPage() { bankBranch?: string | null; }) : null; - const categoryId = category?.id != null ? String(category.id) : undefined; - let parentId = category?.parentId != null ? String(category.parentId) : undefined; - if (!parentId && categoryId) { - for (const parent of cats) { - if (parent.id === categoryId) { - parentId = parent.id; - break; - } - if ((parent.children ?? []).some((c) => c.id === categoryId)) { - parentId = parent.id; - break; - } - } - } + const categories = Array.isArray(d.categories) + ? (d.categories as { id?: string }[]) + : []; + const categoryIds = categories.length + ? categories.map((c) => String(c.id)).filter(Boolean) + : category?.id != null + ? [String(category.id)] + : []; const loginPhone = (typeof d.loginPhone === 'string' && d.loginPhone) || account?.phone || @@ -466,8 +449,7 @@ export default function StoresPage() { city: d.cityName, district: d.district, address: d.address, - categoryParentId: parentId, - categoryId, + categoryIds, latitude: d.latitude != null ? Number(d.latitude) : undefined, longitude: d.longitude != null ? Number(d.longitude) : undefined, settlementRate: d.settlementRate != null ? Number(d.settlementRate) * 100 : 60, @@ -530,7 +512,7 @@ export default function StoresPage() { !/^null$/i.test(v.benefitUsageRule.trim()) ? v.benefitUsageRule.trim() : null, - categoryId: v.categoryId, + categoryIds: v.categoryIds, province: v.province, city: v.city, district: v.district, @@ -661,8 +643,7 @@ export default function StoresPage() { 'partnerAccountId', 'regionCodes', 'cityId', - 'categoryParentId', - 'categoryId', + 'categoryIds', 'name', 'phone', 'address', @@ -691,7 +672,7 @@ export default function StoresPage() { body: JSON.stringify({ partnerAccountId: values.partnerAccountId, cityId: values.cityId, - categoryId: values.categoryId, + categoryIds: values.categoryIds, province: values.province, city: values.city, name: values.name.trim(), @@ -769,8 +750,14 @@ export default function StoresPage() { { key: 'category', title: '分类', - width: 100, - render: (_, row) => row.category?.name || '—', + width: 140, + render: (_, row) => { + const cats = Array.isArray(row.categories) + ? row.categories.map((c: { name?: string }) => c.name).filter(Boolean) + : []; + if (cats.length) return cats.join('、'); + return row.category?.name || '—'; + }, }, { key: 'cityName', title: '城市', dataIndex: 'cityName', width: 80 }, { key: 'phone', title: '登录号', dataIndex: 'phone', width: 120 }, @@ -1156,29 +1143,17 @@ export default function StoresPage() { @@ -1457,23 +1432,9 @@ export default function StoresPage() { - diff --git a/apps/h5-partner/src/lib/storeDraft.ts b/apps/h5-partner/src/lib/storeDraft.ts index c0290f6..02bc039 100644 --- a/apps/h5-partner/src/lib/storeDraft.ts +++ b/apps/h5-partner/src/lib/storeDraft.ts @@ -25,7 +25,10 @@ export type StoreDraftForm = { /** 人均费用(选填) */ avgPrice: string; categoryParentId: string; + /** @deprecated 使用 categoryIds */ categoryId: string; + /** 二级分类多选 */ + categoryIds: string[]; intro: string; /** 好客权益券使用规则 */ benefitUsageRule: string; @@ -68,6 +71,7 @@ export const defaultStoreForm = (): StoreDraftForm => ({ avgPrice: '', categoryParentId: '', categoryId: '', + categoryIds: [], intro: '', benefitUsageRule: '', coverUrl: '', @@ -122,6 +126,20 @@ export function normalizeStoreDraftForm(raw: Partial | null | un openTime2: String(raw.openTime2 ?? base.openTime2), closeTime2: String(raw.closeTime2 ?? base.closeTime2), avgPrice: String(raw.avgPrice ?? base.avgPrice), + categoryIds: (() => { + if (Array.isArray(raw.categoryIds) && raw.categoryIds.length) { + return raw.categoryIds.map(String).filter(Boolean); + } + const legacy = String(raw.categoryId ?? '').trim(); + return legacy ? [legacy] : base.categoryIds; + })(), + categoryId: (() => { + const ids = Array.isArray(raw.categoryIds) && raw.categoryIds.length + ? raw.categoryIds.map(String).filter(Boolean) + : []; + if (ids.length) return ids[0]; + return String(raw.categoryId ?? base.categoryId); + })(), envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, MIN_ENV_PHOTO_COUNT), // 兼容旧草稿:单个 contractUrl 迁移为数组 contractUrls: (() => { @@ -201,7 +219,7 @@ export function validateStoreStep1( | 'openTime2' | 'closeTime2' | 'avgPrice' - | 'categoryId' + | 'categoryIds' | 'intro' | 'benefitUsageRule' >, @@ -233,7 +251,7 @@ export function validateStoreStep1( const n = Number(form.avgPrice); if (Number.isNaN(n) || n < 0) return '人均费用须为非负数字'; } - if (!form.categoryId.trim()) return '请选择店铺类型'; + if (!form.categoryIds?.length) return '请至少选择一个店铺类型'; if (form.intro.trim()) { const len = form.intro.trim().length; if (len < 2 || len > 500) return '门店简介须为 2~500 字'; diff --git a/apps/h5-partner/src/pages/StoreCreatePage.tsx b/apps/h5-partner/src/pages/StoreCreatePage.tsx index 04c8879..580a872 100644 --- a/apps/h5-partner/src/pages/StoreCreatePage.tsx +++ b/apps/h5-partner/src/pages/StoreCreatePage.tsx @@ -206,9 +206,9 @@ export default function StoreCreatePage() { .then((list) => { const tree = Array.isArray(list) ? list : []; setCategoryTree(tree); - if (form.categoryId && !form.categoryParentId) { + if (form.categoryIds.length && !form.categoryParentId) { const parent = tree.find((root) => - (root.children ?? []).some((child) => child.id === form.categoryId), + (root.children ?? []).some((child) => form.categoryIds.includes(child.id)), ); if (parent) patchForm({ categoryParentId: parent.id }); } @@ -216,12 +216,6 @@ export default function StoreCreatePage() { .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(() => { @@ -523,7 +517,8 @@ export default function StoreCreatePage() { ...(form.avgPrice.trim() ? { avgPrice: Number(form.avgPrice) } : {}), - categoryId: form.categoryId.trim(), + categoryIds: form.categoryIds, + categoryId: form.categoryIds[0] || form.categoryId.trim(), intro: form.intro.trim() || undefined, benefitUsageRule: form.benefitUsageRule.trim() || undefined, @@ -690,56 +685,40 @@ export default function StoreCreatePage() {
- - -
- - - - + +
+ {categoryTree.map((parent) => ( +
+
{parent.name}
+
+ {(parent.children ?? []).map((child) => { + const checked = form.categoryIds.includes(child.id); + return ( + + ); + })} +
+
+ ))}
diff --git a/apps/mini-user/src/lib/store-display.ts b/apps/mini-user/src/lib/store-display.ts index cc50976..f4fcf4a 100644 --- a/apps/mini-user/src/lib/store-display.ts +++ b/apps/mini-user/src/lib/store-display.ts @@ -22,6 +22,7 @@ export function storeCategoryTags( tags?: unknown; categoryId?: string | null; category?: StoreCategoryLike | null; + categories?: StoreCategoryLike[] | null; }, tree: StoreCategoryTreeNode[] = [], ): string[] { @@ -30,24 +31,125 @@ export function storeCategoryTags( : []; if (fromJson.length) return fromJson; - const names: string[] = []; - const childName = String(store.category?.name || '').trim(); - const parentName = String(store.category?.parent?.name || '').trim(); - if (parentName) names.push(parentName); - if (childName && childName !== parentName) names.push(childName); + const multi = Array.isArray(store.categories) ? store.categories : []; + if (multi.length) { + return groupCategoryTags(multi, tree); + } - const storeCatId = String(store.categoryId || store.category?.id || ''); - const storeParentId = String(store.category?.parentId || ''); - for (const root of tree) { - if (root.id === storeParentId || root.id === storeCatId) { - if (root.name && !names.includes(root.name)) names.unshift(root.name); - } - for (const child of root.children ?? []) { - if (child.id === storeCatId) { - if (root.name && !names.includes(root.name)) names.unshift(root.name); - if (child.name && !names.includes(child.name)) names.push(child.name); + if (store.category || store.categoryId) { + const grouped = groupCategoryTags( + store.category ? [store.category] : [], + tree, + store.categoryId, + ); + if (grouped.length) return grouped; + } + + const storeCatId = String(store.categoryId || ''); + if (storeCatId) { + for (const root of tree) { + for (const child of root.children ?? []) { + if (child.id === storeCatId) { + return [`${root.name}|${child.name}`]; + } } } } - return names; + return []; +} + +/** 按大类分组:餐饮|火锅·江浙菜、娱乐|KTV */ +function groupCategoryTags( + categories: StoreCategoryLike[], + tree: StoreCategoryTreeNode[] = [], + fallbackCategoryId?: string | null, +): string[] { + if (!categories.length && fallbackCategoryId) { + return groupCategoryTags([{ id: String(fallbackCategoryId) }], tree); + } + + const groups = new Map(); + const order: string[] = []; + + for (const cat of categories) { + const resolved = resolveCategoryParts(cat, tree); + if (!resolved) continue; + const { parentKey, parentName, childName } = resolved; + if (!groups.has(parentKey)) { + groups.set(parentKey, { parentName, children: [] }); + order.push(parentKey); + } + const group = groups.get(parentKey)!; + if (childName && childName !== parentName && !group.children.includes(childName)) { + group.children.push(childName); + } + } + + return order + .map((key) => { + const group = groups.get(key)!; + if (group.parentName && group.children.length) { + return `${group.parentName}|${group.children.join('·')}`; + } + if (group.children.length) return group.children.join('·'); + return group.parentName; + }) + .filter(Boolean); +} + +function resolveCategoryParts( + cat: StoreCategoryLike, + tree: StoreCategoryTreeNode[] = [], +): { parentKey: string; parentName: string; childName: string } | null { + const childName = String(cat.name || '').trim(); + let parentName = String(cat.parent?.name || '').trim(); + const catId = String(cat.id || ''); + const parentId = String(cat.parentId || ''); + + if (!parentName && (parentId || catId)) { + for (const root of tree) { + if (parentId && root.id === parentId) { + parentName = root.name; + break; + } + for (const child of root.children ?? []) { + if (catId && child.id === catId) { + parentName = root.name; + if (!childName) return { parentKey: root.id, parentName, childName: child.name }; + break; + } + } + } + } + + if (!childName && !parentName) return null; + + const parentKey = parentId || parentName || catId; + if (!parentName && catId) { + for (const root of tree) { + for (const child of root.children ?? []) { + if (child.id === catId) { + return { parentKey: root.id, parentName: root.name, childName: child.name }; + } + } + } + } + + return { + parentKey, + parentName: parentName || childName, + childName: childName || parentName, + }; +} + +export function storeLeafCategoryIds(store: { + categoryId?: string | null; + category?: StoreCategoryLike | null; + categories?: StoreCategoryLike[] | null; +}): string[] { + if (Array.isArray(store.categories) && store.categories.length) { + return store.categories.map((c) => String(c.id || '')).filter(Boolean); + } + const id = String(store.categoryId || store.category?.id || ''); + return id ? [id] : []; } diff --git a/apps/mini-user/src/lib/stores-session.ts b/apps/mini-user/src/lib/stores-session.ts index 2855c84..12252ac 100644 --- a/apps/mini-user/src/lib/stores-session.ts +++ b/apps/mini-user/src/lib/stores-session.ts @@ -40,7 +40,7 @@ type StoresSession = { cache: StoresListCache | null; }; -const STORAGE_KEY = 'dukang_stores_session_v2'; +const STORAGE_KEY = 'dukang_stores_session_v3'; let memory: StoresSession | null = null; diff --git a/apps/mini-user/src/pages/store-detail/index.tsx b/apps/mini-user/src/pages/store-detail/index.tsx index 8355e04..2e05d7e 100644 --- a/apps/mini-user/src/pages/store-detail/index.tsx +++ b/apps/mini-user/src/pages/store-detail/index.tsx @@ -79,6 +79,12 @@ type Store = { parentId?: string | null; parent?: { name?: string } | null; } | null; + categories?: { + id?: string; + name?: string; + parentId?: string | null; + parent?: { name?: string } | null; + }[] | null; }; type RecentRedeem = { @@ -406,12 +412,15 @@ export default function StoreDetailPage() { {store.name} - {storeCategoryTags(store, categoryTree).map((tag) => ( - - {tag} - - ))} + {(() => { + const categoryLabels = storeCategoryTags(store, categoryTree); + return categoryLabels.length ? ( + + {categoryLabels.join('、')} + + ) : null; + })()} {[1, 2, 3, 4, 5].map((n) => ( diff --git a/apps/mini-user/src/pages/stores/index.tsx b/apps/mini-user/src/pages/stores/index.tsx index ffbbea0..f671d31 100644 --- a/apps/mini-user/src/pages/stores/index.tsx +++ b/apps/mini-user/src/pages/stores/index.tsx @@ -42,7 +42,7 @@ import { toWeappShareTimeline, } from '../../lib/wechat-share'; import BenefitSloganBar from '../../components/BenefitSloganBar'; -import { storeCategoryTags, storeStarCount } from '../../lib/store-display'; +import { storeCategoryTags, storeLeafCategoryIds, storeStarCount } from '../../lib/store-display'; import openBadgeImg from '../../assets/icons/store-open-badge.png'; type Store = { @@ -66,6 +66,12 @@ type Store = { parentId?: string | null; parent?: { name?: string } | null; } | null; + categories?: { + id?: string; + name?: string; + parentId?: string | null; + parent?: { name?: string } | null; + }[] | null; tags?: unknown; rating?: number | string | null; latitude?: number | string | null; @@ -288,14 +294,21 @@ export default function StoresPage() { function matchesCategory(store: Store): boolean { if (!category.parentId) return true; - const storeCatId = String(store.categoryId || store.category?.id || ''); - const storeParentId = String(store.category?.parentId || ''); - if (category.childId) { - return storeCatId === category.childId; + const leafIds = storeLeafCategoryIds(store); + const parentIds = new Set(); + if (Array.isArray(store.categories)) { + for (const cat of store.categories) { + if (cat.parentId) parentIds.add(String(cat.parentId)); + } } - if (storeParentId && storeParentId === category.parentId) return true; + const legacyParent = String(store.category?.parentId || ''); + if (legacyParent) parentIds.add(legacyParent); + if (category.childId) { + return leafIds.includes(category.childId); + } + if ([...parentIds].some((id) => id === category.parentId)) return true; const siblings = childIdsByParent.get(category.parentId) ?? []; - return siblings.includes(storeCatId); + return leafIds.some((id) => siblings.includes(id)); } const filtered = useMemo(() => { @@ -461,14 +474,10 @@ export default function StoresPage() { {s.name} {(() => { - const tags = storeCategoryTags(s, categoryTree); - return tags.length ? ( - - {tags.map((tag) => ( - - {tag} - - ))} + const categoryLabels = storeCategoryTags(s, categoryTree); + return categoryLabels.length ? ( + + {categoryLabels.join('、')} ) : null; })()} diff --git a/apps/mini-user/src/styles/store-detail.css b/apps/mini-user/src/styles/store-detail.css index 9413708..7f70062 100644 --- a/apps/mini-user/src/styles/store-detail.css +++ b/apps/mini-user/src/styles/store-detail.css @@ -100,6 +100,12 @@ } .store-detail-title-row { + display: flex; + align-items: center; + margin-bottom: 6px; +} + +.store-detail-tags-row { display: flex; flex-wrap: wrap; align-items: center; @@ -107,6 +113,20 @@ margin-bottom: 8px; } +.store-detail-category-line { + display: inline-block; + max-width: 100%; + padding: 2px 8px; + border-radius: 6px; + background: rgba(166, 29, 36, 0.1); + box-sizing: border-box; + font-size: 11px; + font-weight: 500; + line-height: 16px; + color: var(--color-heritage-red); + word-break: break-all; +} + .store-detail-name { font-family: var(--font-headline); font-size: 20px; diff --git a/apps/mini-user/src/styles/stores.css b/apps/mini-user/src/styles/stores.css index 2ed07a3..fdaa7f3 100644 --- a/apps/mini-user/src/styles/stores.css +++ b/apps/mini-user/src/styles/stores.css @@ -215,13 +215,32 @@ white-space: nowrap; } +.store-card-category-wrap { + align-self: flex-start; + max-width: 100%; + padding: 0 6px; + border-radius: 4px; + background: rgba(166, 29, 36, 0.1); + box-sizing: border-box; + line-height: 1; +} + +.store-card-category-text { + font-size: 10px; + font-weight: 500; + line-height: 14px; + color: var(--color-heritage-red); + word-break: break-all; +} + .store-card-tags { display: flex; - flex-wrap: nowrap; + flex-wrap: wrap; align-items: center; gap: 4px; min-width: 0; - overflow: hidden; + margin-top: 2px; + margin-bottom: 2px; } .store-card-tag { diff --git a/server/dukang-api/prisma/migrate-store-category-link.sql b/server/dukang-api/prisma/migrate-store-category-link.sql new file mode 100644 index 0000000..a4991b9 --- /dev/null +++ b/server/dukang-api/prisma/migrate-store-category-link.sql @@ -0,0 +1,21 @@ +-- 门店多分类关联表(v4.0.9+) +-- 执行:mysql ... < migrate-store-category-link.sql + +CREATE TABLE IF NOT EXISTS store_category_link ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + store_id BIGINT UNSIGNED NOT NULL, + category_id BIGINT UNSIGNED NOT NULL, + priority INT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_store_category_link (store_id, category_id), + KEY idx_store_category_link_category (category_id), + CONSTRAINT fk_store_category_link_store FOREIGN KEY (store_id) REFERENCES store_store(id) ON DELETE CASCADE, + CONSTRAINT fk_store_category_link_category FOREIGN KEY (category_id) REFERENCES common_store_category(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 从现有主分类回填 +INSERT IGNORE INTO store_category_link (store_id, category_id, priority) +SELECT id, category_id, 0 +FROM store_store +WHERE category_id IS NOT NULL; diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index a1b7f11..77e7162 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -1000,12 +1000,29 @@ model CommonStoreCategory { parent CommonStoreCategory? @relation("StoreCategoryTree", fields: [parentId], references: [id], onDelete: Restrict) children CommonStoreCategory[] @relation("StoreCategoryTree") stores Store[] + storeLinks StoreCategoryLink[] @@index([parentId, sort]) @@index([status]) @@map("common_store_category") } +/// 门店 ↔ 二级分类多对多;store.category_id 保留主分类(排序第一) +model StoreCategoryLink { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + storeId BigInt @map("store_id") @db.UnsignedBigInt + categoryId BigInt @map("category_id") @db.UnsignedBigInt + priority Int @default(0) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + category CommonStoreCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade) + + @@unique([storeId, categoryId]) + @@index([categoryId]) + @@map("store_category_link") +} + model CommonPromoCode { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt code String @unique @db.VarChar(32) @@ -1542,6 +1559,7 @@ model Store { packages StorePackage[] packageChangeRequests StorePackageChangeRequest[] infoChangeRequests StoreInfoChangeRequest[] + categoryLinks StoreCategoryLink[] @@index([cityId, status]) @@index([partnerAccountId]) diff --git a/server/dukang-api/src/modules/ops/admin-stores.service.ts b/server/dukang-api/src/modules/ops/admin-stores.service.ts index ab2da36..1fbbbef 100644 --- a/server/dukang-api/src/modules/ops/admin-stores.service.ts +++ b/server/dukang-api/src/modules/ops/admin-stores.service.ts @@ -14,6 +14,12 @@ import { contractMediaType, normalizeContractUrls } from '../../common/store-med import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto'; import { PartnerCityService } from '../city-scope/partner-city.service'; import { StoreCategoryService } from '../store/store-category.service'; +import { + attachStoreCategories, + parseUniqueCategoryIds, + storeCategoryLinkInclude, + syncStoreCategoryLinks, +} from '../store/store-category-link.util'; import { AnalyticsService } from '../analytics/analytics.service'; import type { CreateStoreAccountDto, @@ -123,6 +129,7 @@ export class AdminStoresService { cityRef: { select: { id: true, name: true, code: true } }, partnerAccount: { select: { id: true, companyName: true, name: true, phone: true } }, category: { select: { id: true, name: true, parentId: true } }, + ...storeCategoryLinkInclude, bindings: { where: { storeAccount: { isPrimary: 1 } }, take: 1, @@ -169,7 +176,7 @@ export class AdminStoresService { return serializeBigInt({ items: items.map((s) => { const { visibilityPhones, ...rest } = s; - return mapStoreCompat({ + return mapStoreCompat(attachStoreCategories({ ...rest, visibilityWhitelistEnabled: s.visibilityWhitelistEnabled, visibilityPhones: visibilityPhones.map((p) => p.phone), @@ -180,7 +187,7 @@ export class AdminStoresService { partner: s.partnerAccount, account: s.bindings[0]?.storeAccount ?? null, bindings: undefined, - }); + })); }), total, page, @@ -196,6 +203,7 @@ export class AdminStoresService { cityRef: true, partnerAccount: true, category: true, + ...storeCategoryLinkInclude, bindings: { where: { storeAccount: { isPrimary: 1 } }, take: 1, @@ -219,7 +227,7 @@ export class AdminStoresService { }), ]); const { visibilityPhones, ...rest } = store; - return serializeBigInt(mapStoreCompat({ + return serializeBigInt(mapStoreCompat(attachStoreCategories({ ...rest, visibilityWhitelistEnabled: store.visibilityWhitelistEnabled, visibilityPhones: visibilityPhones.map((p) => p.phone), @@ -235,7 +243,7 @@ export class AdminStoresService { redeemCount: store._count.redeemRecords, ratingCount: store._count.ratings, _count: undefined, - })); + }))); } async updateStoreStatus(id: bigint, dto: UpdateStoreStatusDto, actorId: bigint) { @@ -430,11 +438,22 @@ export class AdminStoresService { } let categoryId: bigint | undefined; - if (dto.categoryId !== undefined) { + let categoryIds: bigint[] | undefined; + if (dto.categoryIds !== undefined) { + categoryIds = parseUniqueCategoryIds(dto.categoryIds); + if (!categoryIds.length) { + throw new BadRequestException('请选择门店分类'); + } + for (const id of categoryIds) { + await this.storeCategoryService.assertLeafCategoryId(id); + } + categoryId = categoryIds[0]; + } else if (dto.categoryId !== undefined) { if (!dto.categoryId?.trim()) { throw new BadRequestException('请选择门店分类'); } categoryId = BigInt(dto.categoryId); + categoryIds = [categoryId]; await this.storeCategoryService.assertLeafCategoryId(categoryId); } @@ -661,6 +680,10 @@ export class AdminStoresService { }); } } + + if (categoryIds !== undefined) { + await syncStoreCategoryLinks(tx, id, categoryIds); + } }); return this.detailStore(id, actorId); @@ -693,11 +716,20 @@ export class AdminStoresService { if (!city) throw new BadRequestException('开城城市不存在'); await this.partnerCityService.assertPartnerAccountBoundToCity(partnerAccountId, city.id); - if (!dto.categoryId?.trim()) { + if (!dto.categoryId?.trim() && (!dto.categoryIds || !dto.categoryIds.length)) { throw new BadRequestException('请选择门店分类'); } - const categoryId = BigInt(dto.categoryId); - await this.storeCategoryService.assertLeafCategoryId(categoryId); + const categoryIds = parseUniqueCategoryIds(dto.categoryIds); + if (!categoryIds.length && dto.categoryId?.trim()) { + categoryIds.push(BigInt(dto.categoryId)); + } + if (!categoryIds.length) { + throw new BadRequestException('请选择门店分类'); + } + for (const id of categoryIds) { + await this.storeCategoryService.assertLeafCategoryId(id); + } + const categoryId = categoryIds[0]; const latitude = dto.latitude != null ? Number(dto.latitude) : null; const longitude = dto.longitude != null ? Number(dto.longitude) : null; @@ -775,6 +807,8 @@ export class AdminStoresService { }, }); + await syncStoreCategoryLinks(this.prisma, store.id, categoryIds); + if (dto.coverUrl) { const cover = await this.prisma.commonResource.create({ data: { 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 69b265d..3004e8c 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 @@ -45,9 +45,15 @@ export class CreateStoreDto { @IsString() contactPhone?: string; + @IsOptional() @IsString() - @IsNotEmpty() - categoryId: string; + categoryId?: string; + + /** 多选二级分类;至少选一项 */ + @IsOptional() + @IsArray() + @IsString({ each: true }) + categoryIds?: string[]; @IsOptional() @IsString() @@ -240,6 +246,12 @@ export class UpdateStoreDto { @IsString() categoryId?: string; + /** 多选二级分类;传此项时覆盖 categoryId */ + @IsOptional() + @IsArray() + @IsString({ each: true }) + categoryIds?: string[]; + @IsOptional() @IsNumber() @Min(0) diff --git a/server/dukang-api/src/modules/store/dto/assign-category.dto.ts b/server/dukang-api/src/modules/store/dto/assign-category.dto.ts new file mode 100644 index 0000000..fab6c1d --- /dev/null +++ b/server/dukang-api/src/modules/store/dto/assign-category.dto.ts @@ -0,0 +1,12 @@ +import { IsString, IsNotEmpty, IsOptional, IsInt, Min } from 'class-validator'; + +export class AssignCategoryDto { + @IsString() + @IsNotEmpty() + categoryId!: string; + + @IsOptional() + @IsInt() + @Min(0) + priority?: number; +} diff --git a/server/dukang-api/src/modules/store/store-category-link.util.ts b/server/dukang-api/src/modules/store/store-category-link.util.ts new file mode 100644 index 0000000..bb97fb9 --- /dev/null +++ b/server/dukang-api/src/modules/store/store-category-link.util.ts @@ -0,0 +1,130 @@ +import type { Prisma } from '@prisma/client'; + +export const storeCategoryLinkInclude = { + categoryLinks: { + include: { + category: { include: { parent: true } }, + }, + orderBy: [{ priority: 'asc' as const }, { id: 'asc' as const }], + }, +} satisfies Prisma.StoreInclude; + +export type StoreCategoryItem = { + id: string; + name: string; + parentId: string | null; + parent: { id: string; name: string } | null; + priority: number; +}; + +type CategoryLinkRow = { + priority: number; + category: { + id: bigint; + name: string; + parentId: bigint | null; + parent?: { id: bigint; name: string } | null; + }; +}; + +export function mapStoreCategoryLinks(links: CategoryLinkRow[] | undefined): StoreCategoryItem[] { + return (links ?? []).map((link) => ({ + id: String(link.category.id), + name: link.category.name, + parentId: link.category.parentId != null ? String(link.category.parentId) : null, + parent: link.category.parent + ? { id: String(link.category.parent.id), name: link.category.parent.name } + : null, + priority: link.priority, + })); +} + +export function attachStoreCategories< + T extends { + categoryId?: bigint | null; + category?: { + id: bigint; + name: string; + parentId?: bigint | null; + parent?: { id: bigint; name: string } | null; + } | null; + categoryLinks?: CategoryLinkRow[]; + }, +>(store: T) { + const { categoryLinks, ...rest } = store; + let categories = mapStoreCategoryLinks(categoryLinks); + if (!categories.length && store.category) { + categories = [ + { + id: String(store.category.id), + name: store.category.name, + parentId: store.category.parentId != null ? String(store.category.parentId) : null, + parent: store.category.parent + ? { id: String(store.category.parent.id), name: store.category.parent.name } + : null, + priority: 0, + }, + ]; + } + const primary = categories[0] ?? null; + return { + ...rest, + categories, + categoryId: primary?.id ?? (store.categoryId != null ? String(store.categoryId) : null), + category: primary + ? { + id: primary.id, + name: primary.name, + parentId: primary.parentId, + parent: primary.parent, + } + : store.category + ? { + id: String(store.category.id), + name: store.category.name, + parentId: store.category.parentId != null ? String(store.category.parentId) : null, + parent: store.category.parent + ? { id: String(store.category.parent.id), name: store.category.parent.name } + : null, + } + : null, + }; +} + +export async function syncStoreCategoryLinks( + tx: Prisma.TransactionClient, + storeId: bigint, + categoryIds: bigint[], +) { + const uniqueIds = [...new Set(categoryIds.map((id) => id.toString()))].map(BigInt); + await tx.storeCategoryLink.deleteMany({ where: { storeId } }); + if (uniqueIds.length === 0) { + await tx.store.update({ where: { id: storeId }, data: { categoryId: null } }); + return; + } + await tx.storeCategoryLink.createMany({ + data: uniqueIds.map((categoryId, index) => ({ + storeId, + categoryId, + priority: index, + })), + }); + await tx.store.update({ + where: { id: storeId }, + data: { categoryId: uniqueIds[0] }, + }); +} + +export function parseUniqueCategoryIds(raw: unknown): bigint[] { + if (!Array.isArray(raw)) return []; + const seen = new Set(); + const ids: bigint[] = []; + for (const item of raw) { + const id = BigInt(String(item)); + const key = id.toString(); + if (seen.has(key)) continue; + seen.add(key); + ids.push(id); + } + return ids; +} diff --git a/server/dukang-api/src/modules/store/store-category.service.ts b/server/dukang-api/src/modules/store/store-category.service.ts index a235fa1..62be897 100644 --- a/server/dukang-api/src/modules/store/store-category.service.ts +++ b/server/dukang-api/src/modules/store/store-category.service.ts @@ -253,7 +253,11 @@ export class StoreCategoryService { if (childCount > 0) { throw new BadRequestException('请先删除或停用下级分类'); } - const storeCount = await this.prisma.store.count({ where: { categoryId: id } }); + const storeCount = await this.prisma.store.count({ + where: { + OR: [{ categoryId: id }, { categoryLinks: { some: { categoryId: id } } }], + }, + }); if (storeCount > 0) { // 软停用,避免破坏已有门店关联 const row = await this.prisma.commonStoreCategory.update({ diff --git a/server/dukang-api/src/modules/store/store.controller.ts b/server/dukang-api/src/modules/store/store.controller.ts index 61731f1..9b3782a 100644 --- a/server/dukang-api/src/modules/store/store.controller.ts +++ b/server/dukang-api/src/modules/store/store.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, 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'; @@ -109,10 +109,52 @@ export class PartnerStoreController { } @Get(':id') - detail(@CurrentUser() user: AuthUser, @Param('id') id: string) { + async detail(@CurrentUser() user: AuthUser, @Param('id') id: string) { return this.storeService.partnerGetStore(user.actorId, BigInt(id)); } + @Get(':id/categories') + async getCategories(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.storeService.partnerGetStoreCategories(user.actorId, BigInt(id)); + } + + @Post(':id/categories') + async assignCategory( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { categoryId: string; priority?: number }, + ) { + return this.storeService.partnerAssignCategoryToStore( + user.actorId, + BigInt(id), + body.categoryId, + body.priority, + ); + } + + @Delete(':id/categories/:categoryId') + async removeCategory( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Param('categoryId') categoryId: string, + ) { + return this.storeService.partnerRemoveCategoryFromStore(user.actorId, BigInt(id), categoryId); + } + + @Put(':id/categories') + async replaceCategories( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { categoryIds: string[]; priorities?: Record }, + ) { + return this.storeService.partnerReplaceStoreCategories( + user.actorId, + BigInt(id), + body.categoryIds, + body.priorities, + ); + } + @Post() create(@CurrentUser() user: AuthUser, @Body() body: Record) { return this.storeService.createStore(user.actorId, body); diff --git a/server/dukang-api/src/modules/store/store.service.ts b/server/dukang-api/src/modules/store/store.service.ts index cb29460..20e1644 100644 --- a/server/dukang-api/src/modules/store/store.service.ts +++ b/server/dukang-api/src/modules/store/store.service.ts @@ -18,6 +18,12 @@ 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'; +import { + attachStoreCategories, + parseUniqueCategoryIds, + storeCategoryLinkInclude, + syncStoreCategoryLinks, +} from './store-category-link.util'; import { TencentLbsProvider } from '../../integrations/map/tencent-lbs.provider'; import { TestWhitelistService, @@ -185,6 +191,7 @@ export class StoreService { where: where as never, include: { category: { include: { parent: true } }, + ...storeCategoryLinkInclude, coverResource: true, }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }], @@ -226,11 +233,11 @@ export class StoreService { const coords = await this.ensureStoreCoordinates(store); const { visibilityWhitelistEnabled: _wl, ...rest } = store; const mapped = mapStoreCompat( - { + attachStoreCategories({ ...rest, latitude: coords?.latitude ?? store.latitude, longitude: coords?.longitude ?? store.longitude, - }, + }), { publicDial: true }, ); const distanceMeters = @@ -263,6 +270,7 @@ export class StoreService { where: { id, status: 'OPEN' }, include: { category: { include: { parent: true } }, + ...storeCategoryLinkInclude, coverResource: true, }, }); @@ -287,7 +295,7 @@ export class StoreService { const { visibilityWhitelistEnabled: _wl, ...rest } = store; return serializeBigInt( mapStoreCompat( - { + attachStoreCategories({ ...rest, latitude: coords?.latitude ?? store.latitude, longitude: coords?.longitude ?? store.longitude, @@ -310,7 +318,7 @@ export class StoreService { sortOrder: p.sortOrder, }; }), - }, + }), { publicDial: true }, ), ); @@ -332,17 +340,17 @@ export class StoreService { } const stores = await this.prisma.store.findMany({ where, - include: { category: true, coverResource: true }, + include: { category: true, ...storeCategoryLinkInclude, coverResource: true }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }], }); - return serializeBigInt(stores.map((s) => mapStoreCompat(s))); + return serializeBigInt(stores.map((s) => mapStoreCompat(attachStoreCategories(s)))); } async partnerGetStore(partnerAccountId: bigint, storeId: bigint) { const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId); const store = await this.prisma.store.findFirst({ where: { id: storeId, partnerAccountId: primaryId }, - include: { category: true, coverResource: true }, + include: { category: true, ...storeCategoryLinkInclude, coverResource: true }, }); if (!store) throw new NotFoundException('门店不存在'); if (this.isSubAccount(account)) { @@ -350,7 +358,7 @@ export class StoreService { } const media = await this.loadPartnerStoreMedia(storeId); - return serializeBigInt(mapStoreCompat({ ...store, media })); + return serializeBigInt(mapStoreCompat(attachStoreCategories({ ...store, media }))); } async partnerListCities(partnerAccountId: bigint) { @@ -444,11 +452,20 @@ export class StoreService { const bankAccountNo = body.bankAccountNo ? String(body.bankAccountNo) : null; const bankBranch = body.bankBranch ? String(body.bankBranch) : null; - if (!body.categoryId) { + if (!body.categoryId && !Array.isArray(body.categoryIds)) { throw new BadRequestException('请选择店铺类型'); } - const categoryId = parseBigIntParam(body.categoryId, '分类ID'); - await this.storeCategoryService.assertLeafCategoryId(categoryId); + const categoryIds = parseUniqueCategoryIds(body.categoryIds); + if (!categoryIds.length && body.categoryId) { + categoryIds.push(parseBigIntParam(body.categoryId, '分类ID')); + } + if (!categoryIds.length) { + throw new BadRequestException('请选择店铺类型'); + } + for (const id of categoryIds) { + await this.storeCategoryService.assertLeafCategoryId(id); + } + const categoryId = categoryIds[0]; const openTime = body.openTime ? String(body.openTime).trim() : '10:00'; const closeTime = body.closeTime ? String(body.closeTime).trim() : '22:00'; @@ -507,6 +524,8 @@ export class StoreService { }, }); + await syncStoreCategoryLinks(this.prisma, store.id, categoryIds); + if (latitude == null || longitude == null) { await this.ensureStoreCoordinates(store); } @@ -1515,4 +1534,138 @@ export class StoreService { }); if (!event) throw new ForbiddenException('无权查看该门店'); } + + async getStoreCategories(storeId: bigint) { + const store = await this.prisma.store.findUnique({ + where: { id: storeId }, + include: { + category: { include: { parent: true } }, + ...storeCategoryLinkInclude, + }, + }); + if (!store) throw new NotFoundException('门店不存在'); + return serializeBigInt(attachStoreCategories(store).categories); + } + + async partnerGetStoreCategories(partnerAccountId: bigint, storeId: bigint) { + await this.partnerGetStore(partnerAccountId, storeId); + return this.getStoreCategories(storeId); + } + + async assignCategoryToStore(storeId: bigint, categoryIdRaw: string, priority?: number) { + const categoryId = parseBigIntParam(categoryIdRaw, '分类ID'); + await this.storeCategoryService.assertLeafCategoryId(categoryId); + const store = await this.prisma.store.findUnique({ where: { id: storeId } }); + if (!store) throw new NotFoundException('门店不存在'); + + const existing = await this.prisma.storeCategoryLink.findUnique({ + where: { storeId_categoryId: { storeId, categoryId } }, + }); + if (existing) { + if (priority != null) { + await this.prisma.storeCategoryLink.update({ + where: { id: existing.id }, + data: { priority }, + }); + } + return this.getStoreCategories(storeId); + } + + const nextPriority = + priority ?? + ((await this.prisma.storeCategoryLink.count({ where: { storeId } })) || 0); + await this.prisma.$transaction(async (tx) => { + await tx.storeCategoryLink.create({ + data: { storeId, categoryId, priority: nextPriority }, + }); + if (!store.categoryId) { + await tx.store.update({ where: { id: storeId }, data: { categoryId } }); + } + }); + return this.getStoreCategories(storeId); + } + + async partnerAssignCategoryToStore( + partnerAccountId: bigint, + storeId: bigint, + categoryId: string, + priority?: number, + ) { + await this.partnerGetStore(partnerAccountId, storeId); + return this.assignCategoryToStore(storeId, categoryId, priority); + } + + async removeCategoryFromStore(storeId: bigint, categoryIdRaw: string) { + const categoryId = parseBigIntParam(categoryIdRaw, '分类ID'); + const store = await this.prisma.store.findUnique({ where: { id: storeId } }); + if (!store) throw new NotFoundException('门店不存在'); + + const linkCount = await this.prisma.storeCategoryLink.count({ where: { storeId } }); + const legacyOnly = linkCount === 0 && store.categoryId?.toString() === categoryId.toString(); + if (linkCount <= 1 && !legacyOnly) { + throw new BadRequestException('门店至少保留一个分类'); + } + + await this.prisma.$transaction(async (tx) => { + await tx.storeCategoryLink.deleteMany({ where: { storeId, categoryId } }); + const remaining = await tx.storeCategoryLink.findMany({ + where: { storeId }, + orderBy: [{ priority: 'asc' }, { id: 'asc' }], + }); + const nextPrimary = remaining[0]?.categoryId ?? null; + await tx.store.update({ + where: { id: storeId }, + data: { categoryId: nextPrimary }, + }); + }); + return this.getStoreCategories(storeId); + } + + async partnerRemoveCategoryFromStore( + partnerAccountId: bigint, + storeId: bigint, + categoryId: string, + ) { + await this.partnerGetStore(partnerAccountId, storeId); + return this.removeCategoryFromStore(storeId, categoryId); + } + + async replaceStoreCategories( + storeId: bigint, + categoryIdsRaw: string[], + priorities?: Record, + ) { + if (!categoryIdsRaw?.length) { + throw new BadRequestException('请至少选择一个门店分类'); + } + const parsed = parseUniqueCategoryIds(categoryIdsRaw); + if (!parsed.length) { + throw new BadRequestException('请至少选择一个门店分类'); + } + for (const id of parsed) { + await this.storeCategoryService.assertLeafCategoryId(id); + } + const sorted = [...parsed].sort((a, b) => { + const pa = priorities?.[a.toString()] ?? 0; + const pb = priorities?.[b.toString()] ?? 0; + if (pa !== pb) return pa - pb; + return Number(a - b); + }); + const store = await this.prisma.store.findUnique({ where: { id: storeId } }); + if (!store) throw new NotFoundException('门店不存在'); + await this.prisma.$transaction(async (tx) => { + await syncStoreCategoryLinks(tx, storeId, sorted); + }); + return this.getStoreCategories(storeId); + } + + async partnerReplaceStoreCategories( + partnerAccountId: bigint, + storeId: bigint, + categoryIds: string[], + priorities?: Record, + ) { + await this.partnerGetStore(partnerAccountId, storeId); + return this.replaceStoreCategories(storeId, categoryIds, priorities); + } }