Files
dukang/apps/h5-shop/src/pages/HomePage.tsx
T
jacy fc2e5b65de
CI / verify (pull_request) Has been cancelled
v3.5.3版本更新1
2026-08-20 18:54:15 +08:00

674 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useRef, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { isIosDevice, isScanPermissionWarmupError } from '@dukang/weixin-sdk';
import { useStoreSession } from '../contexts/StoreSessionContext';
import { request } from '../lib/api';
import { formatMoney, toMoneyNumber } from '../lib/money';
import { parseRedeemTokenFromScan } from '../lib/redeem-scan';
import {
authorizeShopWechat,
checkNeedsWechatAuth,
fetchShopAccount,
} from '../lib/wechat-auth';
import { isWechatEnv, weixinSdk } from '../lib/weixin';
import {
clearPendingScanAfterAuth,
consumeScanWarmupAfterAuth,
getPostAuthScanDelayMs,
markPendingScanAfterAuth,
peekPendingScanAfterAuth,
} from '../lib/shop-scan-auth';
import WechatScanAuthModal from '../components/WechatScanAuthModal';
import { useStorePageView } from '../lib/usePageView';
import { trackStore } from '../lib/analytics';
function formatScanError(e: unknown, opts?: { afterAuth?: boolean }): string {
const msg = e instanceof Error ? e.message : '扫码失败,请重试';
if (/invalid signature|config:fail|signature/i.test(msg)) {
return '微信扫码签名校验失败,请刷新页面或重新授权微信后重试';
}
if (isScanPermissionWarmupError(msg)) {
if (opts?.afterAuth) {
return '微信授权后扫码仍未就绪,请刷新页面或重新授权微信';
}
return '微信扫码能力未就绪,请刷新页面或重新授权微信';
}
return msg;
}
function isScanRecoverableError(msg: string): boolean {
return isScanPermissionWarmupError(msg) || /签名校验失败|扫码能力未就绪|请刷新页面/i.test(msg);
}
export default function HomePage() {
useStorePageView('store_home_view');
const navigate = useNavigate();
const { ready, authenticated } = useStoreSession();
const [searchParams] = useSearchParams();
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
const [scanMsg, setScanMsg] = useState('');
const [scanning, setScanning] = useState(false);
const [authModalOpen, setAuthModalOpen] = useState(false);
const [authModalMode, setAuthModalMode] = useState<'bind' | 'recover'>('bind');
const [authLoading, setAuthLoading] = useState(false);
const [authError, setAuthError] = useState('');
const [showOpenModal, setShowOpenModal] = useState(false);
const [opening, setOpening] = useState(false);
const pendingScanStartedRef = useRef(false);
const loadDashboard = useCallback(() => {
return request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard')
.then((d) => {
setDash(d);
})
.catch(() => {});
}, []);
useEffect(() => {
void loadDashboard();
}, [loadDashboard]);
useEffect(() => {
function onResume() {
setScanning(false);
void loadDashboard();
}
function onVisibility() {
if (document.visibilityState === 'visible') onResume();
}
document.addEventListener('visibilitychange', onVisibility);
window.addEventListener('pageshow', onResume);
window.addEventListener('focus', onResume);
return () => {
document.removeEventListener('visibilitychange', onVisibility);
window.removeEventListener('pageshow', onResume);
window.removeEventListener('focus', onResume);
};
}, [loadDashboard]);
const runScan = useCallback(
async (opts?: { postAuthWarmup?: boolean }) => {
trackStore('store_redeem_scan_start');
if (!isWechatEnv()) {
setScanMsg('请在微信内打开门店端进行扫码核销');
return;
}
setScanning(true);
if (!opts?.postAuthWarmup) {
setScanMsg('');
}
try {
// iOS / OAuth 回跳后须重新 wx.config(签名用入场 URL
if (opts?.postAuthWarmup || isIosDevice()) {
weixinSdk.reset();
}
await weixinSdk.init();
const raw = await weixinSdk.scanQrCode(
opts?.postAuthWarmup ? { postAuthWarmup: true } : undefined,
);
if (!raw) {
void loadDashboard();
return;
}
const token = parseRedeemTokenFromScan(raw);
if (!token) {
setScanMsg('无法识别核销码,请扫描用户出示的核销二维码');
return;
}
navigate(`/redeem?token=${encodeURIComponent(token)}`);
} catch (e) {
const tip = formatScanError(e, { afterAuth: opts?.postAuthWarmup });
setScanMsg(tip);
if (isScanRecoverableError(tip)) {
setAuthModalMode('recover');
setAuthError(tip);
setAuthModalOpen(true);
}
} finally {
setScanning(false);
}
},
[loadDashboard, navigate],
);
// OAuth 由 StoreSessionContext 单例处理;此处仅在授权完成后续扫
useEffect(() => {
if (!ready || !authenticated || !isWechatEnv()) return;
if (searchParams.get('code')) return;
if (!peekPendingScanAfterAuth() || pendingScanStartedRef.current) return;
pendingScanStartedRef.current = true;
clearPendingScanAfterAuth();
setAuthModalOpen(false);
setAuthLoading(false);
setAuthError('');
setScanMsg('微信授权成功,正在准备扫码…');
const timer = window.setTimeout(() => {
void runScan({ postAuthWarmup: true });
}, getPostAuthScanDelayMs());
return () => window.clearTimeout(timer);
}, [ready, authenticated, searchParams, runScan]);
async function handleScan() {
setScanMsg('');
// 门店临时闭店/休息中(非营业状态)时,点击扫码直接提示,不进入扫码流程
if (status !== 'OPEN') {
setShowOpenModal(true);
return;
}
if (!isWechatEnv()) {
setScanMsg('请在微信内打开门店端进行扫码核销');
return;
}
try {
const profile = await fetchShopAccount();
if (await checkNeedsWechatAuth(profile)) {
pendingScanStartedRef.current = false;
setAuthModalMode('bind');
setAuthError('');
setAuthModalOpen(true);
return;
}
const needWarmup = consumeScanWarmupAfterAuth();
await runScan(needWarmup ? { postAuthWarmup: true } : undefined);
} catch (e) {
setScanMsg(e instanceof Error ? e.message : '无法发起扫码');
}
}
async function openStoreAndContinue() {
if (opening) return;
setOpening(true);
try {
await request('SHOP_H5', '/shop/store/status', {
method: 'PUT',
body: JSON.stringify({ status: 'OPEN' }),
});
setShowOpenModal(false);
setScanMsg('');
void loadDashboard(); // 刷新门店状态为营业中,扫码按钮可再次使用
} catch (e) {
setShowOpenModal(false);
setScanMsg(e instanceof Error ? e.message : '开启营业失败');
} finally {
setOpening(false);
}
}
async function startWechatAuth() {
setAuthLoading(true);
setAuthError('');
try {
pendingScanStartedRef.current = false;
markPendingScanAfterAuth();
await authorizeShopWechat();
} catch (e) {
clearPendingScanAfterAuth();
setAuthError(e instanceof Error ? e.message : '微信授权失败');
setAuthLoading(false);
}
}
const store = dash?.store as Record<string, unknown> | undefined;
const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || [];
const status = String(store?.status || '');
const open = status === 'OPEN';
const hoursParts: string[] = [];
if (store?.openTime && store?.closeTime) hoursParts.push(`${store.openTime} - ${store.closeTime}`);
if (store?.openTime2 && store?.closeTime2) hoursParts.push(`${store.openTime2} - ${store.closeTime2}`);
const hoursText = hoursParts.length ? hoursParts.join('') : '10:00 - 22:00';
const statusText =
status === 'CLOSED' ? '永久关闭' : open ? '当前正在营业中' : '当前临时闭店';
return (
<PullToRefresh onRefresh={loadDashboard} className="shop-home-page">
<header className="shop-home-header">
<h1 className="app-page-title">门店管理中心</h1>
</header>
<div className="shop-home-content">
<section className="shop-home-hero">
<div className="shop-home-hero-store">
<span className="material-symbols-outlined shop-fill-icon">store</span>
<h2>{String(store?.name || '门店')}</h2>
</div>
<div className="shop-home-stats">
<div className="shop-home-stat">
<p className="shop-home-stat-label">今日核销笔数</p>
<p className="shop-home-stat-value">{Number(dash?.todayCount || 0)}</p>
<p className="shop-home-stat-sub">
扫码 {Number(dash?.todayScanCount || 0)} · 手机号 {Number(dash?.todayPhoneCount || 0)}
</p>
</div>
<div className="shop-home-stat">
<p className="shop-home-stat-label">今日到账金额</p>
<p className="shop-home-stat-value">
<span style={{ fontSize: 18 }}>¥</span>
{formatMoney(toMoneyNumber(dash?.todayAmount))}
</p>
</div>
</div>
</section>
<section className="shop-home-scan">
<button
type="button"
className="shop-home-scan-btn"
disabled={scanning}
onClick={() => void handleScan()}
>
<span className="material-symbols-outlined">qr_code_scanner</span>
</button>
<p className="shop-home-scan-label">{scanning ? '正在打开相机…' : '扫码核销'}</p>
{scanMsg && <p className="shop-home-scan-msg" role="alert">{scanMsg}</p>}
<Link to="/redeem/phone" className="shop-home-phone-link">
<span className="material-symbols-outlined">smartphone</span>
手机号核销
</Link>
</section>
<section className="shop-home-status">
<div className="shop-home-status-left">
<div className={`shop-home-status-icon${open ? '' : ' closed'}`}>
<span className="material-symbols-outlined shop-fill-icon">schedule</span>
</div>
<div>
<p className="shop-home-status-title">营业状态</p>
<p className="shop-home-status-sub">{statusText}</p>
<p className="shop-home-status-sub">营业时间: {hoursText}</p>
</div>
</div>
<label className="shop-home-switch" onClick={() => navigate('/status')}>
<input type="checkbox" checked={open && status !== 'CLOSED'} readOnly tabIndex={-1} />
<span className="shop-home-switch-track" />
</label>
</section>
<section>
<div className="shop-home-records-head">
<h3 className="shop-home-records-title">核销记录</h3>
<Link to="/records" className="shop-home-records-link">
查看全部
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>chevron_right</span>
</Link>
</div>
<div className="shop-home-record-list">
{recent.length === 0 && (
<p className="shop-home-status-sub" style={{ textAlign: 'center', padding: '16px 0' }}>暂无核销记录</p>
)}
{recent.map((r) => (
<div key={String(r.id)} className="shop-home-record-item">
<div>
<p className="shop-home-record-time">核销时间</p>
<p className="shop-home-record-value">
{new Date(String(r.createdAt)).toLocaleString('zh-CN')}
</p>
</div>
<p className="shop-home-record-amount">¥{formatMoney(toMoneyNumber(r.amount))}</p>
</div>
))}
</div>
</section>
</div>
<WechatScanAuthModal
open={authModalOpen}
mode={authModalMode}
loading={authLoading}
error={authError}
onAuthorize={() => void startWechatAuth()}
onRefresh={() => {
window.location.reload();
}}
onCancel={() => {
setAuthModalOpen(false);
setAuthModalMode('bind');
setAuthError('');
clearPendingScanAfterAuth();
pendingScanStartedRef.current = false;
}}
/>
{showOpenModal && (
<div className="shop-redeem-modal" role="dialog" aria-modal="true">
<div className="shop-redeem-modal-card">
<h4 className="shop-redeem-modal-title">门店休息中</h4>
<p className="shop-redeem-modal-desc">门店目前休息中无法核销,是否开启营业?</p>
<div className="shop-redeem-modal-actions">
<button
type="button"
className="shop-redeem-modal-cancel"
disabled={opening}
onClick={() => setShowOpenModal(false)}
>
取消
</button>
<button
type="button"
className="shop-redeem-modal-confirm"
disabled={opening}
onClick={() => void openStoreAndContinue()}
>
{opening ? '开启中…' : '确认开启'}
</button>
</div>
</div>
</div>
)}
</PullToRefresh>
);
}