小程序页面优化(门店)

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 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<Store[]>([]);
const [loading, setLoading] = useState(true);
const [categoryTab, setCategoryTab] = useState<string>('全部');
const [keywordInput, setKeywordInput] = useState('');
const [keyword, setKeyword] = useState('');
const [region, setRegion] = useState<RegionSelection>(DEFAULT_REGION);
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 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(() => {
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(() => {
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 (
<PageShell variant="tab" className="store-page no-tab-header">
<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">
{CATEGORY_TABS.map((tab) => (
<Text
key={tab}
className={`store-category-tab${categoryTab === tab ? ' store-category-tab--active' : ''}`}
onClick={() => setCategoryTab(tab)}
>
{tab}
<View className="store-filter">
<View className="store-search-row">
<Input
className="store-search-input"
placeholder="搜索门店名称/地址"
value={keywordInput}
confirmType="search"
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>
))}
</View>
</View>
<View className="store-list">
@@ -171,6 +229,13 @@ export default function StoresPage() {
onClose={() => setRegionOpen(false)}
onConfirm={(next) => setRegion(next)}
/>
<CategoryPicker
open={categoryOpen}
tree={categoryTree}
value={category}
onClose={() => setCategoryOpen(false)}
onConfirm={(next) => setCategory(next)}
/>
</PageShell>
);
}
+85 -47
View File
@@ -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 {