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