merge(dev): 门店可见白名单
CI / verify (push) Has been cancelled

This commit is contained in:
2026-08-03 13:58:47 +08:00
21 changed files with 557 additions and 78 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">
+3 -1
View File
@@ -2,6 +2,7 @@ import Taro from '@tarojs/taro';
import { ClientApp } from '@dukang/shared-types';
import { forceReloadAfterAccountMerge } from './auth-nav';
import { resetStoresSessionBootstrap } from './stores-session';
import { resetHomeCatalogBootstrap } from './home-catalog-session';
function resolveApiBase(): string {
const origin =
@@ -57,8 +58,9 @@ export function isLoggedIn(): boolean {
export function logout() {
clearAuth();
// 主动退出才重置门店「当次登录」会话;401 清 token 不要打断门店筛选
// 主动退出才重置门店/首页「当次登录」会话;401 清 token 不要打断筛选
resetStoresSessionBootstrap();
resetHomeCatalogBootstrap();
Taro.reLaunch({ url: '/pages/home/index' });
}
@@ -0,0 +1,75 @@
/**
* 首页商品列表「当次登录」会话 —— 切 tab 不重复拉商品;
* 城市 / 登录态变化或下拉刷新时再请求。logout 时 clear。
*/
import Taro from '@tarojs/taro';
export type HomeCatalogCache = {
cityCode: string;
authKey: string;
products: unknown[];
};
type HomeSession = {
bootstrapped: boolean;
cache: HomeCatalogCache | null;
};
const STORAGE_KEY = 'dukang_home_catalog_session_v1';
let memory: HomeSession | null = null;
function emptySession(): HomeSession {
return { bootstrapped: false, cache: null };
}
function readSession(): HomeSession {
if (memory) return memory;
try {
const raw = Taro.getStorageSync(STORAGE_KEY);
if (!raw) {
memory = emptySession();
return memory;
}
const parsed = (typeof raw === 'string' ? JSON.parse(raw) : raw) as Partial<HomeSession>;
memory = {
bootstrapped: !!parsed.bootstrapped,
cache: (parsed.cache as HomeCatalogCache | null) ?? null,
};
return memory;
} catch {
memory = emptySession();
return memory;
}
}
function writeSession(next: HomeSession) {
memory = next;
try {
Taro.setStorageSync(STORAGE_KEY, JSON.stringify(next));
} catch {
/* ignore quota */
}
}
export function isHomeCatalogBootstrapped(): boolean {
return readSession().bootstrapped;
}
export function getHomeCatalogCache(): HomeCatalogCache | null {
return readSession().cache;
}
export function setHomeCatalogCache(cache: HomeCatalogCache): void {
writeSession({ bootstrapped: true, cache });
}
export function resetHomeCatalogBootstrap(): void {
memory = emptySession();
try {
Taro.removeStorageSync(STORAGE_KEY);
} catch {
/* ignore */
}
}
+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;
+56 -24
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { View, Text, Image, Swiper, SwiperItem } from '@tarojs/components';
import Taro, {
useDidShow,
@@ -13,7 +13,12 @@ import CouponBadge from '../../components/CouponBadge';
import WechatShareReady from '../../components/WechatShareReady';
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
import { goLogin } from '../../lib/auth-nav';
import { isLoggedIn, request, toast } from '../../lib/api';
import { getToken, isLoggedIn, request, toast } from '../../lib/api';
import {
getHomeCatalogCache,
isHomeCatalogBootstrapped,
setHomeCatalogCache,
} from '../../lib/home-catalog-session';
import { ensurePayReady } from '../../lib/pay-ready';
import { capturePromoSceneAndTouchScan } from '../../lib/promo';
import { getProductMainImage } from '../../lib/product-images';
@@ -29,7 +34,6 @@ import {
DEFAULT_SHARE_TITLE,
toWeappShareMessage,
} from '../../lib/wechat-share';
type Product = {
id: string;
name: string;
@@ -110,35 +114,60 @@ export default function HomePage() {
});
}, []);
const loadProducts = useCallback(() => {
setLoading(true);
return request<Product[]>(`/catalog/products?cityCode=${encodeURIComponent(cityCode)}`)
.then((list) =>
setProducts(Array.isArray(list) ? list.map((p) => normalizeFulfillmentFlags(p)) : []),
)
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoading(false));
}, [cityCode]);
const applyProductList = useCallback((list: Product[], nextCode: string, authKey: string) => {
const normalized = Array.isArray(list) ? list.map((p) => normalizeFulfillmentFlags(p)) : [];
setProducts(normalized);
setHomeCatalogCache({ cityCode: nextCode, authKey, products: normalized });
}, []);
const fetchProducts = useCallback(
(nextCode: string, authKey: string) => {
setLoading(true);
return request<Product[]>(`/catalog/products?cityCode=${encodeURIComponent(nextCode)}`)
.then((list) => applyProductList(list, nextCode, authKey))
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoading(false));
},
[applyProductList],
);
/**
* 首次进入 / 城市或登录态变化:拉商品。
* 同次再切 tab:只同步选中态,不重复请求(对齐门店页)。
*/
useDidShow(() => {
syncTabBarSelected(0);
void capturePromoSceneAndTouchScan();
void loadMiniHome();
// 白名单商品按登录手机号过滤;登录后 switchTab 回首页不会卸载页面,须重新拉列表
void loadProducts();
void resolveUserCity().then((resolved) => {
setDisplayCity(resolved.displayCity);
setCityCode(getCityCodeForCatalog(resolved));
});
});
useEffect(() => {
void loadProducts();
}, [loadProducts]);
const authKey = getToken() || '';
void (async () => {
const resolved = await resolveUserCity();
const nextCode = getCityCodeForCatalog(resolved);
setDisplayCity(resolved.displayCity);
setCityCode(nextCode);
const cache = getHomeCatalogCache();
if (
isHomeCatalogBootstrapped() &&
cache &&
cache.cityCode === nextCode &&
cache.authKey === authKey &&
Array.isArray(cache.products)
) {
setProducts(cache.products as Product[]);
setLoading(false);
return;
}
await fetchProducts(nextCode, authKey);
})();
});
usePullDownRefresh(() => {
void (async () => {
try {
const authKey = getToken() || '';
const resolved = await resolveUserCity();
setDisplayCity(resolved.displayCity);
const nextCode = getCityCodeForCatalog(resolved);
@@ -148,7 +177,11 @@ export default function HomePage() {
request<Product[]>(`/catalog/products?cityCode=${encodeURIComponent(nextCode)}`),
loadMiniHome(),
]);
setProducts(Array.isArray(list) ? list.map((p) => normalizeFulfillmentFlags(p)) : []);
applyProductList(
Array.isArray(list) ? list : [],
nextCode,
authKey,
);
} catch (e) {
toast(e instanceof Error ? e.message : '加载失败');
} finally {
@@ -157,7 +190,6 @@ export default function HomePage() {
}
})();
});
function openProductDetail(id: string) {
Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` });
}
@@ -75,11 +75,12 @@ export default function OrderConfirmPickupPage() {
const canSubmit = !!preview && quantityOk && !loading && !previewLoading;
function updateQuantity(next: number) {
if (next < 1) return;
if (next < minQty) {
const tip = `现场提货至少购买 ${minQty}`;
toast(tip);
setMsg(tip);
if (next < 1) return;
setQuantity(next);
return;
}
setQuantity(next);
@@ -101,8 +102,12 @@ export default function OrderConfirmPickupPage() {
async function submit() {
if (!canSubmit) {
if (!quantityOk) setMsg(`现场提货至少购买 ${minQty}`);
return;
if (!quantityOk) {
const tip = `现场提货至少购买 ${minQty}`;
setMsg(tip);
toast(tip);
return;
}
}
const returnPath = `/pages/order-confirm-pickup/index?productId=${productId}&qty=${quantity}`;
@@ -191,8 +196,8 @@ export default function OrderConfirmPickupPage() {
<Text></Text>
<View className="order-qty-controls">
<View
className={`order-qty-btn${quantity <= minQty ? ' order-qty-btn--disabled' : ''}`}
onClick={() => updateQuantity(Math.max(minQty, quantity - 1))}
className="order-qty-btn"
onClick={() => updateQuantity(quantity - 1)}
>
<Text></Text>
</View>
@@ -167,14 +167,14 @@ export default function OrderConfirmPage() {
: '';
function updateQuantity(next: number) {
if (next < 1) return;
if (next < minQty) {
const tip = isCross
? `跨城配送至少购买 ${minQty} 瓶(1箱)`
: `同城配送至少购买 ${minQty}`;
toast(tip);
setMsg(tip);
setQuantity(Math.max(1, next));
if (next < 1) return;
setQuantity(next);
return;
}
setQuantity(next);
@@ -352,7 +352,7 @@ export default function OrderConfirmPage() {
<Text></Text>
<View className="order-qty-controls">
<View
className={`order-qty-btn${quantity <= 1 ? ' order-qty-btn--disabled' : ''}`}
className="order-qty-btn"
onClick={() => updateQuantity(quantity - 1)}
>
<Text></Text>
@@ -181,12 +181,15 @@ export default function OrderDetailPage() {
<SubPageHeader
title="订单详情"
onBack={() => {
const pages = Taro.getCurrentPages();
if (pages.length > 1) {
Taro.navigateBack();
// 支付完成后 reLaunch 进详情:栈仅一页时 navigateBack 会退出小程序,统一回首页
const fromPay = String(router.params.from || '') === 'pay';
if (fromPay || Taro.getCurrentPages().length <= 1) {
Taro.switchTab({ url: '/pages/home/index' }).catch(() => {
Taro.reLaunch({ url: '/pages/home/index' });
});
return;
}
Taro.switchTab({ url: '/pages/home/index' });
Taro.navigateBack();
}}
right={<ShareNavButton payload={sharePayload} />}
/>
+5 -1
View File
@@ -95,7 +95,11 @@ export default function OrdersPage() {
<PageShell variant="sub" className="orders-page">
<SubPageHeader
title="我的订单"
onBack={() => Taro.switchTab({ url: '/pages/home/index' })}
onBack={() => {
Taro.switchTab({ url: '/pages/home/index' }).catch(() => {
Taro.reLaunch({ url: '/pages/home/index' });
});
}}
/>
<View className="order-tabs">
{TABS.map((t) => (
+3 -3
View File
@@ -144,10 +144,10 @@ export default function PayPage() {
toast('支付成功', 'success');
}
if (deliveryType === 'ON_SITE_PICKUP') {
// 现场提货支付即完成 → 订单详情(已完成);reLaunch 清掉商品详情栈
Taro.reLaunch({ url: `/pages/order-detail/index?id=${orderId}` });
// 现场提货支付即完成 → 订单详情;from=pay 返回强制回首页,避免 navigateBack 退出小程序
Taro.reLaunch({ url: `/pages/order-detail/index?id=${orderId}&from=pay` });
} else {
Taro.reLaunch({ url: '/pages/orders/index?tab=paid' });
Taro.reLaunch({ url: '/pages/orders/index?tab=paid&from=pay' });
}
} catch (e) {
if (isWechatAuthRequiredError(e)) {
+16 -1
View File
@@ -44,6 +44,20 @@ export default function RedeemCodePage() {
const [timerSec, setTimerSec] = useState(REDEEM_TOKEN_TTL_SECONDS);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const successHandled = useRef(false);
const lastTokenTapAt = useRef(0);
function onTokenTap() {
if (!token) return;
const now = Date.now();
if (now - lastTokenTapAt.current < 350) {
lastTokenTapAt.current = 0;
void Taro.setClipboardData({ data: token })
.then(() => toast('核销码编号已复制', 'success'))
.catch(() => toast('复制失败'));
return;
}
lastTokenTapAt.current = now;
}
const stopTimer = useCallback(() => {
if (timerRef.current != null) {
@@ -144,9 +158,10 @@ export default function RedeemCodePage() {
<Text className="u-muted"></Text>
<Text className="redeem-code-amount">¥ {formatMoney(amount)}</Text>
{token ? (
<View className="redeem-code-token-wrap">
<View className="redeem-code-token-wrap" onClick={onTokenTap}>
<Text className="redeem-code-token-label"></Text>
<Text className="redeem-code-token">{token}</Text>
<Text className="redeem-code-token-hint"></Text>
</View>
) : null}
</View>
@@ -20,6 +20,29 @@ function formatMoney(amount: number) {
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
/** 中国时区展示:2026年8月3日 13点45分 */
function formatChinaDateTime(input?: string | null) {
const d = input ? new Date(input) : new Date();
if (Number.isNaN(d.getTime())) return '—';
const parts = new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: false,
}).formatToParts(d);
const get = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((p) => p.type === type)?.value ?? '';
const year = get('year');
const month = String(Number(get('month')));
const day = String(Number(get('day')));
const hour = String(Number(get('hour')));
const minute = get('minute').padStart(2, '0');
return `${year}${month}${day}${hour}${minute}`;
}
function StarRating({
label,
value,
@@ -65,9 +88,7 @@ export default function RedeemSuccessPage() {
const amount = Number(record?.amount ?? router.params.amount ?? 0);
const storeName = record?.storeName || '门店';
const redeemNo = record?.redeemNo || '—';
const redeemedAt = record?.createdAt
? new Date(record.createdAt).toLocaleString('zh-CN')
: new Date().toLocaleString('zh-CN');
const redeemedAt = formatChinaDateTime(record?.createdAt);
function clearCache() {
try {
@@ -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();
+11 -1
View File
@@ -304,9 +304,19 @@
display: block;
font-family: ui-monospace, monospace;
font-size: 11px;
word-break: break-all;
color: var(--color-on-surface);
line-height: 1.5;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.redeem-code-token-hint {
display: block;
margin-top: 4px;
font-size: 11px;
color: var(--color-subtle-gray);
}
.redeem-success-icon {
+17
View File
@@ -1062,6 +1062,8 @@ model Store {
openTime2 String? @map("open_time_2") @db.VarChar(8)
closeTime2 String? @map("close_time_2") @db.VarChar(8)
settlementRate Decimal @default(0.60) @map("settlement_rate") @db.Decimal(5, 4)
/// Online test: only listed phones can see store on C-end when enabled
visibilityWhitelistEnabled Boolean @default(false) @map("visibility_whitelist_enabled")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@ -1075,6 +1077,7 @@ model Store {
ratings StoreRating[]
payouts StorePayout[]
storeBills StoreBill[]
visibilityPhones StoreVisibilityPhone[]
@@index([cityId, status])
@@index([partnerAccountId])
@@ -1082,6 +1085,20 @@ model Store {
@@map("store_store")
}
/// Store visibility whitelist phones (match by bound user phone)
model StoreVisibilityPhone {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
storeId BigInt @map("store_id") @db.UnsignedBigInt
phone String @db.VarChar(20)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
@@unique([storeId, phone])
@@index([phone])
@@map("store_visibility_phone")
}
model StoreAccount {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
phone String @unique @db.VarChar(20)
@@ -25,6 +25,24 @@ function normalizeStoreOptionalText(value: unknown): string | null {
return s;
}
function normalizeVisibilityPhones(phones?: string[]): string[] {
if (!phones?.length) return [];
const out: string[] = [];
const seen = new Set<string>();
for (const raw of phones) {
const phone = String(raw || '')
.replace(/\D/g, '')
.trim();
if (!phone || seen.has(phone)) continue;
if (!/^1\d{10}$/.test(phone)) {
throw new BadRequestException(`手机号格式无效:${raw}`);
}
seen.add(phone);
out.push(phone);
}
return out;
}
@Injectable()
export class AdminStoresService {
constructor(
@@ -64,19 +82,23 @@ export class AdminStoresService {
},
},
coverResource: { select: { id: true, url: true } },
visibilityPhones: { select: { phone: true }, orderBy: { phone: 'asc' } },
},
}),
this.prisma.store.count({ where }),
]);
return serializeBigInt({
items: items.map((s) =>
mapStoreCompat({
...s,
items: items.map((s) => {
const { visibilityPhones, ...rest } = s;
return mapStoreCompat({
...rest,
visibilityWhitelistEnabled: s.visibilityWhitelistEnabled,
visibilityPhones: visibilityPhones.map((p) => p.phone),
partner: s.partnerAccount,
account: s.bindings[0]?.storeAccount ?? null,
bindings: undefined,
}),
),
});
}),
total,
page,
pageSize,
@@ -96,6 +118,7 @@ export class AdminStoresService {
include: { storeAccount: true },
},
coverResource: true,
visibilityPhones: { select: { phone: true }, orderBy: { phone: 'asc' } },
_count: { select: { redeemRecords: true, ratings: true } },
},
});
@@ -111,8 +134,12 @@ export class AdminStoresService {
take: 5,
}),
]);
const { visibilityPhones, ...rest } = store;
return serializeBigInt(mapStoreCompat({
...store,
...rest,
visibilityWhitelistEnabled: store.visibilityWhitelistEnabled,
visibilityPhones: visibilityPhones.map((p) => p.phone),
partner: store.partnerAccount,
account: store.bindings[0]?.storeAccount ?? null,
/** 门店端登录手机号(store_account.phone),与 store.phone 应对齐 */
loginPhone: store.bindings[0]?.storeAccount?.phone ?? store.phone,
@@ -235,6 +262,26 @@ export class AdminStoresService {
}
const normalizedPhone = dto.phone !== undefined ? dto.phone.trim() : undefined;
if (dto.visibilityWhitelistEnabled !== undefined || dto.visibilityPhones !== undefined) {
const nextEnabled =
dto.visibilityWhitelistEnabled !== undefined
? !!dto.visibilityWhitelistEnabled
: current.visibilityWhitelistEnabled;
if (nextEnabled) {
const phones =
dto.visibilityPhones !== undefined
? normalizeVisibilityPhones(dto.visibilityPhones)
: (
await this.prisma.storeVisibilityPhone.findMany({
where: { storeId: id },
select: { phone: true },
})
).map((p) => p.phone);
if (!phones.length) {
throw new BadRequestException('开启白名单时请至少添加一个手机号');
}
}
}
const bankTouched =
dto.bankAccountName !== undefined ||
dto.bankAccountNo !== undefined ||
@@ -307,10 +354,23 @@ export class AdminStoresService {
...(dto.openTime2 !== undefined ? { openTime2: dto.openTime2 || null } : {}),
...(dto.closeTime2 !== undefined ? { closeTime2: dto.closeTime2 || null } : {}),
...(dto.avgPrice !== undefined ? { avgPrice: dto.avgPrice } : {}),
...(dto.visibilityWhitelistEnabled !== undefined
? { visibilityWhitelistEnabled: !!dto.visibilityWhitelistEnabled }
: {}),
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
},
});
if (dto.visibilityPhones !== undefined) {
const phones = normalizeVisibilityPhones(dto.visibilityPhones);
await tx.storeVisibilityPhone.deleteMany({ where: { storeId: id } });
if (phones.length) {
await tx.storeVisibilityPhone.createMany({
data: phones.map((phone) => ({ storeId: id, phone })),
});
}
}
if (dto.coverUrl) {
if (current.coverResourceId) {
await tx.commonResource.update({
@@ -430,6 +490,12 @@ export class AdminStoresService {
throw new BadRequestException('好客权益券使用规则最多 1000 字');
}
const visibilityPhones = normalizeVisibilityPhones(dto.visibilityPhones);
const whitelistEnabled = !!dto.visibilityWhitelistEnabled;
if (whitelistEnabled && !visibilityPhones.length) {
throw new BadRequestException('开启白名单时请至少添加一个手机号');
}
const store = await this.prisma.store.create({
data: {
cityId: city.id,
@@ -449,11 +515,19 @@ export class AdminStoresService {
closeTime,
openTime2: openTime2 || null,
closeTime2: closeTime2 || null,
visibilityWhitelistEnabled: whitelistEnabled,
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
status: 'OPEN',
auditStatus: 'APPROVED',
auditedAt: new Date(),
rejectReason: null,
...(visibilityPhones.length
? {
visibilityPhones: {
create: visibilityPhones.map((phone) => ({ phone })),
},
}
: {}),
},
});
@@ -127,6 +127,17 @@ export class CreateStoreDto {
@IsNumber()
@Min(0)
avgPrice?: number;
/** 开启后仅白名单手机号在 C 端可见 */
@IsOptional()
@IsBoolean()
visibilityWhitelistEnabled?: boolean;
/** 可见白名单手机号列表 */
@IsOptional()
@IsArray()
@IsString({ each: true })
visibilityPhones?: string[];
}
export class UpdateStoreDto {
@@ -215,6 +226,17 @@ export class UpdateStoreDto {
@IsOptional()
@IsString()
bankBranch?: string | null;
/** 开启后仅白名单手机号在 C 端可见 */
@IsOptional()
@IsBoolean()
visibilityWhitelistEnabled?: boolean;
/** 可见白名单手机号列表 */
@IsOptional()
@IsArray()
@IsString({ each: true })
visibilityPhones?: string[];
}
export class CreateStoreAccountDto {
@@ -3,6 +3,7 @@ import { StoreService } from './store.service';
import { StoreCategoryService } from './store-category.service';
import { RedeemService } from '../redeem/redeem.service';
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
import { RequirePartnerPermissions } from '../../common/decorators/partner-permission.decorator';
import { PartnerPermissionGuard } from '../../common/guards/partner-permission.guard';
@@ -14,19 +15,29 @@ export class PublicStoreController {
constructor(private readonly storeService: StoreService) {}
@Get()
list(
@UseGuards(OptionalJwtAuthGuard)
async list(
@CurrentUser() user: AuthUser | undefined,
@Query('cityCode') cityCode?: string,
@Query('lat') lat?: string,
@Query('lng') lng?: string,
) {
const userLat = lat != null && lat !== '' ? Number(lat) : undefined;
const userLng = lng != null && lng !== '' ? Number(lng) : undefined;
return this.storeService.listOpenStores(cityCode, userLat, userLng);
const viewerPhone = await this.resolveViewerPhone(user);
return this.storeService.listOpenStores(cityCode, userLat, userLng, { phone: viewerPhone });
}
@Get(':id')
detail(@Param('id') id: string) {
return this.storeService.getStore(BigInt(id));
@UseGuards(OptionalJwtAuthGuard)
async detail(@CurrentUser() user: AuthUser | undefined, @Param('id') id: string) {
const viewerPhone = await this.resolveViewerPhone(user);
return this.storeService.getStore(BigInt(id), { phone: viewerPhone });
}
private async resolveViewerPhone(user?: AuthUser) {
if (!user || user.actorType !== 'USER') return null;
return this.storeService.resolveUserPhone(user.actorId);
}
}
@@ -36,6 +36,17 @@ function normalizeOptionalTextField(value: unknown): string | null {
return s;
}
export type StoreViewer = {
/** C 端用户手机号;无则无法看到白名单门店 */
phone?: string | null;
/** 运营/代下单等场景跳过白名单 */
bypassWhitelist?: boolean;
};
function normalizePhone(phone: string | null | undefined): string {
return (phone || '').replace(/\D/g, '').trim();
}
function parseOptionalCoord(value: unknown, kind: 'lat' | 'lng' = 'lng'): number | null {
if (value == null || value === '') return null;
const n = typeof value === 'number' ? value : Number(value);
@@ -96,7 +107,34 @@ export class StoreService {
return { latitude: geo.latitude, longitude: geo.longitude };
}
async listOpenStores(cityCode?: string, userLat?: number, userLng?: number) {
async resolveUserPhone(userId: bigint): Promise<string | null> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { phone: true },
});
return user?.phone ?? null;
}
isVisibleToViewer(
store: {
visibilityWhitelistEnabled: boolean;
visibilityPhones: Array<{ phone: string }>;
},
viewer?: StoreViewer,
): boolean {
if (viewer?.bypassWhitelist) return true;
if (!store.visibilityWhitelistEnabled) return true;
const phone = normalizePhone(viewer?.phone);
if (!phone) return false;
return store.visibilityPhones.some((row) => normalizePhone(row.phone) === phone);
}
async listOpenStores(
cityCode?: string,
userLat?: number,
userLng?: number,
viewer?: StoreViewer,
) {
const where: Record<string, unknown> = { status: 'OPEN' };
if (cityCode) {
const city = await this.prisma.commonCity.findFirst({ where: { code: cityCode } });
@@ -104,10 +142,16 @@ export class StoreService {
}
const stores = await this.prisma.store.findMany({
where: where as never,
include: { category: true, coverResource: true },
include: {
category: true,
coverResource: true,
visibilityPhones: { select: { phone: true } },
},
orderBy: { createdAt: 'desc' },
});
const visible = stores.filter((s) => this.isVisibleToViewer(s, viewer));
const hasUser =
userLat != null &&
userLng != null &&
@@ -121,10 +165,11 @@ export class StoreService {
};
const items: StoreListItem[] = [];
for (const store of stores) {
for (const store of visible) {
const coords = await this.ensureStoreCoordinates(store);
const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = store;
const mapped = mapStoreCompat({
...store,
...rest,
latitude: coords?.latitude ?? store.latitude,
longitude: coords?.longitude ?? store.longitude,
});
@@ -146,20 +191,27 @@ export class StoreService {
return serializeBigInt(items);
}
async getStore(id: bigint) {
async getStore(id: bigint, viewer?: StoreViewer) {
const store = await this.prisma.store.findFirst({
where: { id, status: 'OPEN' },
include: { category: true, coverResource: true },
include: {
category: true,
coverResource: true,
visibilityPhones: { select: { phone: true } },
},
});
if (!store) throw new NotFoundException('门店不存在');
if (!store || !this.isVisibleToViewer(store, viewer)) {
throw new NotFoundException('门店不存在');
}
const coords = await this.ensureStoreCoordinates(store);
const media = await this.prisma.commonResource.findMany({
where: { ownerType: 'STORE', ownerId: id, status: 'ACTIVE', bizType: 'ENV' },
orderBy: { sortOrder: 'asc' },
});
const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = store;
return serializeBigInt(
mapStoreCompat({
...store,
...rest,
latitude: coords?.latitude ?? store.latitude,
longitude: coords?.longitude ?? store.longitude,
media,