From 5d19089f912a2bb5d9d110cd55d319955e601226 Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Tue, 7 Jul 2026 00:18:29 +0800 Subject: [PATCH] =?UTF-8?q?=E9=97=A8=E5=BA=97=E9=A1=B5=E9=9D=A2=E4=BF=AE?= =?UTF-8?q?=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/WechatScanAuthModal.tsx | 40 +++++ apps/h5-shop/src/lib/redeem-scan.ts | 22 +++ apps/h5-shop/src/lib/wechat-auth.ts | 57 +++++++ apps/h5-shop/src/lib/weixin.ts | 1 + apps/h5-shop/src/pages/HomePage.tsx | 141 +++++++++++++++--- apps/h5-shop/src/pages/LoginPage.tsx | 10 +- apps/h5-shop/src/pages/RedeemConfirmPage.tsx | 2 +- apps/h5-shop/src/styles.css | 112 +++++++++++++- packages/weixin-sdk/src/scan.ts | 30 ++-- .../src/modules/iam/auth.controller.ts | 12 +- .../src/modules/iam/auth.service.ts | 44 ++++++ 11 files changed, 432 insertions(+), 39 deletions(-) create mode 100644 apps/h5-shop/src/components/WechatScanAuthModal.tsx create mode 100644 apps/h5-shop/src/lib/redeem-scan.ts create mode 100644 apps/h5-shop/src/lib/wechat-auth.ts diff --git a/apps/h5-shop/src/components/WechatScanAuthModal.tsx b/apps/h5-shop/src/components/WechatScanAuthModal.tsx new file mode 100644 index 0000000..d557326 --- /dev/null +++ b/apps/h5-shop/src/components/WechatScanAuthModal.tsx @@ -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 ( +
+
+
+ qr_code_scanner +
+

微信授权

+

+ 扫码核销需使用微信相机扫描用户核销码。请先完成微信授权并允许使用摄像头。 +

+ {error &&

{error}

} +
+ + +
+
+
+ ); +} diff --git a/apps/h5-shop/src/lib/redeem-scan.ts b/apps/h5-shop/src/lib/redeem-scan.ts new file mode 100644 index 0000000..ff640d6 --- /dev/null +++ b/apps/h5-shop/src/lib/redeem-scan.ts @@ -0,0 +1,22 @@ +/** 从微信扫码结果解析核销 token(32 位 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; +} diff --git a/apps/h5-shop/src/lib/wechat-auth.ts b/apps/h5-shop/src/lib/wechat-auth.ts new file mode 100644 index 0000000..395e3b2 --- /dev/null +++ b/apps/h5-shop/src/lib/wechat-auth.ts @@ -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 { + return request('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 { + if (!isWechatEnv()) { + throw new Error('请在微信内打开以完成授权'); + } + return weixinSdk.login(); +} + +export async function handleShopWechatCallback(): Promise { + if (!isWechatEnv()) return null; + return weixinSdk.handleOAuthCallback(); +} diff --git a/apps/h5-shop/src/lib/weixin.ts b/apps/h5-shop/src/lib/weixin.ts index 48349a4..861394f 100644 --- a/apps/h5-shop/src/lib/weixin.ts +++ b/apps/h5-shop/src/lib/weixin.ts @@ -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 }; diff --git a/apps/h5-shop/src/pages/HomePage.tsx b/apps/h5-shop/src/pages/HomePage.tsx index 7488168..517abd7 100644 --- a/apps/h5-shop/src/pages/HomePage.tsx +++ b/apps/h5-shop/src/pages/HomePage.tsx @@ -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 | 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>('SHOP_H5', '/shop/dashboard').then((d) => { setDash(d); setOpen(String((d.store as Record)?.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 | undefined; const recent = (dash?.recentRecords as Array>) || []; 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 (
@@ -68,10 +151,16 @@ export default function HomePage() {
- -

扫码核销

+

{scanning ? '正在打开相机…' : '扫码核销'}

+ {scanMsg &&

{scanMsg}

}
@@ -117,6 +206,18 @@ export default function HomePage() {
+ + void startWechatAuth()} + onCancel={() => { + setAuthModalOpen(false); + setAuthError(''); + sessionStorage.removeItem(PENDING_SCAN_KEY); + }} + /> ); } diff --git a/apps/h5-shop/src/pages/LoginPage.tsx b/apps/h5-shop/src/pages/LoginPage.tsx index ca21971..afc6341 100644 --- a/apps/h5-shop/src/pages/LoginPage.tsx +++ b/apps/h5-shop/src/pages/LoginPage.tsx @@ -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()} > {loading ? '登录中...' : '登录'} {!loading && arrow_forward} diff --git a/apps/h5-shop/src/pages/RedeemConfirmPage.tsx b/apps/h5-shop/src/pages/RedeemConfirmPage.tsx index 3f77ebf..910dfd3 100644 --- a/apps/h5-shop/src/pages/RedeemConfirmPage.tsx +++ b/apps/h5-shop/src/pages/RedeemConfirmPage.tsx @@ -60,7 +60,7 @@ export default function RedeemConfirmPage() { return; } if (!token.trim()) { - setMsg('请在开发者选项中输入核销码'); + setMsg('请先扫码获取核销码'); return; } setLoading(true); diff --git a/apps/h5-shop/src/styles.css b/apps/h5-shop/src/styles.css index 9ba2fd5..5e4fda1 100644 --- a/apps/h5-shop/src/styles.css +++ b/apps/h5-shop/src/styles.css @@ -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); diff --git a/packages/weixin-sdk/src/scan.ts b/packages/weixin-sdk/src/scan.ts index 9844df2..5c9966a 100644 --- a/packages/weixin-sdk/src/scan.ts +++ b/packages/weixin-sdk/src/scan.ts @@ -23,16 +23,28 @@ export async function scanQrCode(config: WeixinSdkConfig): Promise { - window.wx!.scanQRCode!({ - needResult: 1, - scanType: ['qrCode', 'barCode'], - success: (res) => resolve(res.resultStr || null), - fail: () => resolve(null), - }); - }); + if (!window.wx?.scanQRCode) { + throw new Error('当前微信版本不支持扫码,请升级微信后重试'); } + return new Promise((resolve, reject) => { + window.wx!.scanQRCode!({ + needResult: 1, + scanType: ['qrCode', 'barCode'], + success: (res) => resolve(res.resultStr || null), + fail: (res) => { + const msg = res.errMsg || '扫码失败'; + if (/cancel/i.test(msg)) { + resolve(null); + return; + } + if (/permission|auth|denied|授权|拒绝|camera/i.test(msg)) { + reject(new Error('相机权限未开启,请在微信设置中允许使用摄像头')); + return; + } + reject(new Error(msg)); + }, + }); + }); } const manual = typeof window !== 'undefined' ? window.prompt('当前环境无法调起微信扫码,请手动输入核销码') : null; diff --git a/server/dukang-api/src/modules/iam/auth.controller.ts b/server/dukang-api/src/modules/iam/auth.controller.ts index ef2ca54..f6da4b6 100644 --- a/server/dukang-api/src/modules/iam/auth.controller.ts +++ b/server/dukang-api/src/modules/iam/auth.controller.ts @@ -112,7 +112,17 @@ export class ShopAuthController { } @Post('login/wechat') - wechatLogin(@Body() dto: LoginWechatDto) { + @UseGuards(OptionalJwtAuthGuard) + wechatLogin(@Req() req: Request, @Body() dto: LoginWechatDto) { + const user = (req as Request & { user?: AuthUser }).user; + if (user?.actorType === 'STORE') { + return this.authService.bindStoreWechat( + user.actorId, + dto.code, + ClientApp.SHOP_H5, + dto.platform ?? 'h5', + ); + } return this.authService.loginStoreWechat(dto.code, ClientApp.SHOP_H5, dto.platform ?? 'h5'); } diff --git a/server/dukang-api/src/modules/iam/auth.service.ts b/server/dukang-api/src/modules/iam/auth.service.ts index 4e8bcf7..361a4b3 100644 --- a/server/dukang-api/src/modules/iam/auth.service.ts +++ b/server/dukang-api/src/modules/iam/auth.service.ts @@ -837,6 +837,50 @@ export class AuthService { }); } + async bindStoreWechat( + storeAccountId: bigint, + code: string, + clientApp: ClientApp, + platform: 'h5' | 'mini' = 'h5', + ) { + this.assertWechatEnabled(); + const session = + platform === 'mini' + ? await this.wechatProvider.code2Session(code) + : await this.wechatProvider.oauth2AccessToken(code); + + const account = await this.prisma.storeAccount.findUnique({ + where: { id: storeAccountId }, + include: { store: true }, + }); + if (!account) throw new BadRequestException('门店账号不存在'); + + const conflict = await this.prisma.storeAccount.findFirst({ + where: { wxOpenId: session.openId, id: { not: storeAccountId } }, + }); + if (conflict) { + throw new BadRequestException('该微信已绑定其他门店账号'); + } + + const updated = await this.prisma.storeAccount.update({ + where: { id: storeAccountId }, + data: { + wxOpenId: session.openId, + wxUnionId: session.unionId ?? account.wxUnionId, + lastLoginAt: new Date(), + }, + include: { store: true }, + }); + + return this.issueToken('STORE', updated.id, clientApp, false, undefined, { + id: updated.id.toString(), + storeId: updated.storeId.toString(), + name: updated.name, + phone: updated.phone, + storeName: updated.store.name, + }); + } + async bindPartnerWechat( partnerAccountId: bigint, code: string,