feat(store): 门店可见白名单(对齐商品)

HQ 可配置 visibilityWhitelistEnabled + 手机号;C 端公开门店列表/详情按登录手机号过滤;登录态变化后小程序重新拉门店列表。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 13:58:35 +08:00
parent 06cbcdeb9c
commit fa3484041e
10 changed files with 344 additions and 32 deletions
+2
View File
@@ -27,6 +27,8 @@ export type StoreCreateForm = {
bankAccountNo: string;
bankBranch: string;
settlementRate?: number;
visibilityWhitelistEnabled?: boolean;
visibilityPhones?: string[];
};
const PHONE_RE = /^1\d{10}$/;
+111 -1
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
Alert,
@@ -13,6 +13,7 @@ import {
Select,
Space,
Steps,
Switch,
Table,
Tabs,
Tag,
@@ -20,6 +21,7 @@ import {
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { FormInstance } from 'antd/es/form';
import { FilePdfOutlined, LinkOutlined, EnvironmentOutlined } from '@ant-design/icons';
import { request, type Paginated } from '../lib/api';
import {
@@ -274,6 +276,89 @@ function StoreAuditMediaSection({ detail }: { detail: Record<string, unknown> })
);
}
type UserPickRow = { id: string; phone?: string | null; nickname?: string | null; userNo?: string | null };
function StoreVisibilityWhitelistFields({ form }: { form: FormInstance }) {
const [userOptions, setUserOptions] = useState<UserPickRow[]>([]);
const [userSearching, setUserSearching] = useState(false);
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
async function searchUsers(keyword: string) {
const q = keyword.trim();
if (searchTimer.current) clearTimeout(searchTimer.current);
if (!q) {
setUserOptions([]);
return;
}
searchTimer.current = setTimeout(() => {
void (async () => {
setUserSearching(true);
try {
const qs = new URLSearchParams({ page: '1', pageSize: '20', phone: q });
const res = await request<{ items: UserPickRow[] }>(`/admin/users?${qs}`);
setUserOptions((res.items ?? []).filter((u) => !!u.phone));
} catch {
setUserOptions([]);
} finally {
setUserSearching(false);
}
})();
}, 300);
}
const enabled = Form.useWatch('visibilityWhitelistEnabled', form);
return (
<>
<Form.Item
name="visibilityWhitelistEnabled"
label="可见白名单"
valuePropName="checked"
extra="开启后仅名单内手机号在 C 端可见,用于在线测试"
>
<Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item>
{enabled ? (
<>
<Form.Item
name="visibilityPhones"
label="白名单手机号"
rules={[{ required: true, message: '请至少添加一个手机号' }]}
extra="可直接输入多个手机号回车添加,或从下方用户库选择"
>
<Select
mode="tags"
tokenSeparators={[',', ' ', '', ';', '']}
placeholder="输入手机号后回车"
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item label="从用户库添加">
<Select
showSearch
filterOption={false}
placeholder="按手机号搜索用户"
loading={userSearching}
options={userOptions.map((u) => ({
value: u.phone!,
label: `${u.phone}${u.nickname ? ` · ${u.nickname}` : ''}${u.userNo ? `${u.userNo}` : ''}`,
}))}
onSearch={searchUsers}
onSelect={(phone: string) => {
const cur = (form.getFieldValue('visibilityPhones') as string[] | undefined) ?? [];
if (!cur.includes(phone)) {
form.setFieldsValue({ visibilityPhones: [...cur, phone] });
}
}}
notFoundContent={userSearching ? '搜索中…' : '输入手机号搜索'}
/>
</Form.Item>
</>
) : null}
</>
);
}
type StoreRow = {
id: string;
name: string;
@@ -287,6 +372,8 @@ type StoreRow = {
intro: string | null;
coverUrl: string | null;
createdAt: string;
visibilityWhitelistEnabled?: boolean;
visibilityPhones?: string[];
cityRef?: { name: string; code: string };
partner?: { companyName: string };
account?: {
@@ -504,6 +591,10 @@ export default function StoresPage() {
bankAccountName: account?.bankAccountName || undefined,
bankAccountNo: account?.bankAccountNo || undefined,
bankBranch: account?.bankBranch || undefined,
visibilityWhitelistEnabled: !!d.visibilityWhitelistEnabled,
visibilityPhones: Array.isArray(d.visibilityPhones)
? (d.visibilityPhones as string[])
: [],
});
setDrawerOpen(true);
}
@@ -543,6 +634,10 @@ export default function StoresPage() {
bankAccountName: v.bankAccountName ?? null,
bankAccountNo: v.bankAccountNo ?? null,
bankBranch: v.bankBranch ?? null,
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
visibilityPhones: ((v.visibilityPhones as string[] | undefined) ?? [])
.map((p) => String(p || '').replace(/\D/g, '').trim())
.filter(Boolean),
...(hasCoords
? { latitude: Number(v.latitude), longitude: Number(v.longitude) }
: {}),
@@ -632,6 +727,8 @@ export default function StoresPage() {
openTime2: undefined,
closeTime2: undefined,
avgPrice: undefined,
visibilityWhitelistEnabled: false,
visibilityPhones: [],
});
setCreateStep(0);
setCreateError('');
@@ -713,6 +810,10 @@ export default function StoresPage() {
bankAccountNo: values.bankAccountNo.replace(/\s/g, ''),
bankBranch: values.bankBranch.trim(),
settlementRate: Number(values.settlementRate ?? 60) / 100,
visibilityWhitelistEnabled: !!values.visibilityWhitelistEnabled,
visibilityPhones: (values.visibilityPhones ?? [])
.map((p: string) => String(p || '').replace(/\D/g, '').trim())
.filter(Boolean),
}),
});
message.success('门店已创建');
@@ -766,6 +867,13 @@ export default function StoresPage() {
},
},
{ title: '开城合伙人', dataIndex: ['partner', 'companyName'], width: 120 },
{
title: '可见',
dataIndex: 'visibilityWhitelistEnabled',
width: 90,
render: (v, row) =>
v ? <Tag color="orange">{row.visibilityPhones?.length ?? 0}</Tag> : <Tag></Tag>,
},
{ title: '介绍', dataIndex: 'intro', width: 160, ellipsis: true, render: (v) => v || '—' },
{ title: '店长', dataIndex: ['account', 'name'], width: 90, render: (v) => v || '—' },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
@@ -1059,6 +1167,7 @@ export default function StoresPage() {
<Input type="time" style={{ width: 140 }} />
</Form.Item>
</Space>
<StoreVisibilityWhitelistFields form={editForm} />
</>
),
},
@@ -1279,6 +1388,7 @@ export default function StoresPage() {
>
<Input.TextArea rows={3} placeholder="选填,最多1000字" showCount maxLength={1000} />
</Form.Item>
<StoreVisibilityWhitelistFields form={createForm} />
</div>
<div style={{ display: createStep === 1 ? 'block' : 'none' }}>
<Typography.Paragraph type="secondary">
+2
View File
@@ -22,6 +22,8 @@ export type StoresSessionCategory = {
export type StoresListCache = {
cityKey: string;
cityCode: string;
/** 登录态指纹:token 变化时需重新拉取(白名单) */
authKey: string;
listRegion: StoresSessionRegion;
items: unknown[];
filterRegion: StoresSessionRegion;
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { View, Text, Image } from '@tarojs/components';
import Taro, { usePageScroll, useRouter, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
import Taro, { useDidShow, usePageScroll, useRouter, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import PageNavBar from '../../components/PageNavBar';
import ProductCarousel from '../../components/ProductCarousel';
@@ -78,20 +78,15 @@ export default function StoreDetailPage() {
setHeaderSolid(scrollTop > 100);
});
useEffect(() => {
useDidShow(() => {
if (!storeId) return;
request<Store>(`/stores/${storeId}`)
.then(setStore)
.catch(() => {
request<Store[]>('/stores')
.then((list) => {
const found = (Array.isArray(list) ? list : []).find((s) => s.id === storeId);
if (found) setStore(found);
else toast('门店不存在');
})
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
setStore(null);
toast('门店不存在或暂不可见');
});
}, [storeId]);
});
const sharePayload = useMemo(
() => {
+29 -2
View File
@@ -27,7 +27,7 @@ import {
} from '../../lib/user-location';
import { FALLBACK_CITY_CODE } from '../../lib/product-images';
import { formatDistanceMeters } from '../../lib/geo';
import { request, toast } from '../../lib/api';
import { getToken, request, toast } from '../../lib/api';
import {
getStoresListCache,
isStoresSessionBootstrapped,
@@ -143,6 +143,7 @@ export default function StoresPage() {
setStoresListCache({
cityKey,
cityCode: nextCode,
authKey: getToken() || '',
listRegion: toCityWideRegion(listRegion),
items,
filterRegion,
@@ -160,12 +161,38 @@ export default function StoresPage() {
/**
* 首次进入:弹窗 + 定位 + 拉列表。
* 同次再切回:只同步 tab 选中态,不改筛选、不拉接口、不 setState
* 同次再切回:只同步 tab 选中态(登录态未变)
* 登录/退出后 token 变化:按缓存失效重新拉列表(白名单)。
*/
useDidShow(() => {
syncTabBarSelected(1);
const authKey = getToken() || '';
const cache = getStoresListCache();
if (isStoresSessionBootstrapped()) {
if (cache && (cache.authKey ?? '') === authKey) {
return;
}
// 登录态变了:保留筛选,重新拉列表
void (async () => {
setLoading(true);
const nextCode = cache?.cityCode || FALLBACK_CITY_CODE;
const listRegion = cache?.listRegion
? {
province: cache.listRegion.province,
city: cache.listRegion.city,
district: cache.listRegion.district || '全部',
}
: regionRef.current;
const nextCityKey = cache?.cityKey || makeCityKey(listRegion);
await fetchStores(
nextCode,
readCachedUserCoords(),
nextCityKey,
listRegion,
regionRef.current,
);
})();
return;
}
markStoresSessionBootstrapped();