门店页面修改

This commit is contained in:
2026-07-07 00:18:29 +08:00
parent 1fcafac2a7
commit 5d19089f91
11 changed files with 432 additions and 39 deletions
@@ -0,0 +1,40 @@
type WechatScanAuthModalProps = {
open: boolean;
loading?: boolean;
error?: string;
onAuthorize: () => void;
onCancel: () => void;
};
export default function WechatScanAuthModal({
open,
loading,
error,
onAuthorize,
onCancel,
}: WechatScanAuthModalProps) {
if (!open) return null;
return (
<div className="shop-scan-auth-overlay" role="dialog" aria-modal="true" aria-labelledby="shop-scan-auth-title">
<div className="shop-scan-auth-card">
<div className="shop-scan-auth-icon">
<span className="material-symbols-outlined shop-fill-icon">qr_code_scanner</span>
</div>
<h2 id="shop-scan-auth-title" className="shop-scan-auth-title"></h2>
<p className="shop-scan-auth-desc">
使使
</p>
{error && <p className="shop-scan-auth-error" role="alert">{error}</p>}
<div className="shop-scan-auth-actions">
<button type="button" className="shop-scan-auth-cancel" onClick={onCancel} disabled={loading}>
</button>
<button type="button" className="shop-scan-auth-confirm" onClick={onAuthorize} disabled={loading}>
{loading ? '跳转授权中…' : '微信授权'}
</button>
</div>
</div>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
/** 从微信扫码结果解析核销 token32 位 hex 或带 token 参数的 URL */
export function parseRedeemTokenFromScan(raw: string): string | null {
const trimmed = raw.trim();
if (!trimmed) return null;
if (/^[a-f0-9]{32}$/i.test(trimmed)) {
return trimmed.toLowerCase();
}
try {
const url = trimmed.startsWith('http') ? new URL(trimmed) : new URL(trimmed, 'https://local.invalid');
const fromQuery = url.searchParams.get('token');
if (fromQuery && /^[a-f0-9]{32}$/i.test(fromQuery)) {
return fromQuery.toLowerCase();
}
} catch {
/* not a URL */
}
const hexMatch = trimmed.match(/[a-f0-9]{32}/i);
return hexMatch ? hexMatch[0].toLowerCase() : null;
}
+57
View File
@@ -0,0 +1,57 @@
import type { WechatLoginResult } from '@dukang/shared-types';
import { isWechatEnv, weixinSdk } from './weixin';
import { request, saveAuth, type ShopSessionPayload } from './api';
export type ShopAccountProfile = {
id: string;
storeId: string;
name: string;
phone: string;
wxOpenId?: string | null;
store?: { name: string };
};
export async function fetchShopAccount(): Promise<ShopAccountProfile> {
return request<ShopAccountProfile>('SHOP_H5', '/shop/auth/me');
}
export function needsWechatAuth(profile: ShopAccountProfile | null): boolean {
return isWechatEnv() && !!profile && !profile.wxOpenId;
}
export function sessionFromWechatLogin(result: WechatLoginResult): ShopSessionPayload | null {
if (!result.accessToken || !result.refreshToken) return null;
const store = result.store;
return {
accessToken: result.accessToken,
refreshToken: result.refreshToken,
store: store
? {
id: String(store.id ?? ''),
storeId: String(store.storeId ?? ''),
name: String(store.name ?? ''),
phone: String(store.phone ?? ''),
storeName: String(store.storeName ?? store.name ?? ''),
}
: undefined,
};
}
export function saveShopWechatAuth(result: WechatLoginResult): boolean {
const session = sessionFromWechatLogin(result);
if (!session) return false;
saveAuth(session);
return true;
}
export async function authorizeShopWechat(): Promise<WechatLoginResult | void> {
if (!isWechatEnv()) {
throw new Error('请在微信内打开以完成授权');
}
return weixinSdk.login();
}
export async function handleShopWechatCallback(): Promise<WechatLoginResult | null> {
if (!isWechatEnv()) return null;
return weixinSdk.handleOAuthCallback();
}
+1
View File
@@ -4,6 +4,7 @@ export const weixinSdk = createWeixinSdk({
apiBase: '/api/v1',
clientApp: 'SHOP_H5',
getAccessToken: () => localStorage.getItem('accessToken'),
wechatLoginPath: '/shop/auth/login/wechat',
});
export { isWechatEnv };
+121 -20
View File
@@ -1,7 +1,20 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
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,
fetchShopAccount,
handleShopWechatCallback,
needsWechatAuth,
saveShopWechatAuth,
sessionFromWechatLogin,
} from '../lib/wechat-auth';
import { isWechatEnv, weixinSdk } from '../lib/weixin';
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 });
@@ -9,37 +22,107 @@ function formatMoney(n: number) {
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('');
useEffect(() => {
request('SHOP_H5', '/shop/dashboard').then((d) => {
request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard').then((d) => {
setDash(d);
setOpen(String((d.store as Record<string, unknown>)?.status) === 'OPEN');
});
}, []);
useEffect(() => {
if (!isWechatEnv() || !searchParams.get('code')) return;
void handleShopWechatCallback()
.then((result) => {
if (!result) return;
const session = sessionFromWechatLogin(result);
if (session) {
saveShopWechatAuth(result);
applySession(session);
}
setAuthModalOpen(false);
setAuthError('');
setSearchParams({}, { replace: true });
const shouldScan = sessionStorage.getItem(PENDING_SCAN_KEY) === '1';
sessionStorage.removeItem(PENDING_SCAN_KEY);
if (shouldScan) {
void runScan();
}
})
.catch((e) => {
setAuthError(e instanceof Error ? e.message : '微信授权失败');
});
}, [searchParams, applySession, setSearchParams]);
async function runScan() {
if (!isWechatEnv()) {
navigate('/redeem');
return;
}
setScanning(true);
setScanMsg('');
try {
await weixinSdk.init();
const raw = await weixinSdk.scanQrCode();
if (!raw) return;
const token = parseRedeemTokenFromScan(raw);
if (!token) {
setScanMsg('无法识别核销码,请扫描用户出示的核销二维码');
return;
}
navigate(`/redeem?token=${encodeURIComponent(token)}`);
} catch (e) {
setScanMsg(e instanceof Error ? e.message : '扫码失败,请重试');
} finally {
setScanning(false);
}
}
async function handleScan() {
setScanMsg('');
if (!isWechatEnv()) {
navigate('/redeem');
return;
}
try {
const profile = await fetchShopAccount();
if (needsWechatAuth(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');
async function handleScan() {
if (isWechatEnv()) {
try {
await weixinSdk.init();
const token = await weixinSdk.scanQrCode();
if (token) {
navigate(`/redeem?token=${encodeURIComponent(token)}`);
return;
}
} catch {
/* fall through to manual redeem page */
}
}
navigate('/redeem');
}
return (
<div className="shop-home-page">
<header className="shop-home-header">
@@ -68,10 +151,16 @@ export default function HomePage() {
</section>
<section className="shop-home-scan">
<button type="button" className="shop-home-scan-btn" onClick={handleScan}>
<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"></p>
<p className="shop-home-scan-label">{scanning ? '正在打开相机…' : '扫码核销'}</p>
{scanMsg && <p className="shop-home-scan-msg" role="alert">{scanMsg}</p>}
</section>
<section className="shop-home-status">
@@ -117,6 +206,18 @@ export default function HomePage() {
</div>
</section>
</div>
<WechatScanAuthModal
open={authModalOpen}
loading={authLoading}
error={authError}
onAuthorize={() => void startWechatAuth()}
onCancel={() => {
setAuthModalOpen(false);
setAuthError('');
sessionStorage.removeItem(PENDING_SCAN_KEY);
}}
/>
</div>
);
}
+4 -6
View File
@@ -9,8 +9,6 @@ function maskPhone(phone: string) {
return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`;
}
const DEV_DEFAULT_CODE = import.meta.env.DEV ? '123456' : '';
export default function LoginPage() {
const navigate = useNavigate();
const { applySession } = useStoreSession();
@@ -18,8 +16,8 @@ export default function LoginPage() {
const quick = params.get('quick') === '1';
const savedProfile = getStoreProfile();
const [phone, setPhone] = useState(getLastPhone());
const [code, setCode] = useState(DEV_DEFAULT_CODE);
const [agreed, setAgreed] = useState(false);
const [code, setCode] = useState('');
const [agreed, setAgreed] = useState(true);
const [loading, setLoading] = useState(false);
const [msg, setMsg] = useState('');
const [codeCooldown, setCodeCooldown] = useState(0);
@@ -43,7 +41,7 @@ export default function LoginPage() {
method: 'POST',
body: JSON.stringify({ phone, scene: 'STORE_LOGIN' }),
});
setMsg(import.meta.env.DEV ? '验证码已发送(Mock: 123456' : '验证码已发送');
setMsg('验证码已发送');
setCodeCooldown(60);
const timer = setInterval(() => {
setCodeCooldown((c) => {
@@ -202,7 +200,7 @@ export default function LoginPage() {
type="button"
className="shop-login-submit"
disabled={loading}
onClick={login}
onClick={() => void login()}
>
<span>{loading ? '登录中...' : '登录'}</span>
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
+1 -1
View File
@@ -60,7 +60,7 @@ export default function RedeemConfirmPage() {
return;
}
if (!token.trim()) {
setMsg('请在开发者选项中输入核销码');
setMsg('请先扫码获取核销码');
return;
}
setLoading(true);
+110 -2
View File
@@ -152,23 +152,31 @@
.shop-login-code-row {
display: flex;
gap: 8px;
align-items: stretch;
}
.shop-login-code-row .shop-login-input-wrap {
flex: 1;
min-width: 0;
}
.shop-login-code-btn {
flex-shrink: 0;
align-self: stretch;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 48px;
border: none;
border-radius: var(--radius-sm);
padding: 0 16px;
padding: 0 14px;
background: #ffdad7;
color: var(--color-heritage-red);
font-family: var(--font-label);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.05em;
letter-spacing: 0.02em;
line-height: 1.2;
cursor: pointer;
white-space: nowrap;
}
@@ -618,6 +626,106 @@
color: var(--color-heritage-red);
}
.shop-home-scan-msg {
margin-top: 8px;
font-size: 13px;
color: var(--color-heritage-red);
text-align: center;
max-width: 280px;
}
.shop-scan-auth-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(0, 0, 0, 0.45);
}
.shop-scan-auth-card {
width: 100%;
max-width: 320px;
padding: 28px 24px 24px;
border-radius: var(--radius-md);
background: var(--color-card);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
text-align: center;
}
.shop-scan-auth-icon {
width: 56px;
height: 56px;
margin: 0 auto 16px;
border-radius: 50%;
background: rgba(166, 29, 36, 0.08);
display: flex;
align-items: center;
justify-content: center;
color: var(--color-heritage-red);
}
.shop-scan-auth-icon .material-symbols-outlined {
font-size: 28px;
}
.shop-scan-auth-title {
margin: 0 0 8px;
font-family: var(--font-headline);
font-size: 18px;
font-weight: 600;
color: var(--color-on-surface);
}
.shop-scan-auth-desc {
margin: 0 0 16px;
font-size: 14px;
line-height: 1.5;
color: var(--color-on-surface-variant);
}
.shop-scan-auth-error {
margin: 0 0 12px;
font-size: 13px;
color: var(--color-heritage-red);
}
.shop-scan-auth-actions {
display: flex;
gap: 12px;
}
.shop-scan-auth-cancel,
.shop-scan-auth-confirm {
flex: 1;
min-height: 44px;
border-radius: var(--radius-sm);
font-family: var(--font-label);
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.shop-scan-auth-cancel {
border: 1px solid var(--color-outline-variant);
background: var(--color-surface);
color: var(--color-on-surface);
}
.shop-scan-auth-confirm {
border: none;
background: var(--color-heritage-red);
color: #fff;
}
.shop-scan-auth-cancel:disabled,
.shop-scan-auth-confirm:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.shop-home-status {
background: var(--color-card);
border-radius: var(--radius-md);