@@ -27,6 +27,8 @@ export type StoreCreateForm = {
|
||||
bankAccountNo: string;
|
||||
bankBranch: string;
|
||||
settlementRate?: number;
|
||||
visibilityWhitelistEnabled?: boolean;
|
||||
visibilityPhones?: string[];
|
||||
};
|
||||
|
||||
const PHONE_RE = /^1\d{10}$/;
|
||||
|
||||
@@ -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,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 */
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ export type StoresSessionCategory = {
|
||||
export type StoresListCache = {
|
||||
cityKey: string;
|
||||
cityCode: string;
|
||||
/** 登录态指纹:token 变化时需重新拉取(白名单) */
|
||||
authKey: string;
|
||||
listRegion: StoresSessionRegion;
|
||||
items: unknown[];
|
||||
filterRegion: StoresSessionRegion;
|
||||
|
||||
@@ -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} />}
|
||||
/>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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(
|
||||
() => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user