feat: multi-module iteration

This commit is contained in:
2026-08-04 21:38:49 +08:00
parent 9d96c73246
commit 71f508e02b
1366 changed files with 202004 additions and 0 deletions
+285
View File
@@ -0,0 +1,285 @@
import { useCallback, useEffect, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
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';
import { useStorePageView } from '../lib/usePageView';
import { trackStore } from '../lib/analytics';
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 接口安全域名,并刷新页面后重试';
}
if (/offline verifying|权限验证中|接口未就绪/i.test(msg)) {
return '微信权限校验尚未完成,请等待 1~2 秒后再次点击扫码(首次绑定微信时较常见)';
}
return msg;
}
export default function HomePage() {
useStorePageView('store_home_view');
const navigate = useNavigate();
const { applySession } = useStoreSession();
const [searchParams, setSearchParams] = 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 [authLoading, setAuthLoading] = useState(false);
const [authError, setAuthError] = useState('');
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]);
async function runScan(opts?: { postAuthWarmup?: boolean }) {
trackStore('store_redeem_scan_start');
if (!isWechatEnv()) {
setScanMsg('请在微信内打开门店端进行扫码核销');
return;
}
setScanning(true);
setScanMsg('');
try {
// 授权回跳后强制重签,避免沿用带 code 的旧签名态
if (opts?.postAuthWarmup) {
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) {
setScanMsg(formatScanError(e));
} finally {
setScanning(false);
}
}
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('');
// 先清 OAuth 参数再扫,保证 JSSDK 签名 URL 与当前页一致
stripOAuthParamsFromLocation();
setSearchParams({}, { replace: true });
const shouldScan = sessionStorage.getItem(PENDING_SCAN_KEY) === '1';
sessionStorage.removeItem(PENDING_SCAN_KEY);
if (shouldScan) {
// OAuth 回跳后微信权限离线校验未完成;稍候再扫,失败可再点一次
window.setTimeout(() => void runScan({ postAuthWarmup: true }), 400);
}
})
.catch((e) => {
setAuthError(e instanceof Error ? e.message : '微信授权失败');
});
}, [searchParams, applySession, setSearchParams]);
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 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(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">{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(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);
}}
/>
</PullToRefresh>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { Link } from 'react-router-dom';
import { getLegalDocument, type LegalDocument } from '@dukang/shared-types';
type LegalPageProps = {
docId: LegalDocument['id'];
/** 返回登录页的路径,如 /login */
backTo?: string;
};
/** H5 各端共用的协议/隐私正文页 */
export default function LegalPage({ docId, backTo = '/login' }: LegalPageProps) {
const doc = getLegalDocument(docId);
return (
<div className="legal-h5-page">
<header className="legal-h5-header">
<Link to={backTo} className="legal-h5-back" aria-label="返回">
</Link>
<h1 className="legal-h5-title">{doc.title}</h1>
</header>
<main className="legal-h5-body">
<p className="legal-h5-updated">{doc.updatedAt}</p>
<p className="legal-h5-intro">{doc.intro}</p>
{doc.sections.map((section) => (
<section key={section.heading} className="legal-h5-section">
<h2>{section.heading}</h2>
{section.paragraphs.map((p, i) => (
<p key={`${section.heading}-${i}`}>{p}</p>
))}
</section>
))}
</main>
</div>
);
}
+371
View File
@@ -0,0 +1,371 @@
import { useEffect, useRef, useState, type RefObject } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import AppImage from '@dukang/shared-ui/AppImage';
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
import { useStoreSession } from '../contexts/StoreSessionContext';
import {
getLastPhone,
getStoreProfile,
hasShopWxSession,
request,
saveRememberedSession,
type ShopSessionPayload,
} from '../lib/api';
import { routeAfterShopLogin } from './SelectStorePage';
import {
bindShopWechatAfterSmsLogin,
consumeShopWechatLoginHint,
fetchClientConfig,
formatShopWechatError,
handleShopWechatCallback,
handleShopWechatLoginResult,
loginShopWithWechat,
} from '../lib/wechat-auth';
import { isWechatEnv } from '../lib/weixin';
function maskPhone(phone: string) {
if (phone.length < 7) return phone;
return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`;
}
function ShopAgreementCheckbox({
agreed,
onChange,
labelRef,
}: {
agreed: boolean;
onChange: (next: boolean) => void;
labelRef?: RefObject<HTMLLabelElement>;
}) {
return (
<label className="shop-login-agreement" ref={labelRef}>
<input
type="checkbox"
checked={agreed}
onChange={(e) => onChange(e.target.checked)}
/>
<span>
<Link to="/legal/user-agreement" onClick={(e) => e.stopPropagation()}>
</Link>
<Link to="/legal/privacy-policy" onClick={(e) => e.stopPropagation()}>
</Link>
</span>
</label>
);
}
export default function LoginPage() {
const navigate = useNavigate();
const { applySession } = useStoreSession();
const [params, setSearchParams] = useSearchParams();
const quick = params.get('quick') === '1';
const savedProfile = getStoreProfile();
const [phone, setPhone] = useState(getLastPhone());
const [code, setCode] = useState('');
const [agreed, setAgreed] = useState(false);
const [loading, setLoading] = useState(false);
const [wxLoading, setWxLoading] = useState(false);
const [msg, setMsg] = useState(() => consumeShopWechatLoginHint() ?? '');
const [codeCooldown, setCodeCooldown] = useState(0);
const [wxAuthorize, setWxAuthorize] = useState(false);
const agreementRef = useRef<HTMLLabelElement>(null);
useEffect(() => {
fetchClientConfig()
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
.catch(() => setWxAuthorize(false));
}, []);
useEffect(() => {
const hint = consumeShopWechatLoginHint();
if (hint) setMsg(hint);
}, []);
useEffect(() => {
if (!isWechatEnv() || !wxAuthorize || !params.get('code')) return;
void handleShopWechatCallback()
.then((result) => {
if (!result) return;
const session = handleShopWechatLoginResult(result);
if (session) {
applySession(session);
stripOAuthParamsFromLocation();
setSearchParams({}, { replace: true });
routeAfterShopLogin(session, navigate);
}
})
.catch((e) => setMsg(formatShopWechatError(e)));
}, [applySession, navigate, params, setSearchParams, wxAuthorize]);
const quickStoreName = savedProfile?.storeName ?? '门店管理中心';
const quickPhone = savedProfile?.phone || phone;
function ensureAgreed() {
if (!agreed) {
setMsg('请先阅读并同意用户协议');
agreementRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
return false;
}
return true;
}
async function sendCode() {
if (!ensureAgreed()) return;
setMsg('');
try {
await request('SHOP_H5', '/shop/auth/sms/send', {
method: 'POST',
body: JSON.stringify({ phone, scene: 'STORE_LOGIN' }),
});
setMsg('验证码已发送');
setCodeCooldown(60);
const timer = setInterval(() => {
setCodeCooldown((c) => {
if (c <= 1) {
clearInterval(timer);
return 0;
}
return c - 1;
});
}, 1000);
} catch (e) {
setMsg(e instanceof Error ? e.message : '发送失败');
}
}
async function login() {
if (!ensureAgreed()) return;
setLoading(true);
setMsg('');
try {
const data = await request<ShopSessionPayload>('SHOP_H5', '/shop/auth/login/sms', {
method: 'POST',
body: JSON.stringify({ phone, code }),
});
saveRememberedSession(data);
applySession(data);
if (isWechatEnv() && wxAuthorize) {
setMsg('登录成功,正在关联微信…');
await bindShopWechatAfterSmsLogin();
return;
}
routeAfterShopLogin(data, navigate);
} catch (e) {
setMsg(e instanceof Error ? e.message : '登录失败');
} finally {
setLoading(false);
}
}
async function wechatLogin() {
if (!ensureAgreed()) return;
setMsg('');
if (!isWechatEnv()) {
setMsg('请在微信内打开以使用微信一键登录');
return;
}
setWxLoading(true);
try {
const session = await loginShopWithWechat();
if (session) {
applySession(session);
routeAfterShopLogin(session, navigate);
}
} catch (e) {
setMsg(formatShopWechatError(e));
} finally {
setWxLoading(false);
}
}
if (quick) {
const canWechatQuick = wxAuthorize && isWechatEnv() && hasShopWxSession() && !!savedProfile;
return (
<div className="shop-quick-login-page">
<header className="shop-quick-header">
<AppImage src="/logo.png" alt="杜康好客" wrapperClassName="shop-quick-logo" fit="contain" />
<h1 className="shop-quick-welcome"></h1>
<div className="shop-quick-welcome-line" />
</header>
<section className="shop-quick-store-card">
<div className="shop-quick-store-inner">
<div className="shop-quick-store-icon">
<span className="material-symbols-outlined shop-fill-icon">storefront</span>
</div>
<div>
<h2 className="shop-quick-store-name">{quickStoreName}</h2>
<p className="shop-quick-store-phone">{maskPhone(quickPhone)}</p>
</div>
<span className="shop-quick-verified">
<span className="material-symbols-outlined shop-fill-icon" style={{ fontSize: 14 }}>verified_user</span>
</span>
<div className="shop-quick-switch">
<Link to="/login">
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>sync</span>
</Link>
</div>
</div>
</section>
<div className="shop-quick-actions">
{msg && <p className="shop-login-msg">{msg}</p>}
<ShopAgreementCheckbox
agreed={agreed}
onChange={setAgreed}
labelRef={agreementRef}
/>
{canWechatQuick ? (
<button
type="button"
className="shop-quick-login-btn shop-quick-login-btn--wechat"
disabled={wxLoading}
onClick={() => void wechatLogin()}
>
<span className="material-symbols-outlined">chat</span>
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
</button>
) : (
<p className="shop-login-msg" style={{ textAlign: 'center' }}>
{wxAuthorize && !isWechatEnv()
? '请在微信内打开以使用一键登录'
: '请使用验证码登录并绑定微信后,即可 7 天内免登录'}
</p>
)}
{!canWechatQuick && (
<Link to="/login" className="shop-quick-login-btn" style={{ textAlign: 'center', textDecoration: 'none' }}>
</Link>
)}
<div className="shop-quick-secure">
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>lock</span>
<span>{canWechatQuick ? '微信验证 · 7 天内免登录' : '加密环境安全登录中'}</span>
</div>
</div>
<footer className="shop-quick-footer">
<p className="shop-login-footer-brand">SECURED BY DUKANG HERITAGE</p>
<p style={{ fontSize: 10, fontFamily: 'var(--font-label)' }}>© 2024 </p>
</footer>
</div>
);
}
return (
<div className="shop-login-page">
<header className="shop-login-hero">
<div className="shop-login-logo-wrap">
<AppImage src="/logo.png" alt="杜康好客" wrapperClassName="app-image--fill" fit="contain" />
</div>
<h1 className="shop-login-brand"></h1>
<p className="shop-login-tagline"></p>
</header>
<main className="shop-login-main">
<div className="shop-login-card">
<div className="shop-login-field">
<label htmlFor="phone"></label>
<div className="shop-login-input-wrap">
<span className="material-symbols-outlined">phone_iphone</span>
<input
id="phone"
type="tel"
maxLength={11}
placeholder="请输入您的手机号"
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
</div>
</div>
<div className="shop-login-field">
<label htmlFor="code"></label>
<div className="shop-login-code-row">
<div className="shop-login-input-wrap">
<span className="material-symbols-outlined">shield</span>
<input
id="code"
type="text"
maxLength={6}
placeholder="验证码"
value={code}
onChange={(e) => setCode(e.target.value)}
/>
</div>
<button
type="button"
className="shop-login-code-btn"
disabled={codeCooldown > 0}
onClick={sendCode}
>
{codeCooldown > 0 ? `${codeCooldown}s` : '获取验证码'}
</button>
</div>
</div>
{msg && <p className="shop-login-msg">{msg}</p>}
<ShopAgreementCheckbox
agreed={agreed}
onChange={setAgreed}
labelRef={agreementRef}
/>
<button
type="button"
className="shop-login-submit"
disabled={loading}
onClick={() => void login()}
>
<span>{loading ? '登录中...' : '登录'}</span>
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
</button>
{wxAuthorize && (
<>
<div className="shop-login-divider">
<span className="shop-login-divider-line" />
<span className="shop-login-divider-text"></span>
<span className="shop-login-divider-line" />
</div>
<button
type="button"
className="shop-login-wechat"
disabled={wxLoading}
onClick={() => void wechatLogin()}
>
<span className="material-symbols-outlined">chat</span>
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
</button>
</>
)}
<p className="shop-login-msg" style={{ textAlign: 'center', marginTop: 12 }}>
7
</p>
</div>
</main>
<footer className="shop-login-footer">
<p className="shop-login-footer-brand">Secured by DUKANG HERITAGE</p>
<span className="material-symbols-outlined shop-fill-icon" style={{ opacity: 0.3, fontSize: 20, color: 'var(--color-outline)' }}>
security
</span>
{hasShopWxSession() && savedProfile && (
<p style={{ marginTop: 16, textAlign: 'center' }}>
<Link to="/login?quick=1" className="text-primary body-md"></Link>
</p>
)}
</footer>
</div>
);
}
+194
View File
@@ -0,0 +1,194 @@
import { useCallback, useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
import { useStoreSession } from '../contexts/StoreSessionContext';
import { getStoreProfile, request } from '../lib/api';
import {
authorizeShopWechat,
fetchClientConfig,
fetchShopAccount,
handleShopWechatCallback,
handleShopWechatLoginResult,
} from '../lib/wechat-auth';
import { isWechatEnv } from '../lib/weixin';
import { useStorePageView } from '../lib/usePageView';
export default function MinePage() {
useStorePageView('store_mine_view');
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { resetSession, store: sessionStore, applySession } = useStoreSession();
const profile = sessionStore ?? getStoreProfile();
const [store, setStore] = useState<Record<string, unknown> | null>(null);
const [hasWechat, setHasWechat] = useState<boolean | null>(null);
const [wxAuthorize, setWxAuthorize] = useState(false);
const [binding, setBinding] = useState(false);
const [bindMsg, setBindMsg] = useState('');
const loadMine = useCallback(() => {
return Promise.all([
request('SHOP_H5', '/shop/store').then(setStore).catch(() => setStore(null)),
fetchClientConfig()
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
.catch(() => setWxAuthorize(false)),
fetchShopAccount()
.then((me) => setHasWechat(!!(me.hasWechat || me.wxOpenId)))
.catch(() => setHasWechat(null)),
]);
}, []);
useEffect(() => {
void loadMine();
}, [loadMine]);
useEffect(() => {
if (!isWechatEnv() || !wxAuthorize || !searchParams.get('code')) return;
void handleShopWechatCallback()
.then((result) => {
if (!result) return;
const session = handleShopWechatLoginResult(result);
if (session) {
applySession(session);
setHasWechat(true);
setBindMsg('微信绑定成功');
}
stripOAuthParamsFromLocation();
setSearchParams({}, { replace: true });
})
.catch((e) => setBindMsg(e instanceof Error ? e.message : '微信绑定失败'));
}, [applySession, searchParams, setSearchParams, wxAuthorize]);
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('') : '09:30 - 22:00';
const multiStore = (profile?.stores?.length ?? 0) > 1;
const showBindWechat = wxAuthorize && hasWechat === false;
async function bindWechat() {
if (binding) return;
setBindMsg('');
if (!isWechatEnv()) {
setBindMsg('请在微信内打开门店端以绑定微信');
return;
}
setBinding(true);
try {
const result = await authorizeShopWechat();
if (!result) {
// 跳转公众号 OAuth,回跳后由上面 useEffect 处理
return;
}
const session = handleShopWechatLoginResult(result);
if (session) {
applySession(session);
setHasWechat(true);
setBindMsg('微信绑定成功');
} else {
setBindMsg('绑定未完成,请重试');
}
} catch (e) {
setBindMsg(e instanceof Error ? e.message : '微信绑定失败');
} finally {
setBinding(false);
}
}
return (
<PullToRefresh onRefresh={loadMine} className="shop-mine-page">
<header className="shop-mine-header">
<h1 className="app-page-title"></h1>
</header>
<div className="shop-mine-content">
<h3 className="shop-mine-section-title"></h3>
<div className="shop-mine-info-card">
<div className="shop-mine-info-row">
<div>
<p className="shop-mine-info-label"></p>
<p className="shop-mine-info-value name">{String(store?.name || profile?.storeName || '—')}</p>
</div>
<span className="material-symbols-outlined shop-mine-lock">lock</span>
</div>
<div className="shop-mine-info-row">
<div>
<p className="shop-mine-info-label"></p>
<p className="shop-mine-info-value">{String(store?.address || '—')}</p>
</div>
<span className="material-symbols-outlined shop-mine-lock">lock</span>
</div>
<div className="shop-mine-info-row">
<div>
<p className="shop-mine-info-label"></p>
<p className="shop-mine-info-value">{String(store?.phone || profile?.phone || '—')}</p>
</div>
<span className="material-symbols-outlined shop-mine-lock">lock</span>
</div>
<div className="shop-mine-info-row">
<div>
<p className="shop-mine-info-label"></p>
<p className="shop-mine-info-value">{hoursText}</p>
</div>
<span className="material-symbols-outlined shop-mine-lock">lock</span>
</div>
</div>
<div className="shop-mine-actions">
{multiStore ? (
<button type="button" className="shop-mine-action" onClick={() => navigate('/select-store')}>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>swap_horiz</span>
</button>
) : null}
<button type="button" className="shop-mine-action" onClick={() => navigate('/withdraw')}>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>account_balance_wallet</span>
</button>
{profile?.isPrimary ? (
<button type="button" className="shop-mine-action" onClick={() => navigate('/packages')}>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>restaurant_menu</span>
</button>
) : null}
{profile?.isPrimary ? (
<button type="button" className="shop-mine-action" onClick={() => navigate('/staff')}>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>group</span>
</button>
) : null}
</div>
<div className="shop-mine-help">
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>help</span>
<p></p>
</div>
{bindMsg ? <p className="shop-mine-bind-msg">{bindMsg}</p> : null}
{showBindWechat ? (
<button
type="button"
className="shop-mine-bind-wechat"
disabled={binding}
onClick={() => void bindWechat()}
>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>chat</span>
{binding ? '绑定中…' : '绑定微信'}
</button>
) : null}
<button
type="button"
className="shop-mine-logout"
onClick={() => { resetSession(); navigate('/login'); }}
>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>logout</span>
退
</button>
</div>
</PullToRefresh>
);
}
+143
View File
@@ -0,0 +1,143 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import type { StorePackagesResponse } from '@dukang/shared-types';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import ShopPackagesForm from '../components/ShopPackagesForm';
import { request } from '../lib/api';
import { useStorePageView } from '../lib/usePageView';
import {
emptyPackage,
normalizePackageFormItems,
validatePackageFormItems,
type PackageFormItem,
} from '../lib/storePackages';
export default function PackagesPage() {
useStorePageView('store_packages_view');
const navigate = useNavigate();
const [items, setItems] = useState<PackageFormItem[]>([emptyPackage()]);
const [pending, setPending] = useState<StorePackagesResponse['pendingRequest']>(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [msg, setMsg] = useState('');
async function load() {
setLoading(true);
try {
const data = await request<StorePackagesResponse>('SHOP_H5', '/shop/store/packages');
const base = data.pendingRequest?.packages?.length
? data.pendingRequest.packages
: data.live?.length
? data.live
: [emptyPackage()];
setItems(base.map((p, i) => ({ ...p, price: String(p.price), sortOrder: i })));
setPending(data.pendingRequest ?? null);
setMsg('');
} catch (e) {
setMsg(e instanceof Error ? e.message : '加载失败');
} finally {
setLoading(false);
}
}
useEffect(() => {
void load();
}, []);
async function submitAudit() {
const validationMsg = validatePackageFormItems(items);
if (validationMsg) {
setMsg(validationMsg);
return;
}
if (pending?.status === 'PENDING') {
setMsg('已有套餐变更审核中,请等待总部处理');
return;
}
setSubmitting(true);
setMsg('');
try {
const packages = normalizePackageFormItems(items).map((p) => ({
...p,
price: Number(p.price).toFixed(2),
}));
await request('SHOP_H5', '/shop/store/packages', {
method: 'PUT',
body: JSON.stringify({ packages }),
});
setMsg('已提交审核,请等待总部处理');
await load();
} catch (e) {
setMsg(e instanceof Error ? e.message : '提交失败');
} finally {
setSubmitting(false);
}
}
const disabled = pending?.status === 'PENDING' || submitting;
return (
<PullToRefresh onRefresh={load} className="shop-records-page shop-packages-page">
<header className="shop-records-header" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
type="button"
className="shop-withdraw-back"
onClick={() => navigate('/mine')}
aria-label="返回"
>
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>
arrow_back
</span>
</button>
<h1 className="app-page-title" style={{ margin: 0 }}>
</h1>
</header>
<div className="shop-records-main">
{loading ? <p className="shop-records-empty"></p> : null}
{!loading && pending?.status === 'PENDING' ? (
<section className="shop-packages-notice">
<span className="material-symbols-outlined shop-fill-icon" style={{ fontSize: 18 }}>
hourglass_top
</span>
<p></p>
</section>
) : null}
{!loading && pending?.status === 'REJECTED' && pending.rejectReason ? (
<section className="shop-packages-notice shop-packages-notice--warn">
<span className="material-symbols-outlined shop-fill-icon" style={{ fontSize: 18 }}>
error
</span>
<p>{pending.rejectReason}</p>
</section>
) : null}
{!loading ? (
<>
<p className="shop-packages-intro">
</p>
<ShopPackagesForm items={items} onChange={setItems} disabled={disabled} />
</>
) : null}
{msg ? <p className="shop-withdraw-msg">{msg}</p> : null}
{!loading ? (
<button
type="button"
className="shop-withdraw-btn"
style={{ marginTop: 16 }}
disabled={disabled}
onClick={() => void submitAudit()}
>
{submitting ? '提交中…' : '提交审核'}
</button>
) : null}
</div>
</PullToRefresh>
);
}
+210
View File
@@ -0,0 +1,210 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import type { RedeemPhoneDirectPrepareResult } from '@dukang/shared-types';
import { request } from '../lib/api';
import { useStorePageView } from '../lib/usePageView';
function formatAmount(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
export default function PhoneRedeemPage() {
useStorePageView('store_phone_redeem_view');
const navigate = useNavigate();
const [phone, setPhone] = useState('');
const [amount, setAmount] = useState('');
const [confirmCode, setConfirmCode] = useState('');
const [prepared, setPrepared] = useState<RedeemPhoneDirectPrepareResult | null>(null);
const [storeName, setStoreName] = useState('');
const [storeClosed, setStoreClosed] = useState(false);
const [msg, setMsg] = useState('');
const [loading, setLoading] = useState(false);
const [confirmCooldown, setConfirmCooldown] = useState(0);
useEffect(() => {
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
.then((s) => {
setStoreName(String(s.name || '当前门店'));
if (s.status && s.status !== 'OPEN') setStoreClosed(true);
})
.catch(() => setStoreName('当前门店'));
}, []);
useEffect(() => {
if (confirmCooldown <= 0) return;
const timer = window.setTimeout(() => setConfirmCooldown((v) => v - 1), 1000);
return () => window.clearTimeout(timer);
}, [confirmCooldown]);
async function sendConfirmSms() {
if (!/^1\d{10}$/.test(phone.trim())) {
setMsg('请输入正确的手机号');
return;
}
if (storeClosed) {
setMsg('门店未营业,无法核销');
return;
}
const value = Number(amount);
if (!Number.isFinite(value) || value <= 0) {
setMsg('请输入有效核销金额');
return;
}
setLoading(true);
setMsg('');
try {
const result = await request<RedeemPhoneDirectPrepareResult>('SHOP_H5', '/shop/redeem/phone/prepare-direct', {
method: 'POST',
body: JSON.stringify({ phone: phone.trim(), amount: value }),
});
setPrepared(result);
setConfirmCode('');
setConfirmCooldown(60);
setMsg(`验证码已发送至 ${result.maskedPhone},验证成功后将直接核销`);
} catch (e) {
setPrepared(null);
setMsg(e instanceof Error ? e.message : '发送验证码失败');
} finally {
setLoading(false);
}
}
async function confirmRedeem() {
if (!prepared) {
setMsg('请先发送核销验证码');
return;
}
if (!confirmCode.trim()) {
setMsg('请输入确认验证码');
return;
}
setLoading(true);
setMsg('');
try {
const result = await request<Record<string, unknown>>('SHOP_H5', '/shop/redeem/phone/confirm', {
method: 'POST',
body: JSON.stringify({
sessionId: prepared.sessionId,
code: confirmCode.trim(),
}),
});
sessionStorage.setItem('lastRedeemResult', JSON.stringify(result));
navigate('/redeem/success', {
state: { result, storeName, user: prepared.user },
});
} catch (e) {
setMsg(e instanceof Error ? e.message : '核销失败');
} finally {
setLoading(false);
}
}
const amountValue = Number(amount);
const canSendCode =
/^1\d{10}$/.test(phone.trim()) && Number.isFinite(amountValue) && amountValue > 0;
return (
<div className="shop-redeem-page">
<header className="shop-redeem-header">
<button type="button" className="shop-redeem-back" onClick={() => navigate(-1)} aria-label="返回">
<span className="material-symbols-outlined">arrow_back</span>
</button>
<h1 className="app-page-title"></h1>
</header>
<main className="shop-redeem-main">
{storeClosed && (
<p className="shop-redeem-error" style={{ marginBottom: 12 }}></p>
)}
<section className="shop-redeem-card">
<div className="shop-redeem-banner">
<div className="shop-redeem-banner-icon">
<span className="material-symbols-outlined shop-fill-icon">smartphone</span>
</div>
<div>
<p className="shop-redeem-banner-label"></p>
<h2 className="shop-redeem-banner-name">{storeName}</h2>
</div>
</div>
<div className="shop-redeem-body">
<div className="shop-phone-field">
<label className="shop-phone-label"></label>
<input
className="shop-phone-input"
type="tel"
maxLength={11}
placeholder="请输入用户手机号"
value={phone}
disabled={loading}
onChange={(e) => {
setPhone(e.target.value.replace(/\D/g, ''));
setPrepared(null);
setConfirmCode('');
setConfirmCooldown(0);
}}
/>
</div>
<div className="shop-phone-field">
<label className="shop-phone-label"></label>
<input
className="shop-phone-input"
type="number"
min={0.01}
step={0.01}
placeholder="请输入待核销金额"
value={amount}
disabled={loading}
onChange={(e) => {
setAmount(e.target.value);
setPrepared(null);
setConfirmCode('');
setConfirmCooldown(0);
}}
/>
</div>
<div className="shop-phone-field">
<label className="shop-phone-label"></label>
<div className="shop-phone-code-row">
<input
className="shop-phone-input"
type="text"
inputMode="numeric"
maxLength={6}
placeholder="输入用户收到的验证码"
value={confirmCode}
onChange={(e) => setConfirmCode(e.target.value.replace(/\D/g, ''))}
/>
<button
type="button"
className="shop-phone-code-btn"
disabled={loading || confirmCooldown > 0 || storeClosed || !canSendCode}
onClick={() => void sendConfirmSms()}
>
{confirmCooldown > 0 ? `${confirmCooldown}s` : '发送验证码'}
</button>
</div>
<p className="shop-redeem-hint" style={{ marginTop: 8 }}>
</p>
</div>
<button
type="button"
className="shop-redeem-confirm-btn"
disabled={loading || storeClosed || !prepared || !confirmCode.trim()}
onClick={() => void confirmRedeem()}
>
{loading ? '正在核销…' : `确认核销 ¥${formatAmount(amountValue || 0)}`}
</button>
{msg && <p className="shop-redeem-error">{msg}</p>}
</div>
</section>
</main>
</div>
);
}
+332
View File
@@ -0,0 +1,332 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { REDEEM_CHANNEL_LABELS, type RedeemChannel, type RedeemStatsDto } from '@dukang/shared-types';
import { request } from '../lib/api';
import { useStorePageView } from '../lib/usePageView';
type RangeKey = 'today' | '7d' | '30d';
type StatusFilter = 'all' | 'pending' | 'paid';
function formatMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function inRange(dateStr: string, range: RangeKey) {
const d = new Date(dateStr);
const now = new Date();
const start = new Date();
start.setHours(0, 0, 0, 0);
if (range === 'today') return d >= start;
if (range === '7d') {
start.setDate(now.getDate() - 6);
return d >= start;
}
start.setDate(now.getDate() - 29);
return d >= start;
}
function channelLabel(channel: unknown): string {
const key = channel === 'PHONE' ? 'PHONE' : 'SCAN';
return REDEEM_CHANNEL_LABELS[key];
}
export default function RecordsPage() {
useStorePageView('store_records_view');
const [records, setRecords] = useState<Array<Record<string, unknown>>>([]);
const [range, setRange] = useState<RangeKey>('today');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const [storeName, setStoreName] = useState('');
const [statsOpen, setStatsOpen] = useState(false);
const [statsLoading, setStatsLoading] = useState(false);
const [stats, setStats] = useState<RedeemStatsDto | null>(null);
const loadRecords = useCallback(() => {
return Promise.all([
request<{ list: Array<Record<string, unknown>> }>('SHOP_H5', '/shop/redeem/records?pageSize=200').then(
(d) => {
setRecords(d.list || []);
},
),
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
.then((s) => setStoreName(String(s.name || '')))
.catch(() => {}),
]);
}, []);
const loadStats = useCallback(async (r: RangeKey) => {
setStatsLoading(true);
try {
const data = await request<RedeemStatsDto>('SHOP_H5', `/shop/redeem/stats?range=${r}`);
setStats(data);
} catch {
setStats(null);
} finally {
setStatsLoading(false);
}
}, []);
useEffect(() => {
void loadRecords();
}, [loadRecords]);
useEffect(() => {
if (statsOpen) void loadStats(range);
}, [statsOpen, range, loadStats]);
const filtered = useMemo(() => {
return records.filter((r) => {
if (!inRange(String(r.createdAt), range)) return false;
if (statusFilter === 'all') return true;
const payout = r.payout as { paidAt?: string | null; status?: string } | undefined;
const isPaid = Boolean(payout?.paidAt) || payout?.status === 'PAID' || Boolean(r.paidAt);
if (statusFilter === 'paid') return isPaid;
return !isPaid;
});
}, [records, range, statusFilter]);
const summary = useMemo(() => {
const totalAmount = filtered.reduce((s, r) => s + Number(r.amount || 0), 0);
const totalSettle = filtered.reduce((s, r) => s + Number(r.settleAmount || 0), 0);
const rate = totalAmount > 0 ? Math.round((totalSettle / totalAmount) * 100) : 60;
return { totalAmount, totalSettle, rate };
}, [filtered]);
return (
<PullToRefresh onRefresh={loadRecords} className="shop-records-page">
<header className="shop-records-header">
<h1 className="app-page-title"></h1>
</header>
<div className="shop-records-main">
<nav className="shop-records-filters">
<div className="shop-records-range-tabs">
{(
[
['today', '今日'],
['7d', '近7日'],
['30d', '近30日'],
] as const
).map(([key, label]) => (
<button
key={key}
type="button"
className={`shop-records-range-tab${range === key ? ' active' : ''}`}
onClick={() => setRange(key)}
>
{label}
</button>
))}
</div>
<div className="shop-records-status-row">
<div className="shop-records-status-chips">
{(
[
['all', '全部'],
['pending', '待打款'],
['paid', '已打款'],
] as const
).map(([key, label]) => (
<button
key={key}
type="button"
className={`shop-records-chip${statusFilter === key ? ' active' : ''}`}
onClick={() => setStatusFilter(key)}
>
{label}
</button>
))}
</div>
<button
type="button"
className="shop-records-stats-btn"
onClick={() => setStatsOpen(true)}
>
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>
bar_chart
</span>
</button>
</div>
</nav>
<section className="shop-records-summary">
<div className="shop-records-summary-grid">
<div>
<p className="shop-records-summary-label"></p>
<p className="shop-records-summary-value">¥ {formatMoney(summary.totalAmount)}</p>
</div>
<div>
<p className="shop-records-summary-label"></p>
<p className="shop-records-summary-value">¥ {formatMoney(summary.totalSettle)}</p>
</div>
</div>
<p className="shop-records-summary-note">
<span
className="material-symbols-outlined shop-fill-icon"
style={{ fontSize: 16, color: 'var(--color-success-green)' }}
>
check_circle
</span>
: {summary.rate}% ({summary.rate / 10})
</p>
</section>
<div className="shop-records-list-head">
<h3 className="shop-records-list-title"></h3>
<span className="shop-records-list-count"> {filtered.length} </span>
</div>
{filtered.length === 0 ? (
<p className="shop-records-empty"></p>
) : (
<div className="shop-records-list">
{filtered.map((r) => {
const amount = Number(r.amount || 0);
const settle = Number(r.settleAmount || 0);
const payout = r.payout as { paidAt?: string | null; status?: string } | undefined;
const paid = Boolean(payout?.paidAt) || payout?.status === 'PAID' || Boolean(r.paidAt);
const paidAt = payout?.paidAt || (typeof r.paidAt === 'string' ? r.paidAt : null);
const channel = (r.channel === 'PHONE' ? 'PHONE' : 'SCAN') as RedeemChannel;
return (
<article key={String(r.id)} className="shop-record-card">
<div className="shop-record-card-top">
<div>
<div className="shop-record-order">
<span className="shop-record-time" style={{ margin: 0 }}>
</span>
<span>{r.redeemNo ? String(r.redeemNo) : '—'}</span>
</div>
<p className="shop-record-time">
:{' '}
{new Date(String(r.createdAt))
.toLocaleString('zh-CN', { hour12: false })
.slice(0, 16)}
</p>
<p className="shop-record-channel">
<span
className={`shop-record-channel-tag${channel === 'PHONE' ? ' phone' : ''}`}
>
{channelLabel(channel)}
</span>
</p>
</div>
<span className={`shop-record-badge ${paid ? 'paid' : 'pending'}`}>
{paid ? '已打款' : '待打款'}
</span>
</div>
<div className="shop-record-amounts">
<div>
<p className="shop-record-amount-label">()</p>
<p className="shop-record-amount-value">¥{formatMoney(amount)}</p>
</div>
<div>
<p className="shop-record-amount-label">(6)</p>
<p className="shop-record-amount-value red">¥{formatMoney(settle)}</p>
</div>
</div>
<div className="shop-record-footer">
<p>
{paid
? `打款时间: ${paidAt ? new Date(String(paidAt)).toLocaleDateString('zh-CN') : '—'}`
: '预计打款: T+1工作日'}
</p>
{storeName && (
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span className="material-symbols-outlined" style={{ fontSize: 12 }}>
restaurant
</span>
{storeName}
</span>
)}
</div>
</article>
);
})}
</div>
)}
{filtered.length > 0 && (
<div className="shop-records-end">
<div className="shop-records-end-line" />
<p className="shop-records-list-count"></p>
</div>
)}
</div>
{statsOpen && (
<div className="shop-stats-overlay" role="dialog" aria-modal="true" aria-label="核销方式统计">
<button
type="button"
className="shop-stats-backdrop"
aria-label="关闭"
onClick={() => setStatsOpen(false)}
/>
<div className="shop-stats-sheet">
<div className="shop-stats-sheet-head">
<h2></h2>
<button type="button" className="shop-stats-close" onClick={() => setStatsOpen(false)}>
<span className="material-symbols-outlined">close</span>
</button>
</div>
<p className="shop-stats-range-hint">
{range === 'today' ? '今日' : range === '7d' ? '近7日' : '近30日'}
</p>
{statsLoading ? (
<p className="shop-records-empty"></p>
) : !stats ? (
<p className="shop-records-empty"></p>
) : (
<>
<div className="shop-stats-total">
<div>
<p className="shop-records-summary-label"></p>
<p className="shop-stats-total-value">{stats.totalCount}</p>
</div>
<div>
<p className="shop-records-summary-label"></p>
<p className="shop-stats-total-value">¥{formatMoney(stats.totalAmount)}</p>
</div>
<div>
<p className="shop-records-summary-label"></p>
<p className="shop-stats-total-value">¥{formatMoney(stats.totalSettleAmount)}</p>
</div>
</div>
<div className="shop-stats-channel-list">
{stats.byChannel.map((b) => (
<div key={b.channel} className="shop-stats-channel-card">
<div className="shop-stats-channel-title">
<span
className={`shop-record-channel-tag${b.channel === 'PHONE' ? ' phone' : ''}`}
>
{REDEEM_CHANNEL_LABELS[b.channel]}
</span>
<strong>{b.count} </strong>
</div>
<div className="shop-stats-channel-row">
<span></span>
<span>¥{formatMoney(b.amount)}</span>
</div>
<div className="shop-stats-channel-row">
<span></span>
<span>¥{formatMoney(b.settleAmount)}</span>
</div>
<div className="shop-stats-bar">
<div
className={`shop-stats-bar-fill${b.channel === 'PHONE' ? ' phone' : ''}`}
style={{
width: `${stats.totalCount > 0 ? Math.round((b.count / stats.totalCount) * 100) : 0}%`,
}}
/>
</div>
</div>
))}
</div>
</>
)}
</div>
</div>
)}
</PullToRefresh>
);
}
@@ -0,0 +1,218 @@
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import WeakNetFallbackPanel from '../components/WeakNetFallbackPanel';
import { request } from '../lib/api';
import { reportRedeemFailure } from '../lib/redeem-failure';
import { useStorePageView } from '../lib/usePageView';
function formatAmount(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
type Preview = {
amount: number;
expireInSeconds: number;
user?: { userNo?: string; phone?: string; nickname?: string };
redeemType?: string;
boundStoreId?: string | null;
};
export default function RedeemConfirmPage() {
useStorePageView('store_redeem_confirm_view');
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [token, setToken] = useState('');
const [msg, setMsg] = useState('');
const [loading, setLoading] = useState(false);
const [storeName, setStoreName] = useState('');
const [preview, setPreview] = useState<Preview | null>(null);
const [storeClosed, setStoreClosed] = useState(false);
const [failCount, setFailCount] = useState(0);
const [showWeakNet, setShowWeakNet] = useState(false);
useEffect(() => {
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
.then((s) => {
setStoreName(String(s.name || '当前门店'));
if (s.status && s.status !== 'OPEN') setStoreClosed(true);
})
.catch(() => setStoreName('当前门店'));
}, []);
useEffect(() => {
const scanned = searchParams.get('token')?.trim();
if (!scanned) {
navigate('/', { replace: true });
return;
}
setToken(scanned);
}, [searchParams, navigate]);
useEffect(() => {
if (!token.trim()) {
setPreview(null);
return;
}
request<Preview>('SHOP_H5', '/shop/redeem/preview', {
method: 'POST',
body: JSON.stringify({ token }),
})
.then(setPreview)
.catch(async (e) => {
setPreview(null);
setMsg(e instanceof Error ? e.message : '无法预览核销码');
const report = await reportRedeemFailure(token, 'preview', e);
if (report?.thresholdReached) {
setFailCount(report.failCount);
setShowWeakNet(true);
} else if (report) {
setFailCount(report.failCount);
}
});
}, [token]);
async function confirm() {
if (storeClosed) {
setMsg('门店未营业,无法核销');
return;
}
if (!token.trim()) {
setMsg('请先扫码获取核销码');
return;
}
setLoading(true);
setMsg('');
try {
const result = await request<Record<string, unknown>>('SHOP_H5', '/shop/redeem/confirm', {
method: 'POST',
body: JSON.stringify({ token }),
});
sessionStorage.setItem('lastRedeemResult', JSON.stringify(result));
navigate('/redeem/success', { state: { result, storeName, user: preview?.user } });
} catch (e) {
setMsg(e instanceof Error ? e.message : '核销失败');
const report = await reportRedeemFailure(token, 'confirm', e);
if (report?.thresholdReached) {
setFailCount(report.failCount);
setShowWeakNet(true);
} else if (report) {
setFailCount(report.failCount);
}
} finally {
setLoading(false);
}
}
const previewAmount = preview?.amount ?? 0;
const userLabel = preview?.user?.nickname || preview?.user?.phone || '—';
return (
<div className="shop-redeem-page">
<header className="shop-redeem-header">
<button type="button" className="shop-redeem-back" onClick={() => navigate(-1)} aria-label="返回">
<span className="material-symbols-outlined">arrow_back</span>
</button>
<h1 className="app-page-title"></h1>
</header>
<main className="shop-redeem-main">
{storeClosed && (
<p className="shop-redeem-error" style={{ marginBottom: 12 }}></p>
)}
<section className="shop-redeem-card">
<div className="shop-redeem-banner">
<div className="shop-redeem-banner-icon">
<span className="material-symbols-outlined shop-fill-icon">verified_user</span>
</div>
<div>
<p className="shop-redeem-banner-label"></p>
<h2 className="shop-redeem-banner-name">{storeName}</h2>
</div>
</div>
<div className="shop-redeem-body">
<div className="shop-redeem-user">
<div className="shop-redeem-user-left">
<span className="material-symbols-outlined">person</span>
<span></span>
</div>
<span className="headline-md" style={{ fontFamily: 'var(--font-headline)', fontWeight: 600 }}>
{userLabel}
</span>
</div>
<div className="shop-redeem-amount-section">
<span className="shop-redeem-notch shop-redeem-notch--left" />
<span className="shop-redeem-notch shop-redeem-notch--right" />
<p className="shop-redeem-amount-label"></p>
<div className="shop-redeem-amount">
<span className="shop-redeem-amount-symbol">¥</span>
<span className="shop-redeem-amount-value">{formatAmount(previewAmount)}</span>
</div>
<span className="shop-redeem-benefit">
<span className="material-symbols-outlined shop-fill-icon" style={{ fontSize: 16, color: 'var(--color-aged-amber)' }}>
confirmation_number
</span>
· {preview?.redeemType === 'COUPON' ? '单据核销' : '直接核销'}
</span>
</div>
<div className="shop-redeem-details">
<div className="shop-redeem-detail-row">
<span> ID</span>
<span style={{ wordBreak: 'break-all', maxWidth: '60%', textAlign: 'right' }}>
{token || '扫码后显示'}
</span>
</div>
<div className="shop-redeem-detail-row">
<span></span>
<span>{preview ? `${preview.expireInSeconds}` : '—'}</span>
</div>
{preview?.boundStoreId && (
<div className="shop-redeem-detail-row">
<span></span>
<span></span>
</div>
)}
{failCount > 0 && !showWeakNet && (
<div className="shop-redeem-detail-row">
<span></span>
<span>{failCount} / 5 </span>
</div>
)}
</div>
{msg && <p className="shop-redeem-error">{msg}</p>}
{!showWeakNet && (
<>
<button
type="button"
className={`shop-redeem-confirm-btn${loading ? ' success' : ''}`}
disabled={loading || storeClosed || !preview}
onClick={() => void confirm()}
>
<span className="material-symbols-outlined shop-fill-icon">
{loading ? 'sync' : 'check_circle'}
</span>
<span>{loading ? '正在核销...' : preview ? `确认核销 ¥${formatAmount(previewAmount)}` : '加载中…'}</span>
</button>
<p className="shop-redeem-hint"></p>
</>
)}
</div>
</section>
{showWeakNet && token && (
<WeakNetFallbackPanel redeemToken={token} failCount={failCount} />
)}
<div className="shop-redeem-ornament">
<span className="material-symbols-outlined" style={{ fontSize: 64, color: 'var(--color-heritage-red)' }}>
wine_bar
</span>
</div>
</main>
</div>
);
}
@@ -0,0 +1,100 @@
import { useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
function formatAmount(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
export default function RedeemSuccessPage() {
const navigate = useNavigate();
const location = useLocation();
const result = useMemo(() => {
const stateResult = (location.state as { result?: Record<string, unknown> })?.result;
if (stateResult) return stateResult;
try {
const cached = sessionStorage.getItem('lastRedeemResult');
return cached ? (JSON.parse(cached) as Record<string, unknown>) : null;
} catch {
return null;
}
}, [location.state]);
const storeName = (location.state as { storeName?: string })?.storeName || '当前门店';
const user = (location.state as { user?: { nickname?: string; phone?: string; userNo?: string } })?.user;
const userLabel = user?.nickname || user?.phone || '—';
const amount = Number(result?.amount ?? 0);
const redeemNo = String(result?.redeemNo || '—');
const createdAt = result?.createdAt
? new Date(String(result.createdAt)).toLocaleString('zh-CN')
: new Date().toLocaleString('zh-CN');
return (
<div className="shop-success-page">
<header className="shop-success-header">
<button type="button" className="shop-redeem-back" onClick={() => navigate('/')} aria-label="返回">
<span className="material-symbols-outlined">arrow_back</span>
</button>
<h1 className="app-page-title"></h1>
</header>
<section className="shop-success-hero">
<div className="shop-success-icon-wrap">
<span className="material-symbols-outlined">check_circle</span>
</div>
<h2 className="shop-success-title"></h2>
<p className="shop-success-amount">¥ {formatAmount(amount)}</p>
<p className="shop-success-sub"></p>
<p className="shop-success-note"></p>
</section>
<div className="shop-success-details">
<div className="shop-success-detail-row">
<span className="shop-success-detail-label"></span>
<span className="shop-success-detail-value">{storeName}</span>
</div>
<div className="shop-success-detail-row">
<span className="shop-success-detail-label"></span>
<div className="shop-success-user">
<div className="shop-success-user-avatar">
<span className="material-symbols-outlined">person</span>
</div>
<div style={{ textAlign: 'right' }}>
<div className="shop-success-detail-value">{userLabel}</div>
</div>
</div>
</div>
<div className="shop-success-detail-row">
<span className="shop-success-detail-label"></span>
<span className="shop-success-detail-value" style={{ fontWeight: 400, color: 'var(--color-on-surface-variant)' }}>
{createdAt}
</span>
</div>
<div className="shop-success-detail-row">
<span className="shop-success-detail-label"></span>
<span className="shop-success-detail-value" style={{ fontFamily: 'monospace', fontWeight: 400 }}>
{redeemNo}
</span>
</div>
</div>
<div className="shop-success-actions">
<button type="button" className="shop-success-primary-btn" onClick={() => navigate('/')}>
<span></span>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>qr_code_scanner</span>
</button>
<button type="button" className="shop-success-outline-btn" onClick={() => navigate('/')}>
<span></span>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>home</span>
</button>
</div>
<div className="shop-success-brand">
<div className="shop-success-brand-icon">
<span className="material-symbols-outlined shop-fill-icon" style={{ fontSize: 12 }}>verified</span>
</div>
<p className="shop-success-brand-text">西</p>
</div>
</div>
);
}
+174
View File
@@ -0,0 +1,174 @@
import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { useStoreSession } from '../contexts/StoreSessionContext';
import {
needsStoreSelection,
request,
selectStore,
type ShopSessionPayload,
type ShopStoreOption,
} from '../lib/api';
export default function SelectStorePage() {
const navigate = useNavigate();
const { applySession, store, authenticated } = useStoreSession();
const [stores, setStores] = useState<ShopStoreOption[]>(store?.stores ?? []);
const [loadingId, setLoadingId] = useState<string | null>(null);
const [msg, setMsg] = useState('');
const currentStoreId = store?.storeId || '';
const canGoBack = Boolean(currentStoreId);
const loadStores = useCallback(() => {
return request<ShopStoreOption[]>('SHOP_H5', '/shop/auth/stores')
.then((list) => setStores(list))
.catch((e) => setMsg(e instanceof Error ? e.message : '加载门店失败'));
}, []);
useEffect(() => {
if (!authenticated) {
navigate('/login', { replace: true });
return;
}
void loadStores();
}, [authenticated, navigate, loadStores]);
async function onSelect(storeId: string) {
if (loadingId) return;
if (storeId === currentStoreId) {
navigate('/', { replace: true });
return;
}
setLoadingId(storeId);
setMsg('');
try {
const session = await selectStore(storeId);
applySession(session);
navigate('/', { replace: true });
} catch (e) {
setMsg(e instanceof Error ? e.message : '选店失败');
} finally {
setLoadingId(null);
}
}
// 仅一家店时自动选
useEffect(() => {
if (stores.length === 1 && needsStoreSelection({ store, stores })) {
void onSelect(stores[0].storeId);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stores.length]);
function statusLabel(status: string) {
if (status === 'OPEN') return '营业中';
if (status === 'PAUSED') return '临时闭店';
if (status === 'CLOSED') return '永久关闭';
return status || '门店';
}
return (
<PullToRefresh onRefresh={loadStores} className="shop-select-store-page">
<header className="shop-subpage-header">
{canGoBack ? (
<button
type="button"
className="shop-subpage-back"
onClick={() => navigate('/mine')}
aria-label="返回"
>
<span className="material-symbols-outlined">arrow_back</span>
</button>
) : (
<span className="shop-subpage-header-spacer" />
)}
<h1 className="app-page-title"></h1>
<span className="shop-subpage-header-spacer" />
</header>
<div className="shop-subpage-content">
<section className="shop-subpage-hero">
<div className="shop-subpage-hero-icon">
<span className="material-symbols-outlined shop-fill-icon">store</span>
</div>
<div>
<h2 className="shop-subpage-hero-title"></h2>
<p className="shop-subpage-hero-desc">
{stores.length || '多'}
</p>
</div>
</section>
{msg ? (
<p className="shop-subpage-msg" role="alert">
{msg}
</p>
) : null}
<section>
<h3 className="shop-subpage-section-title"></h3>
<ul className="shop-select-store-list">
{stores.map((item) => {
const active = item.storeId === currentStoreId;
const busy = loadingId === item.storeId;
return (
<li key={item.storeId}>
<button
type="button"
className={`shop-select-store-item${active ? ' is-active' : ''}`}
disabled={Boolean(loadingId)}
onClick={() => void onSelect(item.storeId)}
>
<div className="shop-select-store-item-icon">
<span className="material-symbols-outlined shop-fill-icon">storefront</span>
</div>
<div className="shop-select-store-item-body">
<div className="shop-select-store-item-top">
<span className="shop-select-store-name">{item.name}</span>
{active ? (
<span className="shop-select-store-badge"></span>
) : (
<span className="shop-select-store-status">{statusLabel(item.status)}</span>
)}
</div>
<span className="shop-select-store-meta">
{[item.district, item.address].filter(Boolean).join(' · ') ||
statusLabel(item.status)}
</span>
</div>
<span className="shop-select-store-item-action">
{busy ? (
'进入中…'
) : (
<span className="material-symbols-outlined">chevron_right</span>
)}
</span>
</button>
</li>
);
})}
</ul>
{!stores.length && !msg ? (
<div className="shop-subpage-empty">
<span className="material-symbols-outlined">store</span>
<p></p>
</div>
) : null}
</section>
</div>
</PullToRefresh>
);
}
/** After login/wechat: route to select-store or home */
export function routeAfterShopLogin(
session: ShopSessionPayload,
navigate: (path: string, opts?: { replace?: boolean }) => void,
) {
if (needsStoreSelection(session)) {
navigate('/select-store', { replace: true });
return;
}
navigate('/', { replace: true });
}
+330
View File
@@ -0,0 +1,330 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { STORE_STAFF_ROLE_LABELS, type StoreStaffRole } from '@dukang/shared-types';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { useStoreSession } from '../contexts/StoreSessionContext';
import { getStoreProfile, request } from '../lib/api';
import { useStorePageView } from '../lib/usePageView';
type StaffItem = {
id: string;
name: string;
phone: string;
staffRole: StoreStaffRole;
status: string;
storeIds: string[];
stores: Array<{ storeId: string; name: string }>;
};
type StoreOption = { storeId: string; name: string };
function maskPhone(phone: string) {
if (!phone || phone.length < 7) return phone;
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
}
export default function StaffPage() {
useStorePageView('store_staff_view');
const navigate = useNavigate();
const { store } = useStoreSession();
const profile = store ?? getStoreProfile();
const [list, setList] = useState<StaffItem[]>([]);
const [ownedStores, setOwnedStores] = useState<StoreOption[]>([]);
const [msg, setMsg] = useState('');
const [showForm, setShowForm] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState({ phone: '', name: '', storeIds: [] as string[] });
async function reload() {
const [staff, stores] = await Promise.all([
request<StaffItem[]>('SHOP_H5', '/shop/staff'),
request<StoreOption[]>('SHOP_H5', '/shop/auth/stores'),
]);
setList(staff);
setOwnedStores(stores);
}
useEffect(() => {
if (!profile?.isPrimary) {
navigate('/mine', { replace: true });
return;
}
void reload().catch((e) => setMsg(e instanceof Error ? e.message : '加载失败'));
}, [navigate, profile?.isPrimary]);
async function createStaff() {
const phone = form.phone.trim();
const name = form.name.trim();
if (!/^1[3-9]\d{9}$/.test(phone)) {
setMsg('请输入正确的手机号');
return;
}
if (!name) {
setMsg('请填写姓名');
return;
}
setMsg('');
setSubmitting(true);
try {
await request('SHOP_H5', '/shop/staff', {
method: 'POST',
body: JSON.stringify({
phone,
name,
storeIds: form.storeIds.length ? form.storeIds : ownedStores.map((s) => s.storeId),
}),
});
setShowForm(false);
setForm({ phone: '', name: '', storeIds: [] });
await reload();
} catch (e) {
setMsg(e instanceof Error ? e.message : '创建失败');
} finally {
setSubmitting(false);
}
}
async function toggleStatus(item: StaffItem) {
const next = item.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE';
try {
await request('SHOP_H5', `/shop/staff/${item.id}`, {
method: 'PUT',
body: JSON.stringify({ status: next }),
});
await reload();
} catch (e) {
setMsg(e instanceof Error ? e.message : '更新失败');
}
}
function toggleStoreId(storeId: string) {
setForm((prev) => {
const allIds = ownedStores.map((s) => s.storeId);
// 空数组表示「全部」;取消某一家时转为「除该店外的全部」
if (prev.storeIds.length === 0) {
return { ...prev, storeIds: allIds.filter((id) => id !== storeId) };
}
const next = prev.storeIds.includes(storeId)
? prev.storeIds.filter((id) => id !== storeId)
: [...prev.storeIds, storeId];
// 又选回全部时,恢复默认空数组语义
if (next.length === allIds.length) {
return { ...prev, storeIds: [] };
}
return { ...prev, storeIds: next };
});
}
const selectedCount =
form.storeIds.length === 0 ? ownedStores.length : form.storeIds.length;
return (
<PullToRefresh onRefresh={reload} className="shop-staff-page">
<header className="shop-subpage-header">
<button
type="button"
className="shop-subpage-back"
onClick={() => {
if (showForm) {
setShowForm(false);
setMsg('');
return;
}
navigate('/mine');
}}
aria-label="返回"
>
<span className="material-symbols-outlined">arrow_back</span>
</button>
<h1 className="app-page-title">{showForm ? '添加子账号' : '子账号管理'}</h1>
{!showForm ? (
<button
type="button"
className="shop-subpage-header-action"
onClick={() => {
setShowForm(true);
setMsg('');
}}
>
<span className="material-symbols-outlined">person_add</span>
</button>
) : (
<span className="shop-subpage-header-spacer" />
)}
</header>
<div className="shop-subpage-content">
{!showForm ? (
<section className="shop-subpage-hero shop-subpage-hero--compact">
<div className="shop-subpage-hero-icon">
<span className="material-symbols-outlined shop-fill-icon">group</span>
</div>
<div>
<h2 className="shop-subpage-hero-title"></h2>
<p className="shop-subpage-hero-desc">
{list.length} ·
</p>
</div>
</section>
) : null}
{msg ? (
<p className="shop-subpage-msg" role="alert">
{msg}
</p>
) : null}
{showForm ? (
<section className="shop-staff-form-card">
<div className="shop-staff-form-field">
<label className="shop-staff-form-label" htmlFor="staff-phone">
</label>
<div className="shop-staff-form-input-wrap">
<span className="material-symbols-outlined">smartphone</span>
<input
id="staff-phone"
type="tel"
inputMode="numeric"
maxLength={11}
placeholder="员工登录手机号"
value={form.phone}
onChange={(e) =>
setForm((f) => ({ ...f, phone: e.target.value.replace(/\D/g, '').slice(0, 11) }))
}
/>
</div>
</div>
<div className="shop-staff-form-field">
<label className="shop-staff-form-label" htmlFor="staff-name">
</label>
<div className="shop-staff-form-input-wrap">
<span className="material-symbols-outlined">badge</span>
<input
id="staff-name"
type="text"
placeholder="员工姓名"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
/>
</div>
</div>
<div className="shop-staff-form-field">
<p className="shop-staff-form-label">
<span className="shop-staff-form-hint">
{form.storeIds.length === 0
? `(默认全部 ${ownedStores.length} 家)`
: `(已选 ${selectedCount} 家)`}
</span>
</p>
<div className="shop-staff-store-picks">
{ownedStores.map((s) => {
const checked =
form.storeIds.length === 0 || form.storeIds.includes(s.storeId);
return (
<label
key={s.storeId}
className={`shop-staff-store-pick${checked ? ' is-checked' : ''}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleStoreId(s.storeId)}
/>
<span className="material-symbols-outlined shop-fill-icon">storefront</span>
<span className="shop-staff-store-pick-name">{s.name}</span>
{checked ? (
<span className="material-symbols-outlined shop-staff-store-pick-check">
check_circle
</span>
) : null}
</label>
);
})}
</div>
</div>
<button
type="button"
className="shop-staff-submit"
disabled={submitting}
onClick={() => void createStaff()}
>
{submitting ? '创建中…' : '创建子账号'}
</button>
<button
type="button"
className="shop-staff-cancel"
disabled={submitting}
onClick={() => {
setShowForm(false);
setMsg('');
}}
>
</button>
</section>
) : (
<section>
<div className="shop-home-records-head">
<h3 className="shop-home-records-title"></h3>
</div>
<ul className="shop-staff-list">
{list.map((item) => {
const active = item.status === 'ACTIVE';
const initial = (item.name || item.phone || '?').slice(0, 1);
return (
<li key={item.id} className="shop-staff-item">
<div className="shop-staff-avatar">{initial}</div>
<div className="shop-staff-item-body">
<div className="shop-staff-item-top">
<strong>{item.name || '未命名'}</strong>
<span
className={`shop-staff-status-badge${active ? ' is-active' : ' is-disabled'}`}
>
{active ? '启用' : '停用'}
</span>
</div>
<p className="shop-staff-item-meta">{maskPhone(item.phone)}</p>
<p className="shop-staff-item-meta">
{STORE_STAFF_ROLE_LABELS[item.staffRole] ?? item.staffRole}
{item.stores.length
? ` · ${item.stores.map((s) => s.name).join('、')}`
: ''}
</p>
</div>
<button
type="button"
className={`shop-staff-toggle${active ? '' : ' is-enable'}`}
onClick={() => void toggleStatus(item)}
>
{active ? '停用' : '启用'}
</button>
</li>
);
})}
</ul>
{!list.length ? (
<div className="shop-subpage-empty">
<span className="material-symbols-outlined">person_off</span>
<p></p>
<button
type="button"
className="shop-staff-empty-add"
onClick={() => setShowForm(true)}
>
</button>
</div>
) : null}
</section>
)}
</div>
</PullToRefresh>
);
}
+171
View File
@@ -0,0 +1,171 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useStoreSession } from '../contexts/StoreSessionContext';
import { request } from '../lib/api';
function formatShopHours(store: Record<string, unknown> | null) {
const parts: string[] = [];
const openTime = store?.openTime ? String(store.openTime) : '';
const closeTime = store?.closeTime ? String(store.closeTime) : '';
const openTime2 = store?.openTime2 ? String(store.openTime2) : '';
const closeTime2 = store?.closeTime2 ? String(store.closeTime2) : '';
if (openTime && closeTime) parts.push(`${openTime} - ${closeTime}`);
if (openTime2 && closeTime2) parts.push(`${openTime2} - ${closeTime2}`);
return parts.length ? parts.join('') : '09:30 - 22:00';
}
export default function StatusPage() {
const navigate = useNavigate();
const { resetSession } = useStoreSession();
const [open, setOpen] = useState(true);
const [permanentlyClosed, setPermanentlyClosed] = useState(false);
const [store, setStore] = useState<Record<string, unknown> | null>(null);
const [lastUpdate, setLastUpdate] = useState('');
const [showModal, setShowModal] = useState(false);
const [pendingOpen, setPendingOpen] = useState<boolean | null>(null);
const [error, setError] = useState('');
useEffect(() => {
request('SHOP_H5', '/shop/dashboard').then((d) => {
const s = d.store as Record<string, unknown>;
setStore(s);
const status = String(s?.status || '');
setPermanentlyClosed(status === 'CLOSED');
setOpen(status === 'OPEN');
if (s?.updatedAt) {
setLastUpdate(new Date(String(s.updatedAt)).toLocaleString('zh-CN'));
}
});
}, []);
function requestToggle(next: boolean) {
if (permanentlyClosed) return;
if (next === open) return;
setPendingOpen(next);
setShowModal(true);
}
async function confirmToggle() {
if (pendingOpen === null || permanentlyClosed) return;
const next = pendingOpen ? 'OPEN' : 'PAUSED';
try {
setError('');
await request('SHOP_H5', '/shop/store/status', {
method: 'PUT',
body: JSON.stringify({ status: next }),
});
setOpen(pendingOpen);
setLastUpdate(new Date().toLocaleString('zh-CN'));
} catch (e) {
setError(e instanceof Error ? e.message : '状态切换失败');
} finally {
setShowModal(false);
setPendingOpen(null);
}
}
const hoursText = formatShopHours(store);
const statusLabel = permanentlyClosed ? '永久关闭' : open ? '营业中' : '临时闭店';
return (
<div className="shop-status-page">
<header className="shop-status-header app-page-header">
<button type="button" className="app-page-header-action shop-redeem-back" onClick={() => navigate(-1)} aria-label="返回">
<span className="material-symbols-outlined">arrow_back</span>
</button>
<h1 className="app-page-title"></h1>
<button
type="button"
className="shop-status-logout app-page-header-action app-page-header-action--end"
onClick={() => { resetSession(); navigate('/login'); }}
>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>logout</span>
退
</button>
</header>
<div className="shop-status-content">
<section className="shop-status-card">
<div className="shop-status-icon-wrap">
<div className={`shop-status-icon-outer${open && !permanentlyClosed ? ' open' : ' closed'}`}>
<div className={`shop-status-icon-inner${open && !permanentlyClosed ? ' open' : ' closed'}`}>
<span className="material-symbols-outlined">storefront</span>
</div>
</div>
<span className="shop-status-check">
<span
className="material-symbols-outlined shop-fill-icon"
style={{
fontSize: 14,
color: open && !permanentlyClosed ? 'var(--color-success-green)' : 'var(--color-subtle-gray)',
}}
>
{open && !permanentlyClosed ? 'check_circle' : 'cancel'}
</span>
</span>
</div>
<h2 className={`shop-status-label${open && !permanentlyClosed ? ' open' : ' closed'}`}>
{statusLabel}
</h2>
{permanentlyClosed ? (
<p className="shop-status-switch-caption" style={{ marginTop: 12 }}>
</p>
) : (
<label className={`shop-status-switch${open ? ' open' : ' closed'}`}>
<input
type="checkbox"
checked={open}
onChange={(e) => requestToggle(e.target.checked)}
aria-label={open ? '切换为临时闭店' : '切换为营业中'}
/>
<span className="shop-status-switch-track" />
<span className="shop-status-switch-caption">
{open ? '点击可临时闭店' : '点击恢复营业'}
</span>
</label>
)}
<p className="shop-status-hours-label"></p>
<p className="shop-status-hours">{hoursText}</p>
{lastUpdate && <p className="shop-status-updated"> {lastUpdate}</p>}
{error ? <p className="shop-status-updated" style={{ color: 'var(--color-error, #c62828)' }}>{error}</p> : null}
</section>
<div className={`shop-status-hint${open && !permanentlyClosed ? ' open' : ' closed'}`}>
<span className="material-symbols-outlined">info</span>
<p>
{permanentlyClosed
? '门店已永久关闭。如需重新营业,请联系总部或合伙人处理。'
: open
? '当前处于营业状态,用户可在您的门店核销餐券。'
: '当前处于临时闭店状态,用户将无法看到您的门店或进行核销。'}
</p>
</div>
</div>
{showModal && (
<div className="shop-status-modal" role="dialog" aria-modal="true">
<div className="shop-status-modal-card">
<h4 className="shop-status-modal-title"></h4>
<p className="shop-status-modal-desc">
{pendingOpen
? '切换至“营业中”后,用户可正常选择本店核销餐券。'
: '切换至“临时闭店”后,用户将无法选择本店核销餐券。'}
</p>
<div className="shop-status-modal-actions">
<button type="button" className="shop-status-modal-cancel" onClick={() => { setShowModal(false); setPendingOpen(null); }}>
</button>
<button type="button" className="shop-status-modal-confirm" onClick={confirmToggle}>
</button>
</div>
</div>
</div>
)}
</div>
);
}
+236
View File
@@ -0,0 +1,236 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
STORE_WITHDRAW_STATUS_LABELS,
type StoreWithdrawRequestDto,
type StoreWithdrawStatus,
type StoreWithdrawSummaryDto,
} from '@dukang/shared-types';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { request } from '../lib/api';
import { useStorePageView } from '../lib/usePageView';
type StatusFilter = 'all' | StoreWithdrawStatus;
function formatMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
export default function WithdrawPage() {
useStorePageView('store_withdraw_view');
const navigate = useNavigate();
const [summary, setSummary] = useState<StoreWithdrawSummaryDto | null>(null);
const [items, setItems] = useState<StoreWithdrawRequestDto[]>([]);
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const [submitting, setSubmitting] = useState(false);
const [msg, setMsg] = useState('');
const load = useCallback(async () => {
try {
const [summaryRes, listRes] = await Promise.all([
request<StoreWithdrawSummaryDto>('SHOP_H5', '/shop/withdraw/summary'),
request<{ items: StoreWithdrawRequestDto[] }>('SHOP_H5', '/shop/withdraw/requests?pageSize=50'),
]);
setSummary(summaryRes);
setItems(listRes.items || []);
} catch (e) {
setMsg(e instanceof Error ? e.message : '加载失败');
}
}, []);
useEffect(() => {
void load();
}, [load]);
const filtered = useMemo(() => {
if (statusFilter === 'all') return items;
return items.filter((r) => r.status === statusFilter);
}, [items, statusFilter]);
async function applyWithdraw() {
if (!summary || submitting) return;
if (!summary.isPrimary) {
setMsg('仅主账号可申请提现');
return;
}
if (!(summary.availableAmount > 0)) {
setMsg('暂无可提未出账余额');
return;
}
const ok = window.confirm(
`确认申请提现 ¥${formatMoney(summary.availableAmount)}\n审核通过后将打款至入驻收款账户。`,
);
if (!ok) return;
setSubmitting(true);
setMsg('');
try {
await request('SHOP_H5', '/shop/withdraw', {
method: 'POST',
body: JSON.stringify({}),
});
setMsg('提现申请已提交,请等待总部审核');
await load();
} catch (e) {
setMsg(e instanceof Error ? e.message : '提现申请失败');
} finally {
setSubmitting(false);
}
}
const canApply =
!!summary?.isPrimary &&
summary.availableAmount > 0 &&
!summary.hasPendingRequest &&
summary.hasBankAccount &&
!submitting;
return (
<PullToRefresh onRefresh={load} className="shop-records-page shop-withdraw-page">
<header className="shop-records-header" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
type="button"
className="shop-withdraw-back"
onClick={() => navigate(-1)}
aria-label="返回"
>
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>
arrow_back
</span>
</button>
<h1 className="app-page-title" style={{ margin: 0 }}>
</h1>
</header>
<div className="shop-records-main">
<section className="shop-records-summary">
<div className="shop-records-summary-grid">
<div>
<p className="shop-records-summary-label"></p>
<p className="shop-records-summary-value">
¥ {formatMoney(summary?.availableAmount ?? 0)}
</p>
</div>
<div>
<p className="shop-records-summary-label"></p>
<p className="shop-records-summary-value">
¥ {formatMoney(summary?.remainingDailyLimit ?? 0)}
</p>
</div>
</div>
<p className="shop-records-summary-note">
<span
className="material-symbols-outlined shop-fill-icon"
style={{ fontSize: 16, color: 'var(--color-success-green)' }}
>
info
</span>
¥{formatMoney(summary?.dailyLimit ?? 5000)}
{summary?.hasPendingRequest ? ' · 已有待审核申请' : ''}
{!summary?.hasBankAccount ? ' · 请先完善收款账户' : ''}
</p>
</section>
{summary && !summary.isPrimary ? (
<p className="shop-records-empty"></p>
) : (
<button
type="button"
className="shop-withdraw-btn"
disabled={!canApply}
onClick={() => void applyWithdraw()}
>
{submitting ? '提交中…' : '申请提现'}
</button>
)}
{msg ? <p className="shop-withdraw-msg">{msg}</p> : null}
<nav className="shop-records-filters" style={{ marginTop: 16 }}>
<div className="shop-records-status-chips">
{(
[
['all', '全部'],
['PENDING_REVIEW', '待审核'],
['PAID', '已结算'],
['REJECTED', '已驳回'],
] as const
).map(([key, label]) => (
<button
key={key}
type="button"
className={`shop-records-chip${statusFilter === key ? ' active' : ''}`}
onClick={() => setStatusFilter(key)}
>
{label}
</button>
))}
</div>
</nav>
<div className="shop-records-list-head">
<h3 className="shop-records-list-title"></h3>
<span className="shop-records-list-count"> {filtered.length} </span>
</div>
{filtered.length === 0 ? (
<p className="shop-records-empty"></p>
) : (
<div className="shop-records-list">
{filtered.map((r) => {
const status = r.status as StoreWithdrawStatus;
const badgeClass =
status === 'PAID' ? 'paid' : status === 'REJECTED' ? 'rejected' : 'pending';
return (
<article key={r.id} className="shop-record-card">
<div className="shop-record-card-top">
<div>
<div className="shop-record-order">
<span className="shop-record-time" style={{ margin: 0 }}>
</span>
<span>{r.withdrawNo}</span>
</div>
<p className="shop-record-time">
:{' '}
{new Date(r.appliedAt)
.toLocaleString('zh-CN', { hour12: false })
.slice(0, 16)}
</p>
</div>
<span className={`shop-record-badge ${badgeClass}`}>
{STORE_WITHDRAW_STATUS_LABELS[status] ?? status}
</span>
</div>
<div className="shop-record-amounts">
<div>
<p className="shop-record-amount-label"></p>
<p className="shop-record-amount-value red">¥{formatMoney(Number(r.amount))}</p>
</div>
<div>
<p className="shop-record-amount-label"></p>
<p className="shop-record-amount-value">{r.payoutCount} </p>
</div>
</div>
{status === 'REJECTED' && r.rejectReason ? (
<div className="shop-record-footer">
<p>: {r.rejectReason}</p>
</div>
) : null}
{status === 'PAID' && r.paidAt ? (
<div className="shop-record-footer">
<p>
:{' '}
{new Date(r.paidAt).toLocaleString('zh-CN', { hour12: false }).slice(0, 16)}
</p>
</div>
) : null}
</article>
);
})}
</div>
)}
</div>
</PullToRefresh>
);
}