merge(dev): mini-user eight fixes and catalog whitelist
CI / verify (push) Has been cancelled

This commit is contained in:
2026-08-03 13:12:40 +08:00
29 changed files with 652 additions and 97 deletions
+2
View File
@@ -6,6 +6,7 @@ import DashboardPage from './pages/DashboardPage';
import UsersPage from './pages/UsersPage';
import OrdersPage from './pages/OrdersPage';
import StoresPage from './pages/StoresPage';
import StoreRatingsPage from './pages/StoreRatingsPage';
import StoreAccountsPage from './pages/StoreAccountsPage';
import BenefitCouponsPage from './pages/BenefitCouponsPage';
import BenefitLedgersPage from './pages/BenefitLedgersPage';
@@ -78,6 +79,7 @@ export default function App() {
<Route path="/products" element={<ProductsPage />} />
<Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} />
<Route path="/stores" element={<StoresPage />} />
<Route path="/store-ratings" element={<StoreRatingsPage />} />
<Route path="/store-categories" element={<StoreCategoriesPage />} />
<Route path="/store-accounts" element={<StoreAccountsPage />} />
<Route path="/store-media" element={<StoreMediaPage />} />
@@ -51,6 +51,7 @@ const MENU_ITEMS: MenuProps['items'] = [
label: '门店',
children: [
{ key: '/stores', label: '门店列表' },
{ key: '/store-ratings', label: '门店评价' },
{ key: '/store-categories', label: '门店分类' },
{ key: '/store-accounts', label: '门店账户' },
{ key: '/store-media', label: '门店资源' },
@@ -145,6 +146,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
'/knowledge-bases': 'knowledge_bases',
'stores-group': 'stores',
'/stores': 'stores',
'/store-ratings': 'stores',
'/store-categories': 'stores',
'/store-accounts': 'stores',
'/store-media': 'stores',
@@ -0,0 +1,105 @@
import { useState } from 'react';
import { useState } from 'react';
import { Button, Form, Input, Table, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { useSearchParams } from 'react-router-dom';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string;
serviceScore: number;
envScore: number;
createdAt: string;
redeemNo: string;
redeemAmount: number;
store?: { id: string; name: string; cityName?: string };
user?: { userNo: string; phone: string | null; nickname: string | null };
};
export default function StoreRatingsPage() {
const [searchParams] = useSearchParams();
const initialStoreId = searchParams.get('storeId') || '';
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({
...(initialStoreId ? { storeId: initialStoreId } : {}),
});
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
'/admin/store-ratings',
() => {
const qs = new URLSearchParams();
if (filters.storeId) qs.set('storeId', filters.storeId);
if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo);
return qs;
},
[filters],
);
const columns: ColumnsType<Row> = [
{ title: '核销号', dataIndex: 'redeemNo', width: 180 },
{
title: '门店',
dataIndex: ['store', 'name'],
render: (v, row) => (row.store?.cityName ? `${v}${row.store.cityName}` : v),
},
{
title: '用户',
width: 140,
render: (_, row) => row.user?.phone || row.user?.userNo || '-',
},
{ title: '核销额', dataIndex: 'redeemAmount', width: 90, render: (v) => `¥${v}` },
{ title: '服务分', dataIndex: 'serviceScore', width: 80 },
{ title: '环境分', dataIndex: 'envScore', width: 80 },
{ title: '评价时间', dataIndex: 'createdAt', width: 170, render: fmtTime },
];
return (
<div>
<Typography.Title level={4} style={{ marginTop: 0 }}>
</Typography.Title>
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
initialValues={filters}
onFinish={(v) => {
setFilters({
storeId: v.storeId || '',
redeemNo: v.redeemNo || '',
});
setPage(1);
}}
>
<Form.Item name="storeId" label="门店ID">
<Input allowClear placeholder="storeId" />
</Form.Item>
<Form.Item name="redeemNo" label="核销号">
<Input allowClear />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
</div>
);
}
+5 -2
View File
@@ -770,9 +770,12 @@ export default function StoresPage() {
{ title: '店长', dataIndex: ['account', 'name'], width: 90, render: (v) => v || '—' },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作', width: 80,
title: '操作', width: 140,
render: (_, row) => (
<Button type="link" size="small" onClick={() => void openStoreDetail(row)}></Button>
<Space size={0}>
<Button type="link" size="small" onClick={() => void openStoreDetail(row)}></Button>
<Button type="link" size="small" onClick={() => navigate(`/store-ratings?storeId=${row.id}`)}></Button>
</Space>
),
},
];
+12 -4
View File
@@ -27,7 +27,7 @@ function formatScanError(e: unknown): string {
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名,并刷新页面后重试';
}
if (/offline verifying|权限验证中|接口未就绪/i.test(msg)) {
return `${msg}(可在 URL 后加 ?wxdebug=1 开启 JSSDK 调试查看详情)`;
return '微信权限校验尚未完成,请等待 1~2 秒后再次点击扫码(首次绑定微信时较常见)';
}
return msg;
}
@@ -73,7 +73,7 @@ export default function HomePage() {
};
}, [loadDashboard]);
async function runScan() {
async function runScan(opts?: { postAuthWarmup?: boolean }) {
if (!isWechatEnv()) {
setScanMsg('请在微信内打开门店端进行扫码核销');
return;
@@ -81,8 +81,14 @@ export default function HomePage() {
setScanning(true);
setScanMsg('');
try {
// 授权回跳后强制重签,避免沿用带 code 的旧签名态
if (opts?.postAuthWarmup) {
weixinSdk.reset();
}
await weixinSdk.init();
const raw = await weixinSdk.scanQrCode();
const raw = await weixinSdk.scanQrCode(
opts?.postAuthWarmup ? { postAuthWarmup: true } : undefined,
);
if (!raw) {
void loadDashboard();
return;
@@ -111,12 +117,14 @@ export default function HomePage() {
}
setAuthModalOpen(false);
setAuthError('');
// 先清 OAuth 参数再扫,保证 JSSDK 签名 URL 与当前页一致
stripOAuthParamsFromLocation();
setSearchParams({}, { replace: true });
const shouldScan = sessionStorage.getItem(PENDING_SCAN_KEY) === '1';
sessionStorage.removeItem(PENDING_SCAN_KEY);
if (shouldScan) {
window.setTimeout(() => void runScan(), 0);
// OAuth 回跳后微信权限离线校验未完成;稍候再扫,失败可再点一次
window.setTimeout(() => void runScan({ postAuthWarmup: true }), 400);
}
})
.catch((e) => {
+58 -28
View File
@@ -2,7 +2,6 @@ import { useCallback, useMemo, useState } from 'react';
import { View, Text, Image } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import ShareNavButton from '../../components/ShareNavButton';
import WechatShareReady from '../../components/WechatShareReady';
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
import { goLogin } from '../../lib/auth-nav';
@@ -31,6 +30,14 @@ type CouponItem = {
sourceProduct: string;
};
type RedeemHistoryItem = {
id: string;
redeemNo: string;
amount: number;
storeName: string;
createdAt: string;
};
function formatMoney(amount: number) {
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
@@ -46,11 +53,13 @@ export default function BenefitPage() {
const [loggedIn, setLoggedIn] = useState(() => isLoggedIn());
const [summary, setSummary] = useState<BenefitSummary | null>(null);
const [coupons, setCoupons] = useState<CouponItem[]>([]);
const [redeemHistory, setRedeemHistory] = useState<RedeemHistoryItem[]>([]);
const [tab, setTab] = useState<'available' | 'history'>('available');
const resetGuestState = useCallback(() => {
setSummary(null);
setCoupons([]);
setRedeemHistory([]);
}, []);
const loadBenefit = useCallback(() => {
@@ -58,10 +67,17 @@ export default function BenefitPage() {
return Promise.all([
request<BenefitSummary>('/benefit/summary'),
request<CouponItem[]>('/benefit/coupons'),
request<{ list?: RedeemHistoryItem[] } | RedeemHistoryItem[]>('/redeem/records?page=1&pageSize=50'),
])
.then(([s, list]) => {
.then(([s, list, records]) => {
setSummary(s);
setCoupons(Array.isArray(list) ? list : []);
const hist = Array.isArray(records)
? records
: Array.isArray(records?.list)
? records.list
: [];
setRedeemHistory(hist);
})
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
}, []);
@@ -89,8 +105,6 @@ export default function BenefitPage() {
});
const available = coupons.filter((c) => c.status === 'ACTIVE' && c.balance > 0);
const history = coupons.filter((c) => c.status === 'USED_UP' || c.status === 'VOID');
const visible = tab === 'available' ? available : history;
const sharePayload = useMemo(
() => ({
@@ -123,9 +137,6 @@ export default function BenefitPage() {
<View className="benefit-header-city-pin" />
<Text></Text>
</View>
<View className="benefit-header__share page-nav-bar__right-slot">
<ShareNavButton payload={sharePayload} />
</View>
</View>
</View>
@@ -180,26 +191,26 @@ export default function BenefitPage() {
</Text>
</View>
{visible.length === 0 ? (
<View className="u-empty">{tab === 'available' ? '暂无可用权益' : '暂无历史记录'}</View>
) : (
visible.map((c) => (
<View key={c.id} className="benefit-coupon">
<View className="benefit-coupon-notch" />
<View className="benefit-coupon-notch benefit-coupon-notch--right" />
<View className="benefit-coupon-head">
<Text className="benefit-coupon-name">{c.sourceProduct || '好客权益'}</Text>
<Text className="benefit-coupon-balance">¥{formatMoney(c.balance)}</Text>
</View>
<Text className="benefit-coupon-no">NO. {c.couponNo}</Text>
<View className="benefit-progress">
<View className="benefit-progress-bar" style={{ width: `${usagePercent(c)}%` }} />
</View>
<View className="benefit-coupon-footer">
<Text className="benefit-coupon-meta">
¥{formatMoney(c.usedAmount)} / ¥{formatMoney(c.totalAmount)}
</Text>
{tab === 'available' ? (
{tab === 'available' ? (
available.length === 0 ? (
<View className="u-empty"></View>
) : (
available.map((c) => (
<View key={c.id} className="benefit-coupon">
<View className="benefit-coupon-notch" />
<View className="benefit-coupon-notch benefit-coupon-notch--right" />
<View className="benefit-coupon-head">
<Text className="benefit-coupon-name">{c.sourceProduct || '好客权益'}</Text>
<Text className="benefit-coupon-balance">¥{formatMoney(c.balance)}</Text>
</View>
<Text className="benefit-coupon-no">NO. {c.couponNo}</Text>
<View className="benefit-progress">
<View className="benefit-progress-bar" style={{ width: `${usagePercent(c)}%` }} />
</View>
<View className="benefit-coupon-footer">
<Text className="benefit-coupon-meta">
¥{formatMoney(c.usedAmount)} / ¥{formatMoney(c.totalAmount)}
</Text>
<Text
className="benefit-coupon-btn"
onClick={() =>
@@ -210,7 +221,26 @@ export default function BenefitPage() {
>
</Text>
) : null}
</View>
</View>
))
)
) : redeemHistory.length === 0 ? (
<View className="u-empty"></View>
) : (
redeemHistory.map((r) => (
<View key={r.id} className="benefit-coupon">
<View className="benefit-coupon-notch" />
<View className="benefit-coupon-notch benefit-coupon-notch--right" />
<View className="benefit-coupon-head">
<Text className="benefit-coupon-name">{r.storeName || '门店核销'}</Text>
<Text className="benefit-coupon-balance">-¥{formatMoney(Number(r.amount))}</Text>
</View>
<Text className="benefit-coupon-no">NO. {r.redeemNo}</Text>
<View className="benefit-coupon-footer">
<Text className="benefit-coupon-meta">
{r.createdAt ? String(r.createdAt).slice(0, 19).replace('T', ' ') : '-'}
</Text>
</View>
</View>
))
+12 -10
View File
@@ -110,16 +110,6 @@ export default function HomePage() {
});
}, []);
useDidShow(() => {
syncTabBarSelected(0);
void capturePromoSceneAndTouchScan();
void loadMiniHome();
void resolveUserCity().then((resolved) => {
setDisplayCity(resolved.displayCity);
setCityCode(getCityCodeForCatalog(resolved));
});
});
const loadProducts = useCallback(() => {
setLoading(true);
return request<Product[]>(`/catalog/products?cityCode=${encodeURIComponent(cityCode)}`)
@@ -130,6 +120,18 @@ export default function HomePage() {
.finally(() => setLoading(false));
}, [cityCode]);
useDidShow(() => {
syncTabBarSelected(0);
void capturePromoSceneAndTouchScan();
void loadMiniHome();
// 白名单商品按登录手机号过滤;登录后 switchTab 回首页不会卸载页面,须重新拉列表
void loadProducts();
void resolveUserCity().then((resolved) => {
setDisplayCity(resolved.displayCity);
setCityCode(getCityCodeForCatalog(resolved));
});
});
useEffect(() => {
void loadProducts();
}, [loadProducts]);
+4 -1
View File
@@ -221,7 +221,10 @@ export default function MinePage() {
/** 点击头像:绑定账号后用 chooseAvatar + nickname 获取头像昵称 */
async function handleAvatarTap() {
if (bindingWx || savingProfile) return;
if (bindingWx || savingProfile) {
toast(savingProfile ? '资料保存中…' : '请稍候…');
return;
}
if (!isLoggedIn()) {
goLogin('/pages/mine/index');
return;
@@ -7,7 +7,7 @@ import { goLogin } from '../../lib/auth-nav';
import { buildPayUrl } from '../../lib/checkout-nav';
import { ensurePayReady } from '../../lib/pay-ready';
import { fetchUserProfile } from '../../lib/pay-wechat';
import { request } from '../../lib/api';
import { request, toast } from '../../lib/api';
import { getProductMainImage } from '../../lib/product-images';
type PreviewProduct = {
@@ -75,7 +75,13 @@ export default function OrderConfirmPickupPage() {
const canSubmit = !!preview && quantityOk && !loading && !previewLoading;
function updateQuantity(next: number) {
if (next < minQty) return;
if (next < 1) return;
if (next < minQty) {
const tip = `现场提货至少购买 ${minQty}`;
toast(tip);
setMsg(tip);
return;
}
setQuantity(next);
}
@@ -168,6 +168,15 @@ 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));
return;
}
setQuantity(next);
}
@@ -211,11 +220,11 @@ export default function OrderConfirmPage() {
return;
}
if (!quantityOk) {
setMsg(
isCross
? `城配送至少购买 ${minQty}1箱)`
: `同城配送至少购买 ${minQty}`,
);
const tip = isCross
? `跨城配送至少购买 ${minQty} 瓶(1箱)`
: `城配送至少购买 ${minQty}`;
setMsg(tip);
toast(tip);
}
return;
}
@@ -180,6 +180,14 @@ export default function OrderDetailPage() {
<WechatShareReady payload={sharePayload} />
<SubPageHeader
title="订单详情"
onBack={() => {
const pages = Taro.getCurrentPages();
if (pages.length > 1) {
Taro.navigateBack();
return;
}
Taro.switchTab({ url: '/pages/home/index' });
}}
right={<ShareNavButton payload={sharePayload} />}
/>
<View className="sub-page-body">
+4 -1
View File
@@ -93,7 +93,10 @@ export default function OrdersPage() {
return (
<PageShell variant="sub" className="orders-page">
<SubPageHeader title="我的订单" />
<SubPageHeader
title="我的订单"
onBack={() => Taro.switchTab({ url: '/pages/home/index' })}
/>
<View className="order-tabs">
{TABS.map((t) => (
<Text
+3 -3
View File
@@ -144,10 +144,10 @@ export default function PayPage() {
toast('支付成功', 'success');
}
if (deliveryType === 'ON_SITE_PICKUP') {
// 现场提货支付即完成 → 订单详情(已完成)
Taro.redirectTo({ url: `/pages/order-detail/index?id=${orderId}` });
// 现场提货支付即完成 → 订单详情(已完成)reLaunch 清掉商品详情栈
Taro.reLaunch({ url: `/pages/order-detail/index?id=${orderId}` });
} else {
Taro.redirectTo({ url: '/pages/orders/index?tab=paid' });
Taro.reLaunch({ url: '/pages/orders/index?tab=paid' });
}
} catch (e) {
if (isWechatAuthRequiredError(e)) {
@@ -1,6 +1,12 @@
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, 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 type { ProductDetailContentDto } from '@dukang/shared-types';
import PageShell from '../../components/PageShell';
import PageNavBar from '../../components/PageNavBar';
@@ -51,13 +57,32 @@ export default function ProductDetailPage() {
setHeaderSolid(scrollTop > 100);
});
useEffect(() => {
const loadProduct = useCallback(() => {
if (!productId) return;
request<Product>(`/catalog/products/${productId}`)
.then((p) => setProduct(normalizeFulfillmentFlags(p)))
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
request<Product | null>(`/catalog/products/${productId}`)
.then((p) => {
if (!p) {
setProduct(null);
toast('商品不存在或暂未开放');
return;
}
setProduct(normalizeFulfillmentFlags(p));
})
.catch((e) => {
setProduct(null);
toast(e instanceof Error ? e.message : '加载失败');
});
}, [productId]);
useEffect(() => {
loadProduct();
}, [loadProduct]);
// 登录后返回详情须带 token 重拉,否则白名单商品会一直空白
useDidShow(() => {
loadProduct();
});
const sharePayload = useMemo(
() => ({
title: product?.name || DEFAULT_SHARE_TITLE,
@@ -1,8 +1,9 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import { request, toast } from '../../lib/api';
const LAST_REDEEM_RESULT_KEY = 'lastRedeemResult';
@@ -19,8 +20,38 @@ function formatMoney(amount: number) {
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function StarRating({
label,
value,
onChange,
}: {
label: string;
value: number;
onChange: (score: number) => void;
}) {
return (
<View className="redeem-rating-row">
<Text className="redeem-rating-label">{label}</Text>
<View className="redeem-star-row">
{[1, 2, 3, 4, 5].map((score) => (
<Text
key={score}
className={`redeem-star-btn${score <= value ? ' redeem-star-btn--active' : ''}`}
onClick={() => onChange(score)}
>
</Text>
))}
</View>
</View>
);
}
export default function RedeemSuccessPage() {
const router = useRouter();
const [serviceScore, setServiceScore] = useState(5);
const [envScore, setEnvScore] = useState(5);
const [loading, setLoading] = useState(false);
const record = useMemo<RedeemRecord | null>(() => {
try {
@@ -38,14 +69,49 @@ export default function RedeemSuccessPage() {
? new Date(record.createdAt).toLocaleString('zh-CN')
: new Date().toLocaleString('zh-CN');
function clearCache() {
try {
Taro.removeStorageSync(LAST_REDEEM_RESULT_KEY);
} catch {
/* ignore */
}
}
function goBenefit() {
Taro.removeStorageSync(LAST_REDEEM_RESULT_KEY);
clearCache();
Taro.switchTab({ url: '/pages/benefit/index' });
}
function goHome() {
clearCache();
Taro.switchTab({ url: '/pages/home/index' });
}
async function submitRatingAndFinish() {
setLoading(true);
try {
if (record?.id) {
await request('/redeem/ratings', {
method: 'POST',
data: {
redeemRecordId: record.id,
serviceScore,
envScore,
},
});
toast('评价已提交', 'success');
}
} catch {
/* 评价失败不阻塞返回 */
} finally {
setLoading(false);
goBenefit();
}
}
return (
<PageShell variant="sub" className="redeem-success-page">
<SubPageHeader title="核销成功" />
<SubPageHeader title="核销成功" onBack={goBenefit} />
<View className="sub-page-body">
<View className="redeem-success-icon">
<Text></Text>
@@ -69,14 +135,21 @@ export default function RedeemSuccessPage() {
</View>
</View>
<View className="redeem-submit" onClick={goBenefit}>
<Text></Text>
<View className="redeem-success-rating">
<Text className="redeem-success-rating-title"></Text>
<StarRating label="服务态度" value={serviceScore} onChange={setServiceScore} />
<StarRating label="用餐环境" value={envScore} onChange={setEnvScore} />
</View>
<View
className="u-btn u-btn--ghost u-btn--block"
style={{ margin: '0 20px', boxSizing: 'border-box' }}
onClick={() => Taro.switchTab({ url: '/pages/home/index' })}
className={`redeem-submit${loading ? ' redeem-submit--disabled' : ''}`}
onClick={() => {
if (!loading) void submitRatingAndFinish();
}}
>
<Text>{loading ? '提交中…' : '提交评价并返回'}</Text>
</View>
<View className="redeem-cancel-btn" onClick={goHome}>
<Text></Text>
</View>
</View>
+5 -3
View File
@@ -10,9 +10,10 @@
.mine-header {
position: relative;
z-index: 2;
padding: 16px var(--space-page) 44px;
background: linear-gradient(135deg, #820012 0%, var(--color-heritage-red) 40%, #d4a373 100%);
overflow: hidden;
overflow: visible;
}
.mine-header-texture {
@@ -33,11 +34,12 @@
position: relative;
display: flex;
align-items: center;
z-index: 1;
z-index: 3;
}
.mine-avatar-wrap {
position: relative;
z-index: 4;
flex-shrink: 0;
margin-right: 12px;
}
@@ -160,7 +162,7 @@
.mine-main {
margin-top: -28px;
position: relative;
z-index: 2;
z-index: 1;
padding: 0 var(--space-page) 0;
}
+43
View File
@@ -378,6 +378,49 @@
word-break: break-all;
}
.redeem-success-rating {
margin: 16px var(--space-page) 0;
padding: 16px;
background: var(--color-card);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
}
.redeem-success-rating-title {
display: block;
font-size: 15px;
font-weight: 700;
color: var(--color-on-surface);
margin-bottom: 12px;
}
.redeem-rating-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.redeem-rating-label {
font-size: 14px;
color: var(--color-on-surface);
}
.redeem-star-row {
display: flex;
gap: 6px;
}
.redeem-star-btn {
font-size: 22px;
color: #d0d0d0;
line-height: 1;
}
.redeem-star-btn--active {
color: #f5a623;
}
.ledger-item {
display: flex;
justify-content: space-between;
+1 -1
View File
@@ -55,7 +55,7 @@ export const MINI_USER_STATIC_OSS_BASE =
export const QUALIFICATION_DISCLOSURE_URL = `${MINI_USER_STATIC_OSS_BASE}qualification-disclosure.png`;
/** 总部客服电话(C 端联系客服) */
export const CUSTOMER_SERVICE_PHONE = '400-888-1234';
export const CUSTOMER_SERVICE_PHONE = '13203801799';
/**
* 企业微信「微信客服」链接(C 端「在线客服」;微信内网页点击后进入原生客服会话)
+1 -1
View File
@@ -70,7 +70,7 @@ export function createWeixinSdk(config: WeixinSdkConfig) {
getPhoneNumber: () => getWechatPhoneNumber(config),
getLocation: () => getWechatLocation(config),
getLocationDetailed: () => getWechatLocationDetailed(config),
scanQrCode: () => scanQrCode(config),
scanQrCode: (options?: Parameters<typeof scanQrCode>[1]) => scanQrCode(config, options),
chooseImages: (options?: Parameters<typeof chooseWechatImages>[1]) =>
chooseWechatImages(config, options),
pay: (prepay: Parameters<typeof invokeWechatPay>[0]) =>
+78 -14
View File
@@ -1,5 +1,10 @@
import { getRuntimePlatform, isIosDevice, isWechatDevTools } from './env';
import { ensureJssdkReady } from './jssdk';
import {
ensureJssdkReady,
getJssdkSignUrl,
initWechatJssdk,
resetJssdkConfig,
} from './jssdk';
import type { WeixinSdkConfig } from './types';
function delay(ms: number): Promise<void> {
@@ -11,7 +16,7 @@ export function formatScanFailMessage(errMsg: string): string {
const msg = errMsg.trim() || '扫码失败';
if (/cancel/i.test(msg)) return '';
// iOS 常见:JSSDK 权限离线校验尚未完成,并非系统相机权限
// iOS / 授权回调后常见:JSSDK 权限离线校验尚未完成,并非系统相机权限
if (/offline verifying|permission value is offline/i.test(msg)) {
return '微信权限验证中,请稍候再试或刷新页面后重新扫码';
}
@@ -33,6 +38,47 @@ export function formatScanFailMessage(errMsg: string): string {
return msg;
}
function isScanPermissionWarmupError(msg: string): boolean {
return /offline verifying|permission value is offline|权限验证中|接口未就绪|invalid signature|config:fail|signature/i.test(
msg,
);
}
function checkJsApi(apiName: string): Promise<boolean> {
return new Promise((resolve) => {
if (!window.wx?.checkJsApi) {
resolve(false);
return;
}
window.wx.checkJsApi({
jsApiList: [apiName],
success: (res) => resolve(!!res.checkResult?.[apiName]),
fail: () => resolve(false),
});
});
}
async function ensureScanJssdk(config: WeixinSdkConfig): Promise<void> {
const jssdkOptions = {
apiBase: config.apiBase ?? '/api/v1',
clientApp: config.clientApp,
getAccessToken: config.getAccessToken,
jsApiList: ['scanQRCode', 'checkJsApi'],
};
await ensureJssdkReady(jssdkOptions);
let ready = await checkJsApi('scanQRCode');
if (!ready) {
resetJssdkConfig();
await initWechatJssdk({ ...jssdkOptions, url: getJssdkSignUrl() });
ready = await checkJsApi('scanQRCode');
}
if (!ready) {
throw new Error('微信扫码接口未授权,请刷新页面后重试');
}
}
function invokeScanQrCode(): Promise<string | null> {
return new Promise((resolve, reject) => {
window.wx!.scanQRCode!({
@@ -52,8 +98,16 @@ function invokeScanQrCode(): Promise<string | null> {
});
}
/** 调起扫码(返回二维码/条码内容,失败返回 null) */
export async function scanQrCode(config: WeixinSdkConfig): Promise<string | null> {
export type ScanQrCodeOptions = {
/** OAuth / 首次绑定后权限离线校验更慢,加长预热 */
postAuthWarmup?: boolean;
};
/** 调起扫码(返回二维码/条码内容,取消返回 null) */
export async function scanQrCode(
config: WeixinSdkConfig,
options: ScanQrCodeOptions = {},
): Promise<string | null> {
const platform = getRuntimePlatform();
if (platform === 'mini' && window.wx?.scanCode) {
@@ -68,32 +122,42 @@ export async function scanQrCode(config: WeixinSdkConfig): Promise<string | null
}
if (platform === 'wechat-h5') {
await ensureJssdkReady({
const jssdkOptions = {
apiBase: config.apiBase ?? '/api/v1',
clientApp: config.clientApp,
getAccessToken: config.getAccessToken,
});
jsApiList: ['scanQRCode', 'checkJsApi'],
};
await ensureScanJssdk(config);
if (!window.wx?.scanQRCode) {
throw new Error('当前微信版本不支持扫码,请升级微信后重试');
}
// iOS 在 wx.ready 后立即调 scanQRCode 可能触发 offline verifying
if (isIosDevice() && !isWechatDevTools()) {
await delay(500);
}
// wx.ready ≠ 权限离线校验完成;授权回跳后更明显
const warmupMs = options.postAuthWarmup
? 1200
: isIosDevice() && !isWechatDevTools()
? 800
: 300;
await delay(warmupMs);
const maxAttempts = 2;
const maxAttempts = options.postAuthWarmup ? 3 : 2;
let lastError: Error | null = null;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (attempt > 0) {
await delay(800);
if (lastError && isScanPermissionWarmupError(lastError.message)) {
resetJssdkConfig();
await initWechatJssdk({ ...jssdkOptions, url: getJssdkSignUrl() });
await checkJsApi('scanQRCode');
}
await delay(700 + attempt * 500);
}
try {
return await invokeScanQrCode();
} catch (e) {
lastError = e instanceof Error ? e : new Error('扫码失败');
const raw = lastError.message;
const retryable = /offline verifying|权限验证中|接口未就绪/i.test(raw);
const retryable = isScanPermissionWarmupError(lastError.message);
if (attempt < maxAttempts - 1 && retryable) continue;
throw lastError;
}
@@ -7,6 +7,15 @@ import { JwtService } from '@nestjs/jwt';
import { CLIENT_APP_ACTOR_MAP, ClientApp } from '@dukang/shared-types';
import type { AuthUser } from './jwt-auth.guard';
/** C 端 H5 / 小程序同属 USER,目录白名单只认 actorId→手机号,允许端间 token 兼容 */
const USER_CLIENT_APPS = new Set<ClientApp>([ClientApp.USER_MINI, ClientApp.USER_H5]);
function clientAppCompatible(headerApp: ClientApp | undefined, payloadApp: unknown): boolean {
if (!headerApp || payloadApp == null) return false;
if (headerApp === payloadApp) return true;
return USER_CLIENT_APPS.has(headerApp) && USER_CLIENT_APPS.has(payloadApp as ClientApp);
}
@Injectable()
export class OptionalJwtAuthGuard implements CanActivate {
constructor(private readonly jwtService: JwtService) {}
@@ -20,7 +29,7 @@ export class OptionalJwtAuthGuard implements CanActivate {
try {
const payload = this.jwtService.verify(auth.slice(7));
const clientApp = req.headers['x-client-app'] as ClientApp;
if (!clientApp || payload.clientApp !== clientApp) return true;
if (!clientAppCompatible(clientApp, payload.clientApp)) return true;
const expectedActor = CLIENT_APP_ACTOR_MAP[clientApp];
if (payload.actorType !== expectedActor) return true;
req.user = {
@@ -119,7 +119,29 @@ export class BenefitService {
where: benefitLedgerWhere(userId, couponId),
orderBy: { createdAt: 'desc' },
});
return serializeBigInt(list);
return serializeBigInt(
list.map((e) => {
const type = e.param1 || '';
const amount = Number(e.amount1 ?? 0);
const title =
e.remark ||
(type === 'REDEEM'
? '门店核销'
: type === 'GRANT'
? '购酒入账'
: type === 'REFUND_VOID'
? '退款作废'
: '权益变动');
return {
id: e.id,
type,
amount,
title,
createdAt: e.createdAt,
balanceAfter: e.amount2 != null ? Number(e.amount2) : null,
};
}),
);
}
async getCoupon(userId: bigint, couponId: bigint) {
@@ -81,11 +81,9 @@ export class CatalogService {
return serializeBigInt(
visible.map((p) => {
const media = mapProductMedia(p, resourceMap.get(p.id.toString()) ?? []);
const { visibilityPhones, ...rest } = p;
const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = p;
return {
...rest,
visibilityWhitelistEnabled: p.visibilityWhitelistEnabled,
visibilityPhones: visibilityPhones.map((row) => row.phone),
benefitAmount: p.benefitAmount ?? p.price,
price: Number(p.price),
benefitDisplay: Number(p.benefitAmount ?? p.price),
@@ -119,11 +117,9 @@ export class CatalogService {
});
const media = mapProductMedia(product, resources);
const { visibilityPhones, ...rest } = product;
const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = product;
return serializeBigInt({
...rest,
visibilityWhitelistEnabled: product.visibilityWhitelistEnabled,
visibilityPhones: visibilityPhones.map((row) => row.phone),
benefitAmount: product.benefitAmount ?? product.price,
price: Number(product.price),
...media,
@@ -0,0 +1,15 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { AdminStoreRatingsService } from './admin-store-ratings.service';
import { AdminStoreRatingsQueryDto } from './dto/admin-query.dto';
@Controller('admin/store-ratings')
@UseGuards(HqAuthGuard)
export class AdminStoreRatingsController {
constructor(private readonly service: AdminStoreRatingsService) {}
@Get()
list(@Query() query: AdminStoreRatingsQueryDto) {
return this.service.list(query);
}
}
@@ -0,0 +1,58 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { AdminStoreRatingsQueryDto } from './dto/admin-query.dto';
@Injectable()
export class AdminStoreRatingsService {
constructor(private readonly prisma: PrismaService) {}
async list(query: AdminStoreRatingsQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.StoreRatingWhereInput = {};
if (query.storeId) where.storeId = BigInt(query.storeId);
if (query.redeemNo) {
where.redeemRecord = { redeemNo: { contains: query.redeemNo } };
}
const [items, total] = await Promise.all([
this.prisma.storeRating.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
store: { select: { id: true, name: true, cityName: true } },
redeemRecord: {
select: {
id: true,
redeemNo: true,
amount: true,
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
},
},
},
}),
this.prisma.storeRating.count({ where }),
]);
return serializeBigInt({
items: items.map((r) => ({
id: r.id,
serviceScore: r.serviceScore,
envScore: r.envScore,
createdAt: r.createdAt,
store: r.store,
redeemRecordId: r.redeemRecordId,
redeemNo: r.redeemRecord.redeemNo,
redeemAmount: Number(r.redeemRecord.amount),
user: r.redeemRecord.user,
})),
total,
page,
pageSize,
});
}
}
@@ -244,6 +244,16 @@ export class AdminRedeemRecordsQueryDto extends PaginationQueryDto {
userId?: string;
}
export class AdminStoreRatingsQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
storeId?: string;
@IsOptional()
@IsString()
redeemNo?: string;
}
export class AdminDeliveriesQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
@@ -23,6 +23,8 @@ import { AdminBenefitCouponsController, AdminBenefitLedgersController } from './
import { AdminBenefitService } from './admin-benefit.service';
import { AdminRedeemRecordsController, AdminDeliveriesController } from './admin-redeem.controller';
import { AdminRedeemService, AdminDeliveriesService } from './admin-redeem.service';
import { AdminStoreRatingsController } from './admin-store-ratings.controller';
import { AdminStoreRatingsService } from './admin-store-ratings.service';
import { AdminHqAccountsController } from './admin-hq-accounts.controller';
import { AdminHqAccountsService } from './admin-hq-accounts.service';
import { AdminProductsController } from './admin-products.controller';
@@ -90,6 +92,7 @@ import { AdminFulfillmentProvidersController } from './admin-fulfillment-provide
AdminBenefitCouponsController,
AdminBenefitLedgersController,
AdminRedeemRecordsController,
AdminStoreRatingsController,
AdminDeliveriesController,
AdminHqAccountsController,
AdminProductsController,
@@ -122,6 +125,7 @@ import { AdminFulfillmentProvidersController } from './admin-fulfillment-provide
AdminCitiesService,
AdminBenefitService,
AdminRedeemService,
AdminStoreRatingsService,
AdminDeliveriesService,
AdminHqAccountsService,
AdminProductsService,
@@ -36,6 +36,19 @@ export class UserRedeemController {
rating(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>) {
return this.redeemService.submitRating(user.actorId, body as never);
}
@Get('records')
records(
@CurrentUser() user: AuthUser,
@Query('page') page = '1',
@Query('pageSize') pageSize = '20',
) {
return this.redeemService.listUserRecords(
user.actorId,
Number(page),
Number(pageSize),
);
}
}
@Controller('shop/redeem')
@@ -1095,11 +1095,51 @@ export class RedeemService {
});
}
async listUserRecords(userId: bigint, page = 1, pageSize = 20) {
const take = Math.min(Math.max(pageSize, 1), 50);
const skip = (Math.max(page, 1) - 1) * take;
const [list, total] = await Promise.all([
this.prisma.redeemRecord.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
skip,
take,
include: {
store: { select: { id: true, name: true } },
rating: { select: { serviceScore: true, envScore: true } },
},
}),
this.prisma.redeemRecord.count({ where: { userId } }),
]);
return {
list: serializeBigInt(
list.map((r) => ({
id: r.id,
redeemNo: r.redeemNo,
amount: Number(r.amount),
storeId: r.storeId,
storeName: r.store?.name ?? '门店',
createdAt: r.createdAt,
rating: r.rating
? { serviceScore: r.rating.serviceScore, envScore: r.rating.envScore }
: null,
})),
),
total,
page: Math.max(page, 1),
pageSize: take,
};
}
async submitRating(userId: bigint, body: { redeemRecordId: string; serviceScore: number; envScore: number }) {
const record = await this.prisma.redeemRecord.findFirst({
where: { id: BigInt(body.redeemRecordId), userId },
});
if (!record) throw new NotFoundException('核销记录不存在');
const existing = await this.prisma.storeRating.findUnique({
where: { redeemRecordId: record.id },
});
if (existing) return serializeBigInt(existing);
const rating = await this.prisma.storeRating.create({
data: {
redeemRecordId: record.id,