feat(assoc): v4.0.1 合伙人关联码、分佣账单与 H5 用户管理

订单佣金只认关联用户;合伙人备注写入独立表;H5 增加用户管理与首页统计。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-30 14:35:39 +08:00
parent 3b669f7e38
commit 9c8d5f2cad
125 changed files with 6355 additions and 1436 deletions
+135 -63
View File
@@ -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>
);
}