Files
dukang/apps/h5-shop/src/pages/HomePage.tsx
T
2026-07-15 20:37:40 +08:00

266 lines
9.4 KiB
TypeScript

import { useCallback, useEffect, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useStoreSession } from '../contexts/StoreSessionContext';
import { request } from '../lib/api';
import { parseRedeemTokenFromScan } from '../lib/redeem-scan';
import {
authorizeShopWechat,
checkNeedsWechatAuth,
fetchShopAccount,
handleShopWechatCallback,
handleShopWechatLoginResult,
} from '../lib/wechat-auth';
import { isWechatEnv, weixinSdk } from '../lib/weixin';
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
import WechatScanAuthModal from '../components/WechatScanAuthModal';
const PENDING_SCAN_KEY = 'shop_pending_scan';
function formatMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
}
function formatScanError(e: unknown): string {
const msg = e instanceof Error ? e.message : '扫码失败,请重试';
if (/invalid signature/i.test(msg)) {
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名为 user.runxian.top,并刷新页面后重试';
}
if (/offline verifying|权限验证中|接口未就绪/i.test(msg)) {
return `${msg}(可在 URL 后加 ?wxdebug=1 开启 JSSDK 调试查看详情)`;
}
return msg;
}
export default function HomePage() {
const navigate = useNavigate();
const { applySession } = useStoreSession();
const [searchParams, setSearchParams] = useSearchParams();
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
const [open, setOpen] = useState(true);
const [scanMsg, setScanMsg] = useState('');
const [scanning, setScanning] = useState(false);
const [authModalOpen, setAuthModalOpen] = useState(false);
const [authLoading, setAuthLoading] = useState(false);
const [authError, setAuthError] = useState('');
const loadDashboard = useCallback(() => {
return request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard')
.then((d) => {
setDash(d);
setOpen(String((d.store as Record<string, unknown>)?.status) === 'OPEN');
})
.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]);
useEffect(() => {
if (!isWechatEnv() || !searchParams.get('code')) return;
void handleShopWechatCallback()
.then((result) => {
if (!result) return;
const session = handleShopWechatLoginResult(result);
if (session) {
applySession(session);
}
setAuthModalOpen(false);
setAuthError('');
stripOAuthParamsFromLocation();
setSearchParams({}, { replace: true });
const shouldScan = sessionStorage.getItem(PENDING_SCAN_KEY) === '1';
sessionStorage.removeItem(PENDING_SCAN_KEY);
if (shouldScan) {
window.setTimeout(() => void runScan(), 0);
}
})
.catch((e) => {
setAuthError(e instanceof Error ? e.message : '微信授权失败');
});
}, [searchParams, applySession, setSearchParams]);
async function runScan() {
if (!isWechatEnv()) {
setScanMsg('请在微信内打开门店端进行扫码核销');
return;
}
setScanning(true);
setScanMsg('');
try {
await weixinSdk.init();
const raw = await weixinSdk.scanQrCode();
if (!raw) {
void loadDashboard();
return;
}
const token = parseRedeemTokenFromScan(raw);
if (!token) {
setScanMsg('无法识别核销码,请扫描用户出示的核销二维码');
return;
}
navigate(`/redeem?token=${encodeURIComponent(token)}`);
} catch (e) {
setScanMsg(formatScanError(e));
} finally {
setScanning(false);
}
}
async function handleScan() {
setScanMsg('');
if (!isWechatEnv()) {
setScanMsg('请在微信内打开门店端进行扫码核销');
return;
}
try {
const profile = await fetchShopAccount();
if (await checkNeedsWechatAuth(profile)) {
setAuthModalOpen(true);
return;
}
await runScan();
} catch (e) {
setScanMsg(e instanceof Error ? e.message : '无法发起扫码');
}
}
async function startWechatAuth() {
setAuthLoading(true);
setAuthError('');
try {
sessionStorage.setItem(PENDING_SCAN_KEY, '1');
await authorizeShopWechat();
} catch (e) {
sessionStorage.removeItem(PENDING_SCAN_KEY);
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 openTime = String(store?.openTime || '10:00');
const closeTime = String(store?.closeTime || '22:00');
return (
<div 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>
</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(Number(dash?.todayAmount || 0))}
</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">{open ? '当前正在营业中' : '当前已停止营业'}</p>
<p className="shop-home-status-sub">营业时间: {openTime} - {closeTime}</p>
</div>
</div>
<label className="shop-home-switch" onClick={() => navigate('/status')}>
<input type="checkbox" checked={open} 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(Number(r.amount))}</p>
</div>
))}
</div>
</section>
</div>
<WechatScanAuthModal
open={authModalOpen}
loading={authLoading}
error={authError}
onAuthorize={() => void startWechatAuth()}
onCancel={() => {
setAuthModalOpen(false);
setAuthError('');
sessionStorage.removeItem(PENDING_SCAN_KEY);
}}
/>
</div>
);
}