From 9c5cb69730bdeb9b93dcd9a0d08f48b4b824391b Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Fri, 10 Jul 2026 11:03:11 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AD=90=E8=B4=A6=E5=8F=B7=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E5=BE=AE=E4=BF=A1=E6=8E=88=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/h5-partner/src/App.tsx | 14 +- apps/h5-partner/src/PartnerAppRoutes.tsx | 15 +- apps/h5-partner/src/components/AuthGate.tsx | 29 +++ .../src/components/OssUploadField.tsx | 48 ++-- .../src/contexts/PartnerSessionContext.tsx | 141 +++++++++-- apps/h5-partner/src/lib/api.ts | 231 +++++++++++++++--- apps/h5-partner/src/lib/wechat-auth.ts | 72 ++++-- apps/h5-partner/src/pages/LoginPage.tsx | 137 ++++++----- apps/h5-partner/src/pages/StoreCreatePage.tsx | 70 +----- .../src/modules/iam/auth.controller.ts | 5 + .../src/modules/iam/auth.service.ts | 24 +- 11 files changed, 535 insertions(+), 251 deletions(-) create mode 100644 apps/h5-partner/src/components/AuthGate.tsx diff --git a/apps/h5-partner/src/App.tsx b/apps/h5-partner/src/App.tsx index d9d6182..70d0459 100644 --- a/apps/h5-partner/src/App.tsx +++ b/apps/h5-partner/src/App.tsx @@ -1,13 +1,15 @@ -import { Routes, Route, Navigate } from 'react-router-dom'; +import { Routes, Route } from 'react-router-dom'; +import AuthGate from './components/AuthGate'; import LoginPage from './pages/LoginPage'; import PartnerAppRoutes from './PartnerAppRoutes'; -import { isLoggedIn } from './lib/api'; export default function App() { return ( - - } /> - : } /> - + + + } /> + } /> + + ); } diff --git a/apps/h5-partner/src/PartnerAppRoutes.tsx b/apps/h5-partner/src/PartnerAppRoutes.tsx index 79c7c90..13671c9 100644 --- a/apps/h5-partner/src/PartnerAppRoutes.tsx +++ b/apps/h5-partner/src/PartnerAppRoutes.tsx @@ -1,6 +1,5 @@ import { Navigate, Route, Routes } from 'react-router-dom'; import { usePartnerSession } from './contexts/PartnerSessionContext'; -import { isLoggedIn } from './lib/api'; import { isSubAccount } from './lib/partnerAccess'; import TabLayout from './layouts/TabLayout'; import SubAccountLayout from './layouts/SubAccountLayout'; @@ -19,10 +18,6 @@ import LeaderboardPage from './pages/LeaderboardPage'; import StaffListPage from './pages/StaffListPage'; import StaffCreatePage from './pages/StaffCreatePage'; -function SessionLoading() { - return
加载中...
; -} - function PrimaryRoutes() { return ( @@ -61,14 +56,12 @@ function SubAccountRoutes() { } export default function PartnerAppRoutes() { - const { account, loading, loggedIn } = usePartnerSession(); + const { account, authenticated } = usePartnerSession(); - if (!loggedIn && !isLoggedIn()) { - return ; - } - if (loading) { - return ; + if (!authenticated) { + return null; } + if (isSubAccount(account)) { return ; } diff --git a/apps/h5-partner/src/components/AuthGate.tsx b/apps/h5-partner/src/components/AuthGate.tsx new file mode 100644 index 0000000..862cebe --- /dev/null +++ b/apps/h5-partner/src/components/AuthGate.tsx @@ -0,0 +1,29 @@ +import { Navigate, useLocation } from 'react-router-dom'; +import { getPartnerProfile, hasPartnerWxSession } from '../lib/api'; +import { usePartnerSession } from '../contexts/PartnerSessionContext'; +import { partnerHomePath } from '../lib/partnerAccess'; + +const PUBLIC_PATHS = new Set(['/login']); + +export default function AuthGate({ children }: { children: React.ReactNode }) { + const { ready, authenticated, account } = usePartnerSession(); + const location = useLocation(); + + if (!ready) { + return
加载中...
; + } + + if (authenticated && location.pathname === '/login') { + return ; + } + + if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) { + const profile = getPartnerProfile(); + if (profile && hasPartnerWxSession()) { + return ; + } + return ; + } + + return <>{children}; +} diff --git a/apps/h5-partner/src/components/OssUploadField.tsx b/apps/h5-partner/src/components/OssUploadField.tsx index d6fbc0f..d3a3ac7 100644 --- a/apps/h5-partner/src/components/OssUploadField.tsx +++ b/apps/h5-partner/src/components/OssUploadField.tsx @@ -1,4 +1,4 @@ -import { useEffect, useId, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { uploadFileToOss, type OssMediaType } from '../lib/upload'; import { toastError } from '../lib/toast'; import { @@ -33,11 +33,15 @@ function formatWechatUploadError(e: unknown): string { const formatted = formatChooseImageFailMessage(msg); if (formatted) return formatted; if (/invalid signature/i.test(msg)) { - return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名为 user.runxian.top,并刷新页面后重试'; + return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名,并刷新页面后重试'; } return msg; } +function acceptsImages(accept: string) { + return accept.includes('image'); +} + export default function OssUploadField({ value, onChange, @@ -50,7 +54,6 @@ export default function OssUploadField({ wechatReady, onWechatReadyChange, }: OssUploadFieldProps) { - const inputId = useId(); const inputRef = useRef(null); const [uploading, setUploading] = useState(false); const [authorizing, setAuthorizing] = useState(false); @@ -60,7 +63,9 @@ export default function OssUploadField({ const resolvedAccept = accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? 'image/*,.pdf' : 'image/*'); - const useWechatPicker = isWechatEnv() && mediaType === 'IMAGE'; + const inWechat = isWechatEnv(); + const useWechatPicker = + inWechat && (mediaType === 'IMAGE' || (mediaType === 'FILE' && acceptsImages(resolvedAccept))); const needsAuth = useWechatPicker && needsWechatAuth(profile, clientConfig) && wechatReady !== true; useEffect(() => { @@ -124,7 +129,7 @@ export default function OssUploadField({ await weixinSdk.init(); const files = await weixinSdk.chooseImages({ count: 1, - sourceType: ['album'], + sourceType: ['album', 'camera'], }); if (files?.[0]) { await uploadSelectedFile(files[0]); @@ -161,6 +166,7 @@ export default function OssUploadField({ const isImage = mediaType === 'IMAGE' && value; const isFile = mediaType === 'FILE' && value; const busy = uploading || authorizing; + const pickerLabel = label ?? (useWechatPicker ? '拍照 / 从相册选择' : '点击上传'); const triggerProps = { type: 'button' as const, @@ -172,7 +178,7 @@ export default function OssUploadField({
{needsAuth && (
-

上传照片需先完成微信授权

+

上传照片需先完成微信授权绑定

)} - { - const file = e.target.files?.[0]; - if (file) void uploadSelectedFile(file); - }} - /> + {!useWechatPicker && ( + { + const file = e.target.files?.[0]; + if (file) void uploadSelectedFile(file); + }} + /> + )} {isImage ? ( )} diff --git a/apps/h5-partner/src/contexts/PartnerSessionContext.tsx b/apps/h5-partner/src/contexts/PartnerSessionContext.tsx index b772723..2675131 100644 --- a/apps/h5-partner/src/contexts/PartnerSessionContext.tsx +++ b/apps/h5-partner/src/contexts/PartnerSessionContext.tsx @@ -1,17 +1,39 @@ -import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'; -import { toAppPath } from '@dukang/weixin-sdk'; -import { clearAuth, isLoggedIn, request } from '../lib/api'; - +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react'; +import { stripOAuthParamsFromLocation, toAppPath } from '@dukang/weixin-sdk'; +import { isWxAuthorizeEnabled } from '@dukang/shared-types'; +import { + clearAuth, + ensureSession, + request, + saveAuth, + type PartnerSessionPayload, + type PartnerSessionProfile, +} from '../lib/api'; import type { PartnerMe, PartnerStaffRole } from '@dukang/shared-types'; +import { fetchClientConfig, processPartnerWechatOAuthCallback } from '../lib/wechat-auth'; +import { isWechatEnv } from '../lib/weixin'; export type PartnerAccount = PartnerMe & { staffRole?: PartnerStaffRole; }; type PartnerSessionValue = { + ready: boolean; + authenticated: boolean; account: PartnerAccount | null; - loading: boolean; + /** @deprecated 使用 authenticated */ loggedIn: boolean; + /** @deprecated 使用 ready */ + loading: boolean; + applySession: (session: PartnerSessionPayload) => void; refresh: () => Promise; logout: () => void; }; @@ -19,46 +41,111 @@ type PartnerSessionValue = { const PartnerSessionContext = createContext(null); export function PartnerSessionProvider({ children }: { children: ReactNode }) { + const [ready, setReady] = useState(false); + const [authenticated, setAuthenticated] = useState(false); const [account, setAccount] = useState(null); - const [loading, setLoading] = useState(true); - const [loggedIn, setLoggedIn] = useState(() => isLoggedIn()); + + const applySession = useCallback((session: PartnerSessionPayload) => { + saveAuth(session); + setAuthenticated(true); + if (session.partner) { + setAccount({ + id: session.partner.id, + name: session.partner.name, + phone: session.partner.phone, + companyName: session.partner.companyName, + isPrimary: session.partner.isPrimary, + }); + } + }, []); const refresh = useCallback(async (): Promise => { - if (!isLoggedIn()) { - setAccount(null); - setLoggedIn(false); - setLoading(false); - return null; - } - setLoggedIn(true); - setLoading(true); try { - const data = await request('PARTNER_H5', '/partner/me', { silent: true }); - setAccount(data); - return data; + const result = await ensureSession(); + setAuthenticated(result.authenticated); + if (!result.authenticated || !result.partner) { + setAccount(null); + return null; + } + const me = await request('PARTNER_H5', '/partner/me', { silent: true }); + setAccount(me); + return me; } catch { setAccount(null); + setAuthenticated(false); return null; - } finally { - setLoading(false); } }, []); const logout = useCallback(() => { clearAuth(); setAccount(null); - setLoggedIn(false); + setAuthenticated(false); window.location.href = toAppPath('/login'); }, []); useEffect(() => { - void refresh(); - }, [refresh]); + let cancelled = false; + (async () => { + try { + const params = new URLSearchParams(window.location.search); + if (isWechatEnv() && params.get('code')) { + try { + const config = await fetchClientConfig(); + if (isWxAuthorizeEnabled(config)) { + const session = await processPartnerWechatOAuthCallback(); + if (session && !cancelled) { + applySession(session); + const me = await request('PARTNER_H5', '/partner/me', { silent: true }).catch(() => null); + if (me && !cancelled) setAccount(me); + } + stripOAuthParamsFromLocation(); + } + } catch { + stripOAuthParamsFromLocation(); + } + } + + const result = await ensureSession(); + if (cancelled) return; + setAuthenticated(result.authenticated); + if (result.authenticated) { + const me = await request('PARTNER_H5', '/partner/me', { silent: true }).catch(() => null); + if (!cancelled) setAccount(me); + } else { + setAccount(null); + } + } catch { + if (!cancelled) { + clearAuth({ keepProfile: true }); + setAuthenticated(false); + setAccount(null); + } + } finally { + if (!cancelled) setReady(true); + } + })(); + return () => { + cancelled = true; + }; + }, [applySession]); + + const value = useMemo( + () => ({ + ready, + authenticated, + account, + loggedIn: authenticated, + loading: !ready, + applySession, + refresh, + logout, + }), + [ready, authenticated, account, applySession, refresh, logout], + ); return ( - + {children} ); @@ -69,3 +156,5 @@ export function usePartnerSession(): PartnerSessionValue { if (!ctx) throw new Error('usePartnerSession 必须在 PartnerSessionProvider 内使用'); return ctx; } + +export type { PartnerSessionProfile }; diff --git a/apps/h5-partner/src/lib/api.ts b/apps/h5-partner/src/lib/api.ts index 0060d14..5f705a9 100644 --- a/apps/h5-partner/src/lib/api.ts +++ b/apps/h5-partner/src/lib/api.ts @@ -3,14 +3,30 @@ import { isOnAppPath, toAppPath } from '@dukang/weixin-sdk'; import { showPartnerToast } from './toast'; export const apiBase = '/api/v1'; +const CLIENT_APP = 'PARTNER_H5'; +const ACCESS_TOKEN = 'accessToken'; +const REFRESH_TOKEN = 'refreshToken'; const LAST_PHONE = 'partnerLastPhone'; const PARTNER_PROFILE = 'partnerProfile'; +const SESSION_EXPIRES_AT = 'partnerSessionExpiresAt'; +export const PARTNER_WX_BOUND = 'partnerWxBound'; -export type PartnerSessionProfile = Pick; +/** 微信验证通过后的免登录时长 */ +export const PARTNER_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; -export type PartnerAuthPayload = { +const AUTH_RECOVERY_EXEMPT_PATHS = [ + '/partner/auth/token/refresh', + '/partner/auth/sms/send', + '/partner/auth/login/sms', + '/partner/auth/login/wechat', +]; + +export type PartnerSessionProfile = Pick; + +export type PartnerSessionPayload = { accessToken: string; + refreshToken: string; partner?: PartnerSessionProfile; }; @@ -32,53 +48,188 @@ export function getPartnerProfile(): PartnerSessionProfile | null { } } -export async function request( - clientApp: string, - path: string, - options: ApiRequestOptions = {}, -): Promise { - const { silent, ...fetchOptions } = options; - const token = localStorage.getItem('accessToken'); - const headers: Record = { - 'Content-Type': 'application/json', - 'X-Client-App': clientApp, - ...(fetchOptions.headers as Record), - }; - if (token) headers.Authorization = `Bearer ${token}`; - const res = await fetch(`${apiBase}${path}`, { ...fetchOptions, headers }); - const json = await res.json().catch(() => ({ code: res.status, message: '网络异常' })); - const message = json.message || (res.status === 401 ? '登录已过期,请重新登录' : '请求失败'); - - if (res.status === 401 || json.code === 401) { - if (token && localStorage.getItem('accessToken') === token) { - clearAuth(); - if (!silent) showPartnerToast(message, 'error'); - if (typeof window !== 'undefined' && !isOnAppPath('/login')) { - window.location.href = toAppPath('/login'); - } - } - throw new Error(message); - } - if (json.code !== 0) { - if (!silent) showPartnerToast(message, 'error'); - throw new Error(message); - } - return json.data as T; +export function hasPartnerWxSession() { + return localStorage.getItem(PARTNER_WX_BOUND) === '1'; } -export function saveAuth(data: PartnerAuthPayload) { - localStorage.setItem('accessToken', data.accessToken); +export function isPartnerSessionExpired() { + const raw = localStorage.getItem(SESSION_EXPIRES_AT); + if (!raw) return false; + return Date.now() > Number(raw); +} + +export function touchPartnerSession() { + if (!hasPartnerWxSession()) return; + localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS)); +} + +export function saveAuth(data: PartnerSessionPayload) { + localStorage.setItem(ACCESS_TOKEN, data.accessToken); + if (data.refreshToken) localStorage.setItem(REFRESH_TOKEN, data.refreshToken); if (data.partner) { localStorage.setItem(PARTNER_PROFILE, JSON.stringify(data.partner)); localStorage.setItem(LAST_PHONE, data.partner.phone); } } -export function clearAuth() { - localStorage.removeItem('accessToken'); - localStorage.removeItem(PARTNER_PROFILE); +/** 微信登录/绑定成功后写入 7 天免登录 session */ +export function saveWechatSession(data: PartnerSessionPayload) { + saveAuth(data); + localStorage.setItem(PARTNER_WX_BOUND, '1'); + localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS)); +} + +export function clearAuth(options?: { keepProfile?: boolean }) { + localStorage.removeItem(ACCESS_TOKEN); + localStorage.removeItem(REFRESH_TOKEN); + localStorage.removeItem(SESSION_EXPIRES_AT); + localStorage.removeItem(PARTNER_WX_BOUND); + if (!options?.keepProfile) { + localStorage.removeItem(PARTNER_PROFILE); + localStorage.removeItem(LAST_PHONE); + } } export function isLoggedIn() { - return !!localStorage.getItem('accessToken'); + return !!localStorage.getItem(ACCESS_TOKEN); } + +function profileFromMe(me: PartnerMe): PartnerSessionProfile { + return { + id: me.id, + name: me.name, + phone: me.phone, + companyName: me.companyName, + isPrimary: me.isPrimary, + }; +} + +async function rawRequest( + path: string, + options: RequestInit = {}, + token?: string | null, +): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + 'X-Client-App': CLIENT_APP, + ...(options.headers as Record), + }; + const authToken = token ?? localStorage.getItem(ACCESS_TOKEN); + if (authToken) headers.Authorization = `Bearer ${authToken}`; + + const res = await fetch(`${apiBase}${path}`, { ...options, headers }); + const json = await res.json().catch(() => ({ code: res.status, message: '网络异常' })); + if (json.code !== 0) { + const err = new Error(json.message || '请求失败') as Error & { status?: number }; + err.status = json.code === 401 ? 401 : json.code; + throw err; + } + return json.data as T; +} + +async function refreshSession(): Promise { + const refreshToken = localStorage.getItem(REFRESH_TOKEN); + if (!refreshToken) return null; + try { + const data = await rawRequest( + '/partner/auth/token/refresh', + { + method: 'POST', + body: JSON.stringify({ refreshToken }), + }, + null, + ); + saveAuth(data); + touchPartnerSession(); + return data; + } catch { + return null; + } +} + +async function requestWithAuthRetry( + path: string, + options: RequestInit = {}, + retried = false, +): Promise { + try { + return await rawRequest(path, options); + } catch (e) { + const err = e as Error & { status?: number }; + const canRecover = + err.status === 401 && + !retried && + !AUTH_RECOVERY_EXEMPT_PATHS.some((p) => path.startsWith(p)); + if (!canRecover) throw e; + const refreshed = await refreshSession(); + if (!refreshed) { + clearAuth({ keepProfile: true }); + throw e; + } + return requestWithAuthRetry(path, options, true); + } +} + +export async function request( + clientApp: string, + path: string, + options: ApiRequestOptions = {}, +): Promise { + void clientApp; + const { silent, ...fetchOptions } = options; + try { + return await requestWithAuthRetry(path, fetchOptions); + } catch (e) { + const err = e as Error & { status?: number }; + const message = err.message || '请求失败'; + if (err.status === 401) { + if (localStorage.getItem(ACCESS_TOKEN)) { + clearAuth({ keepProfile: true }); + if (!silent) showPartnerToast(message, 'error'); + if (typeof window !== 'undefined' && !isOnAppPath('/login')) { + window.location.href = toAppPath('/login'); + } + } + } else if (!silent) { + showPartnerToast(message, 'error'); + } + throw e; + } +} + +export async function ensureSession(): Promise<{ authenticated: boolean; partner: PartnerSessionProfile | null }> { + if (!isLoggedIn()) { + return { authenticated: false, partner: null }; + } + if (isPartnerSessionExpired()) { + clearAuth({ keepProfile: true }); + return { authenticated: false, partner: getPartnerProfile() }; + } + try { + const me = await rawRequest('/partner/me'); + const partner = profileFromMe(me); + saveAuth({ + accessToken: localStorage.getItem(ACCESS_TOKEN) ?? '', + refreshToken: localStorage.getItem(REFRESH_TOKEN) ?? '', + partner, + }); + touchPartnerSession(); + return { authenticated: true, partner }; + } catch (e) { + const err = e as Error & { status?: number }; + if (err.status === 401) { + const refreshed = await refreshSession(); + if (refreshed?.partner) { + return { authenticated: true, partner: refreshed.partner }; + } + clearAuth({ keepProfile: true }); + return { authenticated: false, partner: getPartnerProfile() }; + } + const cached = getPartnerProfile(); + if (cached) return { authenticated: true, partner: cached }; + throw e; + } +} + +/** @deprecated 使用 PartnerSessionPayload */ +export type PartnerAuthPayload = PartnerSessionPayload; diff --git a/apps/h5-partner/src/lib/wechat-auth.ts b/apps/h5-partner/src/lib/wechat-auth.ts index ab434ab..d7986cb 100644 --- a/apps/h5-partner/src/lib/wechat-auth.ts +++ b/apps/h5-partner/src/lib/wechat-auth.ts @@ -2,14 +2,15 @@ import type { ClientRuntimeConfig, WechatLoginResult } from '@dukang/shared-type import { isWxAuthorizeEnabled } from '@dukang/shared-types'; import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk'; import { isWechatEnv, weixinSdk } from './weixin'; -import { request, saveAuth } from './api'; +import { request, saveWechatSession, type PartnerSessionPayload } from './api'; export type PartnerProfile = { id: string; name: string; phone: string; - companyName: string; + companyName?: string; hasWechat?: boolean; + isPrimary?: boolean; }; export async function fetchClientConfig(): Promise { @@ -20,7 +21,7 @@ export async function fetchPartnerProfile(): Promise { return request('PARTNER_H5', '/partner/me'); } -/** 微信内上传照片前需完成公众号授权绑定 */ +/** 微信内上传照片前需完成公众号 OAuth 绑定 */ export function needsWechatAuth( profile: PartnerProfile | null, config?: Pick | null, @@ -34,24 +35,30 @@ export async function checkNeedsWechatAuth(profile: PartnerProfile | null): Prom return needsWechatAuth(profile, config); } -/** 处理微信登录/绑定结果,返回是否已拿到 token 可进入首页 */ -export function handlePartnerWechatLoginResult(result: WechatLoginResult): boolean { - if (!result.accessToken) return false; - saveAuth({ accessToken: result.accessToken }); - return true; +export function sessionFromWechatLogin(result: WechatLoginResult): PartnerSessionPayload | null { + if (!result.accessToken || !result.refreshToken) return null; + const partner = result.partner; + return { + accessToken: result.accessToken, + refreshToken: result.refreshToken, + partner: partner + ? { + id: String(partner.id ?? ''), + name: String(partner.name ?? ''), + phone: String(partner.phone ?? ''), + companyName: partner.companyName ? String(partner.companyName) : undefined, + isPrimary: partner.isPrimary !== false, + } + : undefined, + }; } -/** 微信登录成功后拉取并缓存合伙人资料,供一键登录页展示 */ -export async function persistPartnerProfileAfterLogin(): Promise { - try { - const profile = await fetchPartnerProfile(); - saveAuth({ - accessToken: localStorage.getItem('accessToken') ?? '', - partner: profile, - }); - } catch { - /* ignore */ - } +/** 处理微信登录/绑定结果,写入 7 天免登录 session */ +export function handlePartnerWechatLoginResult(result: WechatLoginResult): PartnerSessionPayload | null { + const session = sessionFromWechatLogin(result); + if (!session) return null; + saveWechatSession(session); + return session; } export async function handlePartnerWechatCallback(): Promise { @@ -62,12 +69,12 @@ export async function handlePartnerWechatCallback(): Promise { +export async function loginPartnerWithWechat(): Promise { const config = await fetchClientConfig(); - if (!isWxAuthorizeEnabled(config)) return false; + if (!isWxAuthorizeEnabled(config)) return null; if (!isWechatEnv()) { throw new Error(WECHAT_INAPP_REQUIRED_MSG); } @@ -75,6 +82,14 @@ export async function loginPartnerWithWechat(): Promise { if (result) return handlePartnerWechatLoginResult(result); } +/** 短信登录成功后于微信内自动发起 OAuth,绑定 openId 便于后续免登 */ +export async function bindPartnerWechatAfterSmsLogin(): Promise { + const config = await fetchClientConfig(); + if (!isWxAuthorizeEnabled(config)) return; + if (!isWechatEnv()) return; + await weixinSdk.login(); +} + export async function authorizePartnerWechat(): Promise { const config = await fetchClientConfig(); if (!isWxAuthorizeEnabled(config)) return; @@ -84,7 +99,14 @@ export async function authorizePartnerWechat(): Promise { + const result = await handlePartnerWechatCallback(); + if (!result) return null; return handlePartnerWechatLoginResult(result); } + +/** @deprecated 使用 handlePartnerWechatLoginResult */ +export function savePartnerWechatAuth(result: WechatLoginResult): boolean { + return !!handlePartnerWechatLoginResult(result); +} diff --git a/apps/h5-partner/src/pages/LoginPage.tsx b/apps/h5-partner/src/pages/LoginPage.tsx index cdb4a7c..04088a2 100644 --- a/apps/h5-partner/src/pages/LoginPage.tsx +++ b/apps/h5-partner/src/pages/LoginPage.tsx @@ -1,18 +1,23 @@ import { useEffect, useState } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; -import { request, getLastPhone, getPartnerProfile, saveAuth } from '../lib/api'; +import { isWxAuthorizeEnabled } from '@dukang/shared-types'; +import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk'; +import { + getLastPhone, + getPartnerProfile, + hasPartnerWxSession, + request, + saveAuth, + type PartnerSessionPayload, +} from '../lib/api'; import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { partnerHomePath } from '../lib/partnerAccess'; -import type { PartnerMe } from '@dukang/shared-types'; import { + bindPartnerWechatAfterSmsLogin, fetchClientConfig, - handlePartnerWechatCallback, - handlePartnerWechatLoginResult, loginPartnerWithWechat, } from '../lib/wechat-auth'; import { isWechatEnv } from '../lib/weixin'; -import { isWxAuthorizeEnabled } from '@dukang/shared-types'; -import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk'; import { toastError } from '../lib/toast'; const REMEMBER_PHONE_KEY = 'partner_remember_phone'; @@ -44,9 +49,17 @@ function formatPartnerError(e: unknown): string { return text; } +function formatWechatError(e: unknown): string { + const text = e instanceof Error ? e.message : '微信登录失败'; + if (text.includes('首次登录') || text.includes('手机验证码')) { + return '该微信尚未绑定合伙人账号,请先使用手机验证码登录,登录后将自动关联微信'; + } + return text; +} + export default function LoginPage() { const navigate = useNavigate(); - const { refresh } = usePartnerSession(); + const { applySession, refresh, account } = usePartnerSession(); const [params] = useSearchParams(); const quick = params.get('quick') === '1'; const savedProfile = getPartnerProfile(); @@ -71,26 +84,6 @@ export default function LoginPage() { const quickCompany = savedProfile?.companyName ?? ''; const quickPhone = savedProfile?.phone || phone; - useEffect(() => { - if (!isWechatEnv() || !wxAuthorize || !params.get('code')) return; - void handlePartnerWechatCallback() - .then(async (result) => { - if (!result) return; - if (!handlePartnerWechatLoginResult(result)) return; - const account = await refresh(); - navigate(partnerHomePath(account ?? undefined)); - }) - .catch((e) => setMsg(formatWechatError(e))); - }, [navigate, refresh, wxAuthorize, params]); - - function formatWechatError(e: unknown): string { - const text = e instanceof Error ? e.message : '微信登录失败'; - if (text.includes('首次登录') || text.includes('手机验证码')) { - return '该微信尚未绑定合伙人账号,请先使用手机验证码登录,登录后将自动关联微信'; - } - return text; - } - function ensureAgreed() { if (!agreed) { const text = '请先勾选并同意用户协议'; @@ -142,28 +135,30 @@ export default function LoginPage() { } } - async function login(options?: { quick?: boolean }) { - if (!options?.quick && !ensureAgreed()) return; + async function finishLoginNavigate() { + const me = await refresh(); + navigate(partnerHomePath(me ?? account ?? savedProfile)); + } + + async function login() { + if (!ensureAgreed()) return; setLoading(true); setMsg(''); try { - if (options?.quick) { - await request('PARTNER_H5', '/partner/auth/sms/send', { - method: 'POST', - body: JSON.stringify({ phone: quickPhone, scene: 'PARTNER_LOGIN' }), - silent: true, - }); - } - const loginPhone = options?.quick ? quickPhone : phone; - const data = await request<{ accessToken: string; partner?: PartnerMe }>('PARTNER_H5', '/partner/auth/login/sms', { + const data = await request('PARTNER_H5', '/partner/auth/login/sms', { method: 'POST', - body: JSON.stringify({ phone: loginPhone, code }), + body: JSON.stringify({ phone, code }), silent: true, }); saveAuth(data); - persistRememberAccount(loginPhone); - const account = await refresh(); - navigate(partnerHomePath(account ?? data.partner)); + applySession(data); + persistRememberAccount(phone); + if (isWechatEnv() && wxAuthorize) { + setMsg('登录成功,正在关联微信…'); + await bindPartnerWechatAfterSmsLogin(); + return; + } + await finishLoginNavigate(); } catch (e) { const text = formatPartnerError(e); setMsg(text); @@ -183,10 +178,10 @@ export default function LoginPage() { } setWxLoading(true); try { - const ok = await loginPartnerWithWechat(); - if (ok) { - const account = await refresh(); - navigate(partnerHomePath(account ?? undefined)); + const session = await loginPartnerWithWechat(); + if (session) { + applySession(session); + await finishLoginNavigate(); } } catch (e) { const text = formatWechatError(e); @@ -198,6 +193,8 @@ export default function LoginPage() { } if (quick) { + const canWechatQuick = wxAuthorize && isWechatEnv() && hasPartnerWxSession() && !!savedProfile; + return (
@@ -228,17 +225,37 @@ export default function LoginPage() {
verified_user - Secured by Dukang Heritage + + {canWechatQuick ? '微信验证 · 7 天内免登录' : 'Secured by Dukang Heritage'} +
@@ -308,13 +325,13 @@ export default function LoginPage() { {wxAuthorize && ( <> -
其他登录方式
- +
其他登录方式
+ )} @@ -327,7 +344,9 @@ export default function LoginPage() {
- 快捷登录 + {hasPartnerWxSession() && savedProfile && ( + 微信快捷登录 + )}