feat(assoc): v4.0.1 合伙人关联码、分佣账单与 H5 用户管理
订单佣金只认关联用户;合伙人备注写入独立表;H5 增加用户管理与首页统计。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -34,12 +34,15 @@ import {
|
||||
markStoresSessionBootstrapped,
|
||||
patchStoresFilterCache,
|
||||
setStoresListCache,
|
||||
type StoreSortKey,
|
||||
} from '../../lib/stores-session';
|
||||
import {
|
||||
buildSceneSharePayload,
|
||||
toWeappShareMessage,
|
||||
toWeappShareTimeline,
|
||||
} from '../../lib/wechat-share';
|
||||
import BenefitSloganBar from '../../components/BenefitSloganBar';
|
||||
import { storeCategoryTags, storeStarCount } from '../../lib/store-display';
|
||||
import openBadgeImg from '../../assets/icons/store-open-badge.png';
|
||||
|
||||
type Store = {
|
||||
@@ -57,12 +60,26 @@ type Store = {
|
||||
avgPrice?: number | null;
|
||||
status?: string;
|
||||
categoryId?: string | null;
|
||||
category?: { id?: string; name?: string; parentId?: string | null } | null;
|
||||
category?: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
parentId?: string | null;
|
||||
parent?: { name?: string } | null;
|
||||
} | null;
|
||||
tags?: unknown;
|
||||
rating?: number | string | null;
|
||||
latitude?: number | string | null;
|
||||
longitude?: number | string | null;
|
||||
distanceMeters?: number | null;
|
||||
redeemCount?: number | null;
|
||||
};
|
||||
|
||||
const STORE_SORT_OPTIONS: { key: StoreSortKey; label: string }[] = [
|
||||
{ key: 'nearby', label: '附近优先' },
|
||||
{ key: 'rating', label: '好评优先' },
|
||||
{ key: 'redeem', label: '核销次数' },
|
||||
];
|
||||
|
||||
function makeCityKey(region: Pick<RegionSelection, 'province' | 'city'>): string {
|
||||
return `${region.province}|${region.city}`;
|
||||
}
|
||||
@@ -98,13 +115,15 @@ export default function StoresPage() {
|
||||
);
|
||||
const [categoryOpen, setCategoryOpen] = useState(false);
|
||||
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
|
||||
const [locating, setLocating] = useState(false);
|
||||
const [sort, setSort] = useState<StoreSortKey>(() => cached?.sort ?? 'nearby');
|
||||
const [sortOpen, setSortOpen] = useState(false);
|
||||
const fetchCityKeyRef = useRef<string | null>(cached?.cityKey ?? null);
|
||||
const fetchSeqRef = useRef(0);
|
||||
const regionRef = useRef(region);
|
||||
regionRef.current = region;
|
||||
const regionLabel = formatRegionLabel(region);
|
||||
const categoryLabel = formatCategoryLabel(category);
|
||||
const sortLabel = STORE_SORT_OPTIONS.find((o) => o.key === sort)?.label ?? '附近优先';
|
||||
const showBootLoading = loading && stores.length === 0;
|
||||
|
||||
const childIdsByParent = useMemo(() => {
|
||||
@@ -151,6 +170,7 @@ export default function StoresPage() {
|
||||
keyword: prev?.keyword ?? keyword,
|
||||
keywordInput: prev?.keywordInput ?? keywordInput,
|
||||
category: prev?.category ?? category,
|
||||
sort: prev?.sort ?? sort,
|
||||
});
|
||||
} catch (e) {
|
||||
if (seq !== fetchSeqRef.current) return;
|
||||
@@ -278,13 +298,29 @@ export default function StoresPage() {
|
||||
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);
|
||||
});
|
||||
const filtered = useMemo(() => {
|
||||
const list = 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);
|
||||
});
|
||||
const next = [...list];
|
||||
next.sort((a, b) => {
|
||||
if (sort === 'rating') {
|
||||
const diff = storeStarCount(b.rating) - storeStarCount(a.rating);
|
||||
if (diff !== 0) return diff;
|
||||
} else if (sort === 'redeem') {
|
||||
const diff = (b.redeemCount ?? 0) - (a.redeemCount ?? 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
const da = a.distanceMeters ?? Number.POSITIVE_INFINITY;
|
||||
const db = b.distanceMeters ?? Number.POSITIVE_INFINITY;
|
||||
return da - db;
|
||||
});
|
||||
return next;
|
||||
}, [stores, region, category, keyword, sort, childIdsByParent]);
|
||||
|
||||
function applySearch() {
|
||||
const next = keywordInput.trim();
|
||||
@@ -296,52 +332,18 @@ export default function StoresPage() {
|
||||
setKeywordInput('');
|
||||
setKeyword('');
|
||||
setCategory(EMPTY_CATEGORY);
|
||||
setSort('nearby');
|
||||
setRegion(DEFAULT_REGION);
|
||||
regionRef.current = DEFAULT_REGION;
|
||||
patchStoresFilterCache({
|
||||
keyword: '',
|
||||
keywordInput: '',
|
||||
category: EMPTY_CATEGORY,
|
||||
sort: 'nearby',
|
||||
filterRegion: DEFAULT_REGION,
|
||||
});
|
||||
}
|
||||
|
||||
async function locateToUserRegion() {
|
||||
if (locating) return;
|
||||
const { confirm } = await Taro.showModal({
|
||||
title: '获取当前位置',
|
||||
content: '是否允许获取当前位置,并将筛选定位到您所在的城市与区县?',
|
||||
confirmText: '允许',
|
||||
cancelText: '暂不',
|
||||
}).catch(() => ({ confirm: false, cancel: true }));
|
||||
if (!confirm) return;
|
||||
|
||||
setLocating(true);
|
||||
setLoading(true);
|
||||
try {
|
||||
const resolved = await resolveUserCity(true);
|
||||
// 筛选器用真实省市+区县;拉数仍按开城 cityCode(未开城则郑州)
|
||||
const filterRegion = resolved.region;
|
||||
const { cityCode, region: listRegion } = regionForCatalogFetch(resolved);
|
||||
const nextCityKey = makeCityKey(listRegion);
|
||||
setRegion(filterRegion);
|
||||
regionRef.current = filterRegion;
|
||||
await fetchStores(
|
||||
cityCode,
|
||||
readCachedUserCoords(),
|
||||
nextCityKey,
|
||||
listRegion,
|
||||
filterRegion,
|
||||
);
|
||||
toast(`已定位到${formatRegionLabel(filterRegion)}`, 'success');
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '定位失败');
|
||||
setLoading(false);
|
||||
} finally {
|
||||
setLocating(false);
|
||||
}
|
||||
}
|
||||
|
||||
function hoursText(store: Store): string {
|
||||
const parts: string[] = [];
|
||||
if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`);
|
||||
@@ -366,30 +368,59 @@ export default function StoresPage() {
|
||||
<WechatShareReady payload={sharePayload} />
|
||||
<TabMainHeader title="门店" />
|
||||
|
||||
<View className="store-slogan-wrap">
|
||||
<BenefitSloganBar />
|
||||
</View>
|
||||
|
||||
<View className="store-filter">
|
||||
<View className="store-search-row">
|
||||
<Input
|
||||
className="store-search-input"
|
||||
placeholder="搜索门店名称/地址"
|
||||
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 className="store-search-btn" onClick={applySearch}>
|
||||
<Text className="store-search-btn-text">搜索</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="store-filter-row">
|
||||
<View className="store-filter-chip" onClick={() => setRegionOpen(true)}>
|
||||
<View
|
||||
className="store-filter-chip"
|
||||
onClick={() => {
|
||||
setCategoryOpen(false);
|
||||
setSortOpen(false);
|
||||
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)}>
|
||||
<View
|
||||
className="store-filter-chip"
|
||||
onClick={() => {
|
||||
setRegionOpen(false);
|
||||
setSortOpen(false);
|
||||
setCategoryOpen(true);
|
||||
}}
|
||||
>
|
||||
<Text className="store-filter-chip-text">{categoryLabel}</Text>
|
||||
<Text className="store-filter-chip-arrow">▾</Text>
|
||||
</View>
|
||||
<View
|
||||
className="store-filter-chip"
|
||||
onClick={() => {
|
||||
setRegionOpen(false);
|
||||
setCategoryOpen(false);
|
||||
setSortOpen(true);
|
||||
}}
|
||||
>
|
||||
<Text className="store-filter-chip-text">{sortLabel}</Text>
|
||||
<Text className="store-filter-chip-arrow">▾</Text>
|
||||
</View>
|
||||
<View
|
||||
className="store-filter-icon-btn"
|
||||
onClick={resetFilters}
|
||||
@@ -398,15 +429,6 @@ export default function StoresPage() {
|
||||
{/* 小程序 View 伪元素不稳定,用 Text 保证真机可见 */}
|
||||
<Text className="store-filter-icon-glyph">↺</Text>
|
||||
</View>
|
||||
<View
|
||||
className={`store-filter-icon-btn${locating ? ' store-filter-icon-btn--busy' : ''}`}
|
||||
onClick={() => {
|
||||
void locateToUserRegion();
|
||||
}}
|
||||
aria-label="获取当前位置"
|
||||
>
|
||||
<Text className="store-filter-icon-glyph">⌖</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -435,11 +457,34 @@ export default function StoresPage() {
|
||||
/>
|
||||
</View>
|
||||
<View className="store-card-body">
|
||||
{/* 第1行:标题(截断无省略号,顶到最右) */}
|
||||
<View className="store-card-row store-card-row--head">
|
||||
<Text className="store-card-name">{s.name}</Text>
|
||||
</View>
|
||||
{/* 第2行:地址(最多两行)+ 距离 */}
|
||||
{(() => {
|
||||
const tags = storeCategoryTags(s, categoryTree);
|
||||
return tags.length ? (
|
||||
<View className="store-card-tags">
|
||||
{tags.map((tag) => (
|
||||
<Text key={tag} className="store-card-tag">
|
||||
{tag}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
) : null;
|
||||
})()}
|
||||
<View className="store-card-row store-card-row--rating">
|
||||
<View className="store-card-stars">
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<Text
|
||||
key={n}
|
||||
className={`store-card-star${n <= storeStarCount(s.rating) ? ' store-card-star--on' : ''}`}
|
||||
>
|
||||
★
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
<Text className="store-card-redeem">核销{s.redeemCount ?? 0}次</Text>
|
||||
</View>
|
||||
<View className="store-card-row store-card-row--mid">
|
||||
<Text className="store-card-address" numberOfLines={2}>
|
||||
{s.address || (s.district ? `${s.district}` : '地址待完善')}
|
||||
@@ -448,12 +493,10 @@ export default function StoresPage() {
|
||||
{formatDistanceMeters(s.distanceMeters)}
|
||||
</Text>
|
||||
</View>
|
||||
{/* 第3行:营业时间(同行) */}
|
||||
<View className="store-card-row store-card-row--hours">
|
||||
<Text className="store-card-hours">{hoursText(s)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text className="store-card-arrow">›</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
@@ -480,6 +523,35 @@ export default function StoresPage() {
|
||||
patchStoresFilterCache({ category: next });
|
||||
}}
|
||||
/>
|
||||
{sortOpen ? (
|
||||
<View className="region-picker-overlay" onClick={() => setSortOpen(false)}>
|
||||
<View className="region-picker-sheet store-sort-sheet" onClick={(e) => e.stopPropagation()}>
|
||||
<View className="region-picker-toolbar">
|
||||
<View className="region-picker-tabs">
|
||||
<Text className="region-picker-tab active">排序规则</Text>
|
||||
</View>
|
||||
<Text className="region-picker-confirm ready" onClick={() => setSortOpen(false)}>
|
||||
关闭
|
||||
</Text>
|
||||
</View>
|
||||
<View className="region-picker-list">
|
||||
{STORE_SORT_OPTIONS.map((opt) => (
|
||||
<View
|
||||
key={opt.key}
|
||||
className={`region-picker-option${sort === opt.key ? ' selected' : ''}`}
|
||||
onClick={() => {
|
||||
setSort(opt.key);
|
||||
patchStoresFilterCache({ sort: opt.key });
|
||||
setSortOpen(false);
|
||||
}}
|
||||
>
|
||||
<Text>{opt.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user