This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 端「在线客服」;微信内网页点击后进入原生客服会话)
|
||||||
|
|||||||
@@ -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]) =>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user