diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx
index 6d653a4..1ab9e14 100644
--- a/apps/admin-web/src/App.tsx
+++ b/apps/admin-web/src/App.tsx
@@ -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() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx
index 2fd6e51..8406a2a 100644
--- a/apps/admin-web/src/layouts/AdminLayout.tsx
+++ b/apps/admin-web/src/layouts/AdminLayout.tsx
@@ -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',
diff --git a/apps/admin-web/src/pages/StoreRatingsPage.tsx b/apps/admin-web/src/pages/StoreRatingsPage.tsx
new file mode 100644
index 0000000..ab7a6a3
--- /dev/null
+++ b/apps/admin-web/src/pages/StoreRatingsPage.tsx
@@ -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>({
+ ...(initialStoreId ? { storeId: initialStoreId } : {}),
+ });
+
+ const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList(
+ '/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 = [
+ { 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 (
+
+
+ 门店评价
+
+
+
+
+
+
+
+
+
+
+
+
{
+ setPage(p);
+ setPageSize(ps);
+ },
+ }}
+ />
+
+ );
+}
diff --git a/apps/admin-web/src/pages/StoresPage.tsx b/apps/admin-web/src/pages/StoresPage.tsx
index e1782e9..36f385a 100644
--- a/apps/admin-web/src/pages/StoresPage.tsx
+++ b/apps/admin-web/src/pages/StoresPage.tsx
@@ -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) => (
-
+
+
+
+
),
},
];
diff --git a/apps/h5-shop/src/pages/HomePage.tsx b/apps/h5-shop/src/pages/HomePage.tsx
index a09ca35..66a2fab 100644
--- a/apps/h5-shop/src/pages/HomePage.tsx
+++ b/apps/h5-shop/src/pages/HomePage.tsx
@@ -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) => {
diff --git a/apps/mini-user/src/pages/benefit/index.tsx b/apps/mini-user/src/pages/benefit/index.tsx
index df102b1..85e034c 100644
--- a/apps/mini-user/src/pages/benefit/index.tsx
+++ b/apps/mini-user/src/pages/benefit/index.tsx
@@ -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(null);
const [coupons, setCoupons] = useState([]);
+ const [redeemHistory, setRedeemHistory] = useState([]);
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('/benefit/summary'),
request('/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() {
郑州市
-
-
-
@@ -180,26 +191,26 @@ export default function BenefitPage() {
- {visible.length === 0 ? (
- {tab === 'available' ? '暂无可用权益' : '暂无历史记录'}
- ) : (
- visible.map((c) => (
-
-
-
-
- {c.sourceProduct || '好客权益'}
- ¥{formatMoney(c.balance)}
-
- NO. {c.couponNo}
-
-
-
-
-
- 已用 ¥{formatMoney(c.usedAmount)} / 总额 ¥{formatMoney(c.totalAmount)}
-
- {tab === 'available' ? (
+ {tab === 'available' ? (
+ available.length === 0 ? (
+ 暂无可用权益
+ ) : (
+ available.map((c) => (
+
+
+
+
+ {c.sourceProduct || '好客权益'}
+ ¥{formatMoney(c.balance)}
+
+ NO. {c.couponNo}
+
+
+
+
+
+ 已用 ¥{formatMoney(c.usedAmount)} / 总额 ¥{formatMoney(c.totalAmount)}
+
@@ -210,7 +221,26 @@ export default function BenefitPage() {
>
立即核销
- ) : null}
+
+
+ ))
+ )
+ ) : redeemHistory.length === 0 ? (
+ 暂无核销记录
+ ) : (
+ redeemHistory.map((r) => (
+
+
+
+
+ {r.storeName || '门店核销'}
+ -¥{formatMoney(Number(r.amount))}
+
+ NO. {r.redeemNo}
+
+
+ {r.createdAt ? String(r.createdAt).slice(0, 19).replace('T', ' ') : '-'}
+
))
diff --git a/apps/mini-user/src/pages/home/index.tsx b/apps/mini-user/src/pages/home/index.tsx
index dd9c7e5..a4ef76d 100644
--- a/apps/mini-user/src/pages/home/index.tsx
+++ b/apps/mini-user/src/pages/home/index.tsx
@@ -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(`/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]);
diff --git a/apps/mini-user/src/pages/mine/index.tsx b/apps/mini-user/src/pages/mine/index.tsx
index f359681..397e29d 100644
--- a/apps/mini-user/src/pages/mine/index.tsx
+++ b/apps/mini-user/src/pages/mine/index.tsx
@@ -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;
diff --git a/apps/mini-user/src/pages/order-confirm-pickup/index.tsx b/apps/mini-user/src/pages/order-confirm-pickup/index.tsx
index 3704d0c..9d187e8 100644
--- a/apps/mini-user/src/pages/order-confirm-pickup/index.tsx
+++ b/apps/mini-user/src/pages/order-confirm-pickup/index.tsx
@@ -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);
}
diff --git a/apps/mini-user/src/pages/order-confirm/index.tsx b/apps/mini-user/src/pages/order-confirm/index.tsx
index 2e665d0..1195316 100644
--- a/apps/mini-user/src/pages/order-confirm/index.tsx
+++ b/apps/mini-user/src/pages/order-confirm/index.tsx
@@ -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;
}
diff --git a/apps/mini-user/src/pages/order-detail/index.tsx b/apps/mini-user/src/pages/order-detail/index.tsx
index 5744d6e..83cb554 100644
--- a/apps/mini-user/src/pages/order-detail/index.tsx
+++ b/apps/mini-user/src/pages/order-detail/index.tsx
@@ -180,6 +180,14 @@ export default function OrderDetailPage() {
{
+ const pages = Taro.getCurrentPages();
+ if (pages.length > 1) {
+ Taro.navigateBack();
+ return;
+ }
+ Taro.switchTab({ url: '/pages/home/index' });
+ }}
right={}
/>
diff --git a/apps/mini-user/src/pages/orders/index.tsx b/apps/mini-user/src/pages/orders/index.tsx
index b1d8928..0a87cf5 100644
--- a/apps/mini-user/src/pages/orders/index.tsx
+++ b/apps/mini-user/src/pages/orders/index.tsx
@@ -93,7 +93,10 @@ export default function OrdersPage() {
return (
-
+ Taro.switchTab({ url: '/pages/home/index' })}
+ />
{TABS.map((t) => (
100);
});
- useEffect(() => {
+ const loadProduct = useCallback(() => {
if (!productId) return;
- request(`/catalog/products/${productId}`)
- .then((p) => setProduct(normalizeFulfillmentFlags(p)))
- .catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
+ request(`/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,
diff --git a/apps/mini-user/src/pages/redeem-success/index.tsx b/apps/mini-user/src/pages/redeem-success/index.tsx
index 024be82..d7b07f9 100644
--- a/apps/mini-user/src/pages/redeem-success/index.tsx
+++ b/apps/mini-user/src/pages/redeem-success/index.tsx
@@ -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 (
+
+ {label}
+
+ {[1, 2, 3, 4, 5].map((score) => (
+ onChange(score)}
+ >
+ ★
+
+ ))}
+
+
+ );
+}
+
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(() => {
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 (
-
+
✓
@@ -69,14 +135,21 @@ export default function RedeemSuccessPage() {
-
- 返回权益
+
+ 为门店服务评分
+
+
+
Taro.switchTab({ url: '/pages/home/index' })}
+ className={`redeem-submit${loading ? ' redeem-submit--disabled' : ''}`}
+ onClick={() => {
+ if (!loading) void submitRatingAndFinish();
+ }}
>
+ {loading ? '提交中…' : '提交评价并返回'}
+
+
回到首页
diff --git a/apps/mini-user/src/styles/mine.css b/apps/mini-user/src/styles/mine.css
index 06ae44d..8d12e83 100644
--- a/apps/mini-user/src/styles/mine.css
+++ b/apps/mini-user/src/styles/mine.css
@@ -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;
}
diff --git a/apps/mini-user/src/styles/redeem.css b/apps/mini-user/src/styles/redeem.css
index 3995378..a6d1891 100644
--- a/apps/mini-user/src/styles/redeem.css
+++ b/apps/mini-user/src/styles/redeem.css
@@ -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;
diff --git a/packages/shared-types/src/config.ts b/packages/shared-types/src/config.ts
index f94de98..1b8b658 100644
--- a/packages/shared-types/src/config.ts
+++ b/packages/shared-types/src/config.ts
@@ -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 端「在线客服」;微信内网页点击后进入原生客服会话)
diff --git a/packages/weixin-sdk/src/index.ts b/packages/weixin-sdk/src/index.ts
index 0dcbd2d..eeffa6a 100644
--- a/packages/weixin-sdk/src/index.ts
+++ b/packages/weixin-sdk/src/index.ts
@@ -70,7 +70,7 @@ export function createWeixinSdk(config: WeixinSdkConfig) {
getPhoneNumber: () => getWechatPhoneNumber(config),
getLocation: () => getWechatLocation(config),
getLocationDetailed: () => getWechatLocationDetailed(config),
- scanQrCode: () => scanQrCode(config),
+ scanQrCode: (options?: Parameters[1]) => scanQrCode(config, options),
chooseImages: (options?: Parameters[1]) =>
chooseWechatImages(config, options),
pay: (prepay: Parameters[0]) =>
diff --git a/packages/weixin-sdk/src/scan.ts b/packages/weixin-sdk/src/scan.ts
index e14c6c3..026c0ad 100644
--- a/packages/weixin-sdk/src/scan.ts
+++ b/packages/weixin-sdk/src/scan.ts
@@ -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 {
@@ -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 {
+ 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 {
+ 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 {
return new Promise((resolve, reject) => {
window.wx!.scanQRCode!({
@@ -52,8 +98,16 @@ function invokeScanQrCode(): Promise {
});
}
-/** 调起扫码(返回二维码/条码内容,失败返回 null) */
-export async function scanQrCode(config: WeixinSdkConfig): Promise {
+export type ScanQrCodeOptions = {
+ /** OAuth / 首次绑定后权限离线校验更慢,加长预热 */
+ postAuthWarmup?: boolean;
+};
+
+/** 调起扫码(返回二维码/条码内容,取消返回 null) */
+export async function scanQrCode(
+ config: WeixinSdkConfig,
+ options: ScanQrCodeOptions = {},
+): Promise {
const platform = getRuntimePlatform();
if (platform === 'mini' && window.wx?.scanCode) {
@@ -68,32 +122,42 @@ export async function scanQrCode(config: WeixinSdkConfig): Promise 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;
}
diff --git a/server/dukang-api/src/common/guards/optional-jwt-auth.guard.ts b/server/dukang-api/src/common/guards/optional-jwt-auth.guard.ts
index 1eb5103..2beb65a 100644
--- a/server/dukang-api/src/common/guards/optional-jwt-auth.guard.ts
+++ b/server/dukang-api/src/common/guards/optional-jwt-auth.guard.ts
@@ -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.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 = {
diff --git a/server/dukang-api/src/modules/benefit/benefit.service.ts b/server/dukang-api/src/modules/benefit/benefit.service.ts
index 50b5a79..46e7752 100644
--- a/server/dukang-api/src/modules/benefit/benefit.service.ts
+++ b/server/dukang-api/src/modules/benefit/benefit.service.ts
@@ -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) {
diff --git a/server/dukang-api/src/modules/catalog/catalog.service.ts b/server/dukang-api/src/modules/catalog/catalog.service.ts
index 2430397..c64b9c7 100644
--- a/server/dukang-api/src/modules/catalog/catalog.service.ts
+++ b/server/dukang-api/src/modules/catalog/catalog.service.ts
@@ -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,
diff --git a/server/dukang-api/src/modules/ops/admin-store-ratings.controller.ts b/server/dukang-api/src/modules/ops/admin-store-ratings.controller.ts
new file mode 100644
index 0000000..4e8208f
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-store-ratings.controller.ts
@@ -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);
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/admin-store-ratings.service.ts b/server/dukang-api/src/modules/ops/admin-store-ratings.service.ts
new file mode 100644
index 0000000..2def44d
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-store-ratings.service.ts
@@ -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,
+ });
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts
index c2da8e2..2e1c213 100644
--- a/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts
+++ b/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts
@@ -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()
diff --git a/server/dukang-api/src/modules/ops/ops.module.ts b/server/dukang-api/src/modules/ops/ops.module.ts
index 2b78686..26bf70f 100644
--- a/server/dukang-api/src/modules/ops/ops.module.ts
+++ b/server/dukang-api/src/modules/ops/ops.module.ts
@@ -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,
diff --git a/server/dukang-api/src/modules/redeem/redeem.controller.ts b/server/dukang-api/src/modules/redeem/redeem.controller.ts
index 4db8803..d85cfb8 100644
--- a/server/dukang-api/src/modules/redeem/redeem.controller.ts
+++ b/server/dukang-api/src/modules/redeem/redeem.controller.ts
@@ -36,6 +36,19 @@ export class UserRedeemController {
rating(@CurrentUser() user: AuthUser, @Body() body: Record) {
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')
diff --git a/server/dukang-api/src/modules/redeem/redeem.service.ts b/server/dukang-api/src/modules/redeem/redeem.service.ts
index 6944d82..f485792 100644
--- a/server/dukang-api/src/modules/redeem/redeem.service.ts
+++ b/server/dukang-api/src/modules/redeem/redeem.service.ts
@@ -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,