674 lines
14 KiB
TypeScript
674 lines
14 KiB
TypeScript
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>
|
||
|
||
);
|
||
|
||
}
|
||
|
||
|