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() {
-
@@ -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,