From addfcfb0b1f8f30d6f5a26d52706dc8bd99b8d54 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Wed, 22 Jul 2026 17:56:25 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B0=8F=E7=A8=8B=E5=BA=8F=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E4=BC=98=E5=8C=96=EF=BC=88=E9=97=A8=E5=BA=97=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/CategoryPicker.tsx | 159 ++++++++++++++++++ apps/mini-user/src/pages/stores/index.tsx | 113 ++++++++++--- apps/mini-user/src/styles/stores.css | 132 +++++++++------ .../src/modules/store/store.controller.ts | 10 ++ .../src/modules/store/store.module.ts | 2 + 5 files changed, 345 insertions(+), 71 deletions(-) create mode 100644 apps/mini-user/src/components/CategoryPicker.tsx diff --git a/apps/mini-user/src/components/CategoryPicker.tsx b/apps/mini-user/src/components/CategoryPicker.tsx new file mode 100644 index 0000000..87a0b67 --- /dev/null +++ b/apps/mini-user/src/components/CategoryPicker.tsx @@ -0,0 +1,159 @@ +import { useEffect, useMemo, useState } from 'react'; +import { View, Text, ScrollView } from '@tarojs/components'; + +export type StoreCategoryNode = { + id: string; + name: string; + children?: StoreCategoryNode[]; +}; + +export type CategorySelection = { + parentId: string; + parentName: string; + childId: string; + childName: string; +}; + +export const EMPTY_CATEGORY: CategorySelection = { + parentId: '', + parentName: '', + childId: '', + childName: '', +}; + +export function formatCategoryLabel(sel: CategorySelection): string { + if (sel.childName) return sel.childName; + if (sel.parentName) return sel.parentName; + return '全部分类'; +} + +type CategoryPickerProps = { + open: boolean; + tree: StoreCategoryNode[]; + value: CategorySelection; + onClose: () => void; + onConfirm: (next: CategorySelection) => void; +}; + +type TabKey = 'parent' | 'child'; + +export default function CategoryPicker({ + open, + tree, + value, + onClose, + onConfirm, +}: CategoryPickerProps) { + const [draft, setDraft] = useState(value); + const [activeTab, setActiveTab] = useState('parent'); + + useEffect(() => { + if (!open) return; + setDraft(value); + setActiveTab(value.parentId ? 'child' : 'parent'); + }, [open, value]); + + const children = useMemo(() => { + const parent = tree.find((n) => n.id === draft.parentId); + return parent?.children ?? []; + }, [tree, draft.parentId]); + + if (!open) return null; + + function selectParent(node: StoreCategoryNode | null) { + if (!node) { + setDraft(EMPTY_CATEGORY); + return; + } + setDraft({ + parentId: node.id, + parentName: node.name, + childId: '', + childName: '', + }); + setActiveTab('child'); + } + + function selectChild(node: StoreCategoryNode | null) { + if (!node) { + setDraft((prev) => ({ ...prev, childId: '', childName: '' })); + return; + } + setDraft((prev) => ({ + ...prev, + childId: node.id, + childName: node.name, + })); + } + + function handleConfirm() { + onConfirm(draft); + onClose(); + } + + return ( + + e.stopPropagation()}> + + + setActiveTab('parent')} + > + {draft.parentName || '大类'} + + draft.parentId && setActiveTab('child')} + > + {draft.childName || '细类'} + + + + 确定 + + + + + {activeTab === 'parent' ? ( + <> + selectParent(null)} + > + 全部分类 + + {tree.map((item) => ( + selectParent(item)} + > + {item.name} + + ))} + + ) : ( + <> + selectChild(null)} + > + 全部细类 + + {children.map((item) => ( + selectChild(item)} + > + {item.name} + + ))} + + )} + + + + ); +} diff --git a/apps/mini-user/src/pages/stores/index.tsx b/apps/mini-user/src/pages/stores/index.tsx index e545c22..e43b0ae 100644 --- a/apps/mini-user/src/pages/stores/index.tsx +++ b/apps/mini-user/src/pages/stores/index.tsx @@ -1,9 +1,15 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { View, Text, Image, Input } from '@tarojs/components'; import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro'; import PageShell from '../../components/PageShell'; import TabMainHeader from '../../components/TabMainHeader'; import RegionPicker from '../../components/RegionPicker'; +import CategoryPicker, { + EMPTY_CATEGORY, + formatCategoryLabel, + type CategorySelection, + type StoreCategoryNode, +} from '../../components/CategoryPicker'; import { DEFAULT_REGION, formatRegionLabel, @@ -26,20 +32,36 @@ type Store = { openTime?: string | null; closeTime?: string | null; status?: string; + categoryId?: string | null; + category?: { id?: string; name?: string; parentId?: string | null } | null; }; -const CATEGORY_TABS = ['全部', '火锅', '地方菜', '高端餐饮', '烧烤烤肉', '西餐'] as const; const MOCK_DISTANCES = ['800m', '1.2km', '3.5km', '1.5km', '2.0km']; export default function StoresPage() { const [stores, setStores] = useState([]); const [loading, setLoading] = useState(true); - const [categoryTab, setCategoryTab] = useState('全部'); + const [keywordInput, setKeywordInput] = useState(''); const [keyword, setKeyword] = useState(''); const [region, setRegion] = useState(DEFAULT_REGION); const [regionOpen, setRegionOpen] = useState(false); + const [category, setCategory] = useState(EMPTY_CATEGORY); + const [categoryOpen, setCategoryOpen] = useState(false); + const [categoryTree, setCategoryTree] = useState([]); const [cityCode, setCityCode] = useState(FALLBACK_CITY_CODE); const regionLabel = formatRegionLabel(region); + const categoryLabel = formatCategoryLabel(category); + + const childIdsByParent = useMemo(() => { + const map = new Map(); + for (const root of categoryTree) { + map.set( + root.id, + (root.children ?? []).map((c) => c.id), + ); + } + return map; + }, [categoryTree]); useDidShow(() => { syncTabBarSelected(1); @@ -49,6 +71,12 @@ export default function StoresPage() { }); }); + useEffect(() => { + void request('/store-categories') + .then((tree) => setCategoryTree(Array.isArray(tree) ? tree : [])) + .catch(() => setCategoryTree([])); + }, []); + const loadStores = useCallback(() => { setLoading(true); const path = cityCode ? `/stores?cityCode=${encodeURIComponent(cityCode)}` : '/stores'; @@ -82,13 +110,37 @@ 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; + } + if (storeParentId && storeParentId === category.parentId) return true; + const siblings = childIdsByParent.get(category.parentId) ?? []; + return siblings.includes(storeCatId); + } + const filtered = stores.filter((s) => { if (!matchesRegionFilter(s, region)) return false; + if (!matchesCategory(s)) return false; if (!keyword.trim()) return true; const q = keyword.trim(); return s.name.includes(q) || (s.address ?? '').includes(q) || (s.district ?? '').includes(q); }); + function applySearch() { + setKeyword(keywordInput.trim()); + } + + function resetFilters() { + setKeywordInput(''); + setKeyword(''); + setCategory(EMPTY_CATEGORY); + setRegion(DEFAULT_REGION); + } + function formatHours(store: Store) { if (store.openTime && store.closeTime) { return `营业时间: ${store.openTime}-${store.closeTime}`; @@ -99,29 +151,35 @@ export default function StoresPage() { return ( - - setRegionOpen(true)}> - - {regionLabel} ▾ - - setKeyword(e.detail.value)} - /> - - - {CATEGORY_TABS.map((tab) => ( - setCategoryTab(tab)} - > - {tab} + + + setKeywordInput(e.detail.value)} + onConfirm={applySearch} + /> + + + + + + + setRegionOpen(true)}> + {regionLabel} + + + setCategoryOpen(true)}> + {categoryLabel} + + + + 重置 - ))} + @@ -171,6 +229,13 @@ export default function StoresPage() { onClose={() => setRegionOpen(false)} onConfirm={(next) => setRegion(next)} /> + setCategoryOpen(false)} + onConfirm={(next) => setCategory(next)} + /> ); } diff --git a/apps/mini-user/src/styles/stores.css b/apps/mini-user/src/styles/stores.css index fdffa99..eae8193 100644 --- a/apps/mini-user/src/styles/stores.css +++ b/apps/mini-user/src/styles/stores.css @@ -3,56 +3,34 @@ background: var(--color-background); } -.store-toolbar { - display: flex; - align-items: center; - gap: 8px; +.store-filter { padding: 0 var(--space-page) 12px; } -.store-location { +.store-search-row { display: flex; align-items: center; - flex-shrink: 0; - max-width: 42%; - color: var(--color-on-surface-variant); - font-size: 12px; - font-weight: 500; + gap: 8px; + width: 100%; + box-sizing: border-box; } -.store-location-pin { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--color-heritage-red); - margin-right: 6px; - flex-shrink: 0; -} - -.store-location-text { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.store-search { +.store-search-input { flex: 1; min-width: 0; height: 40px; - padding: 0 12px; + padding: 0 14px; border-radius: var(--radius-md); background: var(--color-surface-container-low); font-size: 13px; line-height: 40px; color: var(--color-on-surface); box-sizing: border-box; - display: flex; - align-items: center; } -.store-search input, -.store-search .taro-input, -.store-search .weui-input { +.store-search-input input, +.store-search-input .taro-input, +.store-search-input .weui-input { width: 100% !important; height: 100% !important; min-height: 0 !important; @@ -66,32 +44,92 @@ color: inherit; } -.store-category-tabs { +.store-search-btn { + flex-shrink: 0; + width: 40px; + height: 40px; + border-radius: var(--radius-md); + background: var(--color-heritage-red); + color: #fff; display: flex; - padding: 12px var(--space-page); - overflow-x: auto; - white-space: nowrap; - border-bottom: 1px solid rgba(226, 190, 188, 0.1); + align-items: center; + justify-content: center; } -.store-category-tab { - flex-shrink: 0; - margin-right: 24px; +.store-search-icon { + position: relative; + width: 14px; + height: 14px; + border: 2px solid currentColor; + border-radius: 50%; + box-sizing: border-box; +} + +.store-search-icon::after { + content: ''; + position: absolute; + right: -5px; + bottom: -4px; + width: 7px; + height: 2px; + background: currentColor; + border-radius: 1px; + transform: rotate(45deg); + transform-origin: left center; +} + +.store-filter-row { + display: flex; + align-items: center; + gap: 8px; + margin-top: 10px; +} + +.store-filter-chip { + flex: 1; + min-width: 0; + height: 36px; + padding: 0 10px; + border-radius: var(--radius-md); + background: var(--color-surface-container-low); + display: flex; + align-items: center; + justify-content: space-between; + gap: 4px; + box-sizing: border-box; +} + +.store-filter-chip-text { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; font-size: 12px; font-weight: 500; - color: var(--color-on-surface-variant); - padding-bottom: 6px; - border-bottom: 2px solid transparent; + color: var(--color-on-surface); } -.store-category-tab--active { +.store-filter-chip-arrow { + flex-shrink: 0; + font-size: 10px; + color: var(--color-subtle-gray); +} + +.store-filter-reset { + flex-shrink: 0; + height: 36px; + padding: 0 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: 600; color: var(--color-heritage-red); - border-bottom-color: var(--color-heritage-red); - font-weight: 700; } .store-list { - padding: 16px var(--space-page); + padding: 4px var(--space-page) 16px; } .store-card { diff --git a/server/dukang-api/src/modules/store/store.controller.ts b/server/dukang-api/src/modules/store/store.controller.ts index b11064a..4d97c67 100644 --- a/server/dukang-api/src/modules/store/store.controller.ts +++ b/server/dukang-api/src/modules/store/store.controller.ts @@ -24,6 +24,16 @@ export class PublicStoreController { } } +@Controller('store-categories') +export class PublicStoreCategoriesController { + constructor(private readonly categories: StoreCategoryService) {} + + @Get() + list() { + return this.categories.listTree({ includeDisabled: false, ensure: true }); + } +} + @Controller('partner/store-categories') @UseGuards(JwtAuthGuard) export class PartnerStoreCategoriesController { diff --git a/server/dukang-api/src/modules/store/store.module.ts b/server/dukang-api/src/modules/store/store.module.ts index 11a9739..c34f31c 100644 --- a/server/dukang-api/src/modules/store/store.module.ts +++ b/server/dukang-api/src/modules/store/store.module.ts @@ -10,6 +10,7 @@ import { PartnerReportController, PartnerStoreCategoriesController, PartnerStoreController, + PublicStoreCategoriesController, PublicStoreController, ShopDashboardController, ShopStoreController, @@ -19,6 +20,7 @@ import { imports: [IamModule, AnalyticsModule, CityScopeModule, forwardRef(() => RedeemModule)], controllers: [ PublicStoreController, + PublicStoreCategoriesController, PartnerStoreCategoriesController, PartnerStoreController, PartnerDashboardController,