小程序页面优化(门店)

This commit is contained in:
2026-07-22 17:56:25 +08:00
parent b392c28787
commit addfcfb0b1
5 changed files with 345 additions and 71 deletions
@@ -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<CategorySelection>(value);
const [activeTab, setActiveTab] = useState<TabKey>('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 (
<View className="region-picker-overlay" onClick={onClose}>
<View className="region-picker-sheet" onClick={(e) => e.stopPropagation()}>
<View className="region-picker-toolbar">
<View className="region-picker-tabs">
<Text
className={`region-picker-tab${activeTab === 'parent' ? ' active' : ''}`}
onClick={() => setActiveTab('parent')}
>
{draft.parentName || '大类'}
</Text>
<Text
className={`region-picker-tab${activeTab === 'child' ? ' active' : ''}${!draft.parentId ? ' disabled' : ''}`}
onClick={() => draft.parentId && setActiveTab('child')}
>
{draft.childName || '细类'}
</Text>
</View>
<Text className="region-picker-confirm ready" onClick={handleConfirm}>
</Text>
</View>
<ScrollView className="region-picker-list" scrollY showScrollbar={false}>
{activeTab === 'parent' ? (
<>
<View
className={`region-picker-option${!draft.parentId ? ' selected' : ''} region-picker-option--all`}
onClick={() => selectParent(null)}
>
<Text></Text>
</View>
{tree.map((item) => (
<View
key={item.id}
className={`region-picker-option${draft.parentId === item.id ? ' selected' : ''}`}
onClick={() => selectParent(item)}
>
<Text>{item.name}</Text>
</View>
))}
</>
) : (
<>
<View
className={`region-picker-option${!draft.childId ? ' selected' : ''} region-picker-option--all`}
onClick={() => selectChild(null)}
>
<Text></Text>
</View>
{children.map((item) => (
<View
key={item.id}
className={`region-picker-option${draft.childId === item.id ? ' selected' : ''}`}
onClick={() => selectChild(item)}
>
<Text>{item.name}</Text>
</View>
))}
</>
)}
</ScrollView>
</View>
</View>
);
}
+89 -24
View File
@@ -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 { View, Text, Image, Input } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro'; import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import TabMainHeader from '../../components/TabMainHeader'; import TabMainHeader from '../../components/TabMainHeader';
import RegionPicker from '../../components/RegionPicker'; import RegionPicker from '../../components/RegionPicker';
import CategoryPicker, {
EMPTY_CATEGORY,
formatCategoryLabel,
type CategorySelection,
type StoreCategoryNode,
} from '../../components/CategoryPicker';
import { import {
DEFAULT_REGION, DEFAULT_REGION,
formatRegionLabel, formatRegionLabel,
@@ -26,20 +32,36 @@ type Store = {
openTime?: string | null; openTime?: string | null;
closeTime?: string | null; closeTime?: string | null;
status?: string; 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']; const MOCK_DISTANCES = ['800m', '1.2km', '3.5km', '1.5km', '2.0km'];
export default function StoresPage() { export default function StoresPage() {
const [stores, setStores] = useState<Store[]>([]); const [stores, setStores] = useState<Store[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [categoryTab, setCategoryTab] = useState<string>('全部'); const [keywordInput, setKeywordInput] = useState('');
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [region, setRegion] = useState<RegionSelection>(DEFAULT_REGION); const [region, setRegion] = useState<RegionSelection>(DEFAULT_REGION);
const [regionOpen, setRegionOpen] = useState(false); const [regionOpen, setRegionOpen] = useState(false);
const [category, setCategory] = useState<CategorySelection>(EMPTY_CATEGORY);
const [categoryOpen, setCategoryOpen] = useState(false);
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
const [cityCode, setCityCode] = useState<string>(FALLBACK_CITY_CODE); const [cityCode, setCityCode] = useState<string>(FALLBACK_CITY_CODE);
const regionLabel = formatRegionLabel(region); const regionLabel = formatRegionLabel(region);
const categoryLabel = formatCategoryLabel(category);
const childIdsByParent = useMemo(() => {
const map = new Map<string, string[]>();
for (const root of categoryTree) {
map.set(
root.id,
(root.children ?? []).map((c) => c.id),
);
}
return map;
}, [categoryTree]);
useDidShow(() => { useDidShow(() => {
syncTabBarSelected(1); syncTabBarSelected(1);
@@ -49,6 +71,12 @@ export default function StoresPage() {
}); });
}); });
useEffect(() => {
void request<StoreCategoryNode[]>('/store-categories')
.then((tree) => setCategoryTree(Array.isArray(tree) ? tree : []))
.catch(() => setCategoryTree([]));
}, []);
const loadStores = useCallback(() => { const loadStores = useCallback(() => {
setLoading(true); setLoading(true);
const path = cityCode ? `/stores?cityCode=${encodeURIComponent(cityCode)}` : '/stores'; 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) => { const filtered = stores.filter((s) => {
if (!matchesRegionFilter(s, region)) return false; if (!matchesRegionFilter(s, region)) return false;
if (!matchesCategory(s)) return false;
if (!keyword.trim()) return true; if (!keyword.trim()) return true;
const q = keyword.trim(); const q = keyword.trim();
return s.name.includes(q) || (s.address ?? '').includes(q) || (s.district ?? '').includes(q); 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) { function formatHours(store: Store) {
if (store.openTime && store.closeTime) { if (store.openTime && store.closeTime) {
return `营业时间: ${store.openTime}-${store.closeTime}`; return `营业时间: ${store.openTime}-${store.closeTime}`;
@@ -99,29 +151,35 @@ export default function StoresPage() {
return ( return (
<PageShell variant="tab" className="store-page no-tab-header"> <PageShell variant="tab" className="store-page no-tab-header">
<TabMainHeader title="门店" /> <TabMainHeader title="门店" />
<View className="store-toolbar">
<View className="store-location" onClick={() => setRegionOpen(true)}>
<View className="store-location-pin" />
<Text className="store-location-text">{regionLabel} </Text>
</View>
<Input
className="store-search"
placeholder="搜索门店"
value={keyword}
onInput={(e) => setKeyword(e.detail.value)}
/>
</View>
<View className="store-category-tabs"> <View className="store-filter">
{CATEGORY_TABS.map((tab) => ( <View className="store-search-row">
<Text <Input
key={tab} className="store-search-input"
className={`store-category-tab${categoryTab === tab ? ' store-category-tab--active' : ''}`} placeholder="搜索门店名称/地址"
onClick={() => setCategoryTab(tab)} value={keywordInput}
> confirmType="search"
{tab} onInput={(e) => setKeywordInput(e.detail.value)}
onConfirm={applySearch}
/>
<View className="store-search-btn" onClick={applySearch} aria-label="搜索">
<View className="store-search-icon" />
</View>
</View>
<View className="store-filter-row">
<View className="store-filter-chip" onClick={() => setRegionOpen(true)}>
<Text className="store-filter-chip-text">{regionLabel}</Text>
<Text className="store-filter-chip-arrow"></Text>
</View>
<View className="store-filter-chip" onClick={() => setCategoryOpen(true)}>
<Text className="store-filter-chip-text">{categoryLabel}</Text>
<Text className="store-filter-chip-arrow"></Text>
</View>
<Text className="store-filter-reset" onClick={resetFilters}>
</Text> </Text>
))} </View>
</View> </View>
<View className="store-list"> <View className="store-list">
@@ -171,6 +229,13 @@ export default function StoresPage() {
onClose={() => setRegionOpen(false)} onClose={() => setRegionOpen(false)}
onConfirm={(next) => setRegion(next)} onConfirm={(next) => setRegion(next)}
/> />
<CategoryPicker
open={categoryOpen}
tree={categoryTree}
value={category}
onClose={() => setCategoryOpen(false)}
onConfirm={(next) => setCategory(next)}
/>
</PageShell> </PageShell>
); );
} }
+85 -47
View File
@@ -3,56 +3,34 @@
background: var(--color-background); background: var(--color-background);
} }
.store-toolbar { .store-filter {
display: flex;
align-items: center;
gap: 8px;
padding: 0 var(--space-page) 12px; padding: 0 var(--space-page) 12px;
} }
.store-location { .store-search-row {
display: flex; display: flex;
align-items: center; align-items: center;
flex-shrink: 0; gap: 8px;
max-width: 42%; width: 100%;
color: var(--color-on-surface-variant); box-sizing: border-box;
font-size: 12px;
font-weight: 500;
} }
.store-location-pin { .store-search-input {
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 {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
height: 40px; height: 40px;
padding: 0 12px; padding: 0 14px;
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: var(--color-surface-container-low); background: var(--color-surface-container-low);
font-size: 13px; font-size: 13px;
line-height: 40px; line-height: 40px;
color: var(--color-on-surface); color: var(--color-on-surface);
box-sizing: border-box; box-sizing: border-box;
display: flex;
align-items: center;
} }
.store-search input, .store-search-input input,
.store-search .taro-input, .store-search-input .taro-input,
.store-search .weui-input { .store-search-input .weui-input {
width: 100% !important; width: 100% !important;
height: 100% !important; height: 100% !important;
min-height: 0 !important; min-height: 0 !important;
@@ -66,32 +44,92 @@
color: inherit; 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; display: flex;
padding: 12px var(--space-page); align-items: center;
overflow-x: auto; justify-content: center;
white-space: nowrap;
border-bottom: 1px solid rgba(226, 190, 188, 0.1);
} }
.store-category-tab { .store-search-icon {
flex-shrink: 0; position: relative;
margin-right: 24px; 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-size: 12px;
font-weight: 500; font-weight: 500;
color: var(--color-on-surface-variant); color: var(--color-on-surface);
padding-bottom: 6px;
border-bottom: 2px solid transparent;
} }
.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); color: var(--color-heritage-red);
border-bottom-color: var(--color-heritage-red);
font-weight: 700;
} }
.store-list { .store-list {
padding: 16px var(--space-page); padding: 4px var(--space-page) 16px;
} }
.store-card { .store-card {
@@ -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') @Controller('partner/store-categories')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
export class PartnerStoreCategoriesController { export class PartnerStoreCategoriesController {
@@ -10,6 +10,7 @@ import {
PartnerReportController, PartnerReportController,
PartnerStoreCategoriesController, PartnerStoreCategoriesController,
PartnerStoreController, PartnerStoreController,
PublicStoreCategoriesController,
PublicStoreController, PublicStoreController,
ShopDashboardController, ShopDashboardController,
ShopStoreController, ShopStoreController,
@@ -19,6 +20,7 @@ import {
imports: [IamModule, AnalyticsModule, CityScopeModule, forwardRef(() => RedeemModule)], imports: [IamModule, AnalyticsModule, CityScopeModule, forwardRef(() => RedeemModule)],
controllers: [ controllers: [
PublicStoreController, PublicStoreController,
PublicStoreCategoriesController,
PartnerStoreCategoriesController, PartnerStoreCategoriesController,
PartnerStoreController, PartnerStoreController,
PartnerDashboardController, PartnerDashboardController,