diff --git a/apps/h5-partner/src/App.tsx b/apps/h5-partner/src/App.tsx index ba2dd67..d9d6182 100644 --- a/apps/h5-partner/src/App.tsx +++ b/apps/h5-partner/src/App.tsx @@ -1,44 +1,13 @@ -import { Routes, Route, Navigate } from 'react-router-dom'; -import { useEffect } from 'react'; -import LoginPage from './pages/LoginPage'; -import PartnerAppRoutes from './PartnerAppRoutes'; -import { handlePartnerWechatCallback, handlePartnerWechatLoginResult } from './lib/wechat-auth'; -import { isLoggedIn } from './lib/api'; -import { isWechatEnv } from './lib/weixin'; -import { useLocation, useNavigate } from 'react-router-dom'; - -function WechatOAuthHandler() { - const location = useLocation(); - const navigate = useNavigate(); - - useEffect(() => { - if (!isWechatEnv() || !location.search.includes('code=')) return; - if (location.pathname === '/login') return; - void handlePartnerWechatCallback() - .then((result) => { - if (!result || !handlePartnerWechatLoginResult(result)) return; - const params = new URLSearchParams(location.search); - params.delete('code'); - params.delete('state'); - const qs = params.toString(); - navigate(`${location.pathname}${qs ? `?${qs}` : ''}`, { replace: true }); - }) - .catch(() => { - /* 页面内组件会提示 */ - }); - }, [location.pathname, location.search, navigate]); - - return null; -} - -export default function App() { - return ( - <> - - - } /> - : } /> - - - ); -} +import { Routes, Route, Navigate } from 'react-router-dom'; +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/components/OssUploadField.tsx b/apps/h5-partner/src/components/OssUploadField.tsx index 12e8d74..d6fbc0f 100644 --- a/apps/h5-partner/src/components/OssUploadField.tsx +++ b/apps/h5-partner/src/components/OssUploadField.tsx @@ -1,5 +1,6 @@ import { useEffect, useId, useRef, useState } from 'react'; import { uploadFileToOss, type OssMediaType } from '../lib/upload'; +import { toastError } from '../lib/toast'; import { authorizePartnerWechat, fetchClientConfig, @@ -84,7 +85,9 @@ export default function OssUploadField({ async function uploadSelectedFile(file: File) { if (file.size > DEFAULT_MAX_MB * 1024 * 1024) { - setError(`文件不能超过 ${DEFAULT_MAX_MB}MB`); + const text = `文件不能超过 ${DEFAULT_MAX_MB}MB`; + setError(text); + toastError(text); return; } setUploading(true); @@ -106,7 +109,9 @@ export default function OssUploadField({ try { await authorizePartnerWechat(); } catch (e) { - setError(e instanceof Error ? e.message : '微信授权失败'); + const text = e instanceof Error ? e.message : '微信授权失败'; + setError(text); + toastError(text); setAuthorizing(false); } } @@ -133,7 +138,9 @@ export default function OssUploadField({ setError(''); if (useWechatPicker && needsAuth) { - setError('请先完成微信授权后再上传照片'); + const text = '请先完成微信授权后再上传照片'; + setError(text); + toastError(text); return; } diff --git a/apps/h5-partner/src/contexts/PartnerSessionContext.tsx b/apps/h5-partner/src/contexts/PartnerSessionContext.tsx index 04b85a2..b772723 100644 --- a/apps/h5-partner/src/contexts/PartnerSessionContext.tsx +++ b/apps/h5-partner/src/contexts/PartnerSessionContext.tsx @@ -12,7 +12,7 @@ type PartnerSessionValue = { account: PartnerAccount | null; loading: boolean; loggedIn: boolean; - refresh: () => Promise; + refresh: () => Promise; logout: () => void; }; @@ -21,18 +21,24 @@ const PartnerSessionContext = createContext(null); export function PartnerSessionProvider({ children }: { children: ReactNode }) { const [account, setAccount] = useState(null); const [loading, setLoading] = useState(true); + const [loggedIn, setLoggedIn] = useState(() => isLoggedIn()); - const refresh = useCallback(async () => { + const refresh = useCallback(async (): Promise => { if (!isLoggedIn()) { setAccount(null); + setLoggedIn(false); setLoading(false); - return; + return null; } + setLoggedIn(true); + setLoading(true); try { - const data = await request('PARTNER_H5', '/partner/me'); + const data = await request('PARTNER_H5', '/partner/me', { silent: true }); setAccount(data); + return data; } catch { setAccount(null); + return null; } finally { setLoading(false); } @@ -41,6 +47,7 @@ export function PartnerSessionProvider({ children }: { children: ReactNode }) { const logout = useCallback(() => { clearAuth(); setAccount(null); + setLoggedIn(false); window.location.href = toAppPath('/login'); }, []); @@ -50,7 +57,7 @@ export function PartnerSessionProvider({ children }: { children: ReactNode }) { return ( {children} diff --git a/apps/h5-partner/src/contexts/PartnerToastContext.tsx b/apps/h5-partner/src/contexts/PartnerToastContext.tsx new file mode 100644 index 0000000..20ba362 --- /dev/null +++ b/apps/h5-partner/src/contexts/PartnerToastContext.tsx @@ -0,0 +1,53 @@ +import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react'; +import { registerPartnerToastListener, type PartnerToastVariant } from '../lib/toast'; + +type PartnerToastContextValue = { + showToast: (message: string, variant?: PartnerToastVariant) => void; +}; + +const PartnerToastContext = createContext(null); + +export function PartnerToastProvider({ children }: { children: ReactNode }) { + const [toast, setToast] = useState(''); + const [variant, setVariant] = useState('success'); + const timerRef = useRef(null); + + const showToast = useCallback((message: string, nextVariant: PartnerToastVariant = 'success') => { + if (timerRef.current) window.clearTimeout(timerRef.current); + setVariant(nextVariant); + setToast(message); + timerRef.current = window.setTimeout(() => { + setToast(''); + timerRef.current = null; + }, nextVariant === 'error' ? 3000 : 2000); + }, []); + + useEffect(() => { + registerPartnerToastListener(showToast); + return () => { + registerPartnerToastListener(null); + if (timerRef.current) window.clearTimeout(timerRef.current); + }; + }, [showToast]); + + return ( + + {children} + {toast ? ( +
+ {toast} +
+ ) : null} +
+ ); +} + +export function usePartnerToast(): PartnerToastContextValue { + const ctx = useContext(PartnerToastContext); + if (!ctx) throw new Error('usePartnerToast 必须在 PartnerToastProvider 内使用'); + return ctx; +} diff --git a/apps/h5-partner/src/lib/api.ts b/apps/h5-partner/src/lib/api.ts index 16df1e6..0060d14 100644 --- a/apps/h5-partner/src/lib/api.ts +++ b/apps/h5-partner/src/lib/api.ts @@ -1,34 +1,82 @@ +import type { PartnerMe } from '@dukang/shared-types'; import { isOnAppPath, toAppPath } from '@dukang/weixin-sdk'; +import { showPartnerToast } from './toast'; export const apiBase = '/api/v1'; -export async function request(clientApp: string, path: string, options: RequestInit = {}): Promise { +const LAST_PHONE = 'partnerLastPhone'; +const PARTNER_PROFILE = 'partnerProfile'; + +export type PartnerSessionProfile = Pick; + +export type PartnerAuthPayload = { + accessToken: string; + partner?: PartnerSessionProfile; +}; + +export type ApiRequestOptions = RequestInit & { + /** 为 true 时不弹出 toast(由调用方自行展示) */ + silent?: boolean; +}; + +export function getLastPhone() { + return localStorage.getItem(LAST_PHONE) ?? ''; +} + +export function getPartnerProfile(): PartnerSessionProfile | null { + try { + const raw = localStorage.getItem(PARTNER_PROFILE); + return raw ? (JSON.parse(raw) as PartnerSessionProfile) : null; + } catch { + return 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, - ...(options.headers as Record), + ...(fetchOptions.headers as Record), }; if (token) headers.Authorization = `Bearer ${token}`; - const res = await fetch(`${apiBase}${path}`, { ...options, headers }); + 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) { - clearAuth(); - if (typeof window !== 'undefined' && !isOnAppPath('/login')) { - window.location.href = toAppPath('/login'); + 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(json.message || '登录已过期,请重新登录'); + throw new Error(message); + } + if (json.code !== 0) { + if (!silent) showPartnerToast(message, 'error'); + throw new Error(message); } - if (json.code !== 0) throw new Error(json.message || '请求失败'); return json.data as T; } -export function saveAuth(data: { accessToken: string }) { +export function saveAuth(data: PartnerAuthPayload) { localStorage.setItem('accessToken', data.accessToken); + 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); } export function isLoggedIn() { diff --git a/apps/h5-partner/src/lib/toast.ts b/apps/h5-partner/src/lib/toast.ts new file mode 100644 index 0000000..9a434ea --- /dev/null +++ b/apps/h5-partner/src/lib/toast.ts @@ -0,0 +1,23 @@ +export type PartnerToastVariant = 'success' | 'error'; + +type PartnerToastListener = (message: string, variant: PartnerToastVariant) => void; + +let listener: PartnerToastListener | null = null; + +export function registerPartnerToastListener(fn: PartnerToastListener | null) { + listener = fn; +} + +export function showPartnerToast(message: string, variant: PartnerToastVariant = 'error') { + const text = message.trim(); + if (!text || !listener) return; + listener(text, variant); +} + +export function toastError(message: string) { + showPartnerToast(message, 'error'); +} + +export function toastSuccess(message: string) { + showPartnerToast(message, 'success'); +} diff --git a/apps/h5-partner/src/lib/upload.ts b/apps/h5-partner/src/lib/upload.ts index 9ff01c6..356341d 100644 --- a/apps/h5-partner/src/lib/upload.ts +++ b/apps/h5-partner/src/lib/upload.ts @@ -1,4 +1,5 @@ import { apiBase, request } from './api'; +import { showPartnerToast } from './toast'; export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE'; @@ -32,9 +33,13 @@ export async function uploadFileToOss( const json = await res.json(); if (json.code === 401) { localStorage.removeItem('accessToken'); + showPartnerToast('未登录', 'error'); throw new Error('未登录'); } - if (json.code !== 0) throw new Error(json.message || '上传失败'); + if (json.code !== 0) { + showPartnerToast(json.message || '上传失败', 'error'); + throw new Error(json.message || '上传失败'); + } const data = json.data as UploadFileResult; return { diff --git a/apps/h5-partner/src/lib/wechat-auth.ts b/apps/h5-partner/src/lib/wechat-auth.ts index fc6fa8c..ab434ab 100644 --- a/apps/h5-partner/src/lib/wechat-auth.ts +++ b/apps/h5-partner/src/lib/wechat-auth.ts @@ -41,6 +41,19 @@ export function handlePartnerWechatLoginResult(result: WechatLoginResult): boole return true; } +/** 微信登录成功后拉取并缓存合伙人资料,供一键登录页展示 */ +export async function persistPartnerProfileAfterLogin(): Promise { + try { + const profile = await fetchPartnerProfile(); + saveAuth({ + accessToken: localStorage.getItem('accessToken') ?? '', + partner: profile, + }); + } catch { + /* ignore */ + } +} + export async function handlePartnerWechatCallback(): Promise { if (!isWechatEnv()) return null; const config = await fetchClientConfig(); @@ -62,16 +75,6 @@ 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; diff --git a/apps/h5-partner/src/main.tsx b/apps/h5-partner/src/main.tsx index 9ad9251..e8bf706 100644 --- a/apps/h5-partner/src/main.tsx +++ b/apps/h5-partner/src/main.tsx @@ -4,14 +4,17 @@ import { BrowserRouter } from 'react-router-dom'; import { getRouterBasename } from '@dukang/weixin-sdk'; import App from './App'; import { PartnerSessionProvider } from './contexts/PartnerSessionContext'; +import { PartnerToastProvider } from './contexts/PartnerToastContext'; import './styles.css'; ReactDOM.createRoot(document.getElementById('root')!).render( - - - + + + + + , ); diff --git a/apps/h5-partner/src/pages/LoginPage.tsx b/apps/h5-partner/src/pages/LoginPage.tsx index 5d56e11..cdb4a7c 100644 --- a/apps/h5-partner/src/pages/LoginPage.tsx +++ b/apps/h5-partner/src/pages/LoginPage.tsx @@ -1,10 +1,10 @@ import { useEffect, useState } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; -import { request, saveAuth } from '../lib/api'; +import { request, getLastPhone, getPartnerProfile, saveAuth } 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, @@ -13,10 +13,16 @@ import { 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'; const REMEMBER_FLAG_KEY = 'partner_remember_account'; +function maskPhone(phone: string) { + if (phone.length < 7) return phone; + return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`; +} + function loadRememberedPhone(): { phone: string; remember: boolean } { try { const remember = localStorage.getItem(REMEMBER_FLAG_KEY) === '1'; @@ -40,10 +46,12 @@ function formatPartnerError(e: unknown): string { export default function LoginPage() { const navigate = useNavigate(); + const { refresh } = usePartnerSession(); const [params] = useSearchParams(); const quick = params.get('quick') === '1'; + const savedProfile = getPartnerProfile(); const remembered = loadRememberedPhone(); - const [phone, setPhone] = useState(remembered.phone || '13700000001'); + const [phone, setPhone] = useState(remembered.phone || getLastPhone()); const [code, setCode] = useState(''); const [rememberAccount, setRememberAccount] = useState(remembered.remember); const [agreed, setAgreed] = useState(true); @@ -59,15 +67,21 @@ export default function LoginPage() { .catch(() => setWxAuthorize(false)); }, []); + const quickName = savedProfile?.name ?? '城市合伙人'; + const quickCompany = savedProfile?.companyName ?? ''; + const quickPhone = savedProfile?.phone || phone; + useEffect(() => { - if (!isWechatEnv() || !wxAuthorize) return; + if (!isWechatEnv() || !wxAuthorize || !params.get('code')) return; void handlePartnerWechatCallback() - .then((result) => { + .then(async (result) => { if (!result) return; - if (handlePartnerWechatLoginResult(result)) navigate('/'); + if (!handlePartnerWechatLoginResult(result)) return; + const account = await refresh(); + navigate(partnerHomePath(account ?? undefined)); }) .catch((e) => setMsg(formatWechatError(e))); - }, [navigate, wxAuthorize]); + }, [navigate, refresh, wxAuthorize, params]); function formatWechatError(e: unknown): string { const text = e instanceof Error ? e.message : '微信登录失败'; @@ -79,7 +93,9 @@ export default function LoginPage() { function ensureAgreed() { if (!agreed) { - setMsg('请先勾选并同意用户协议'); + const text = '请先勾选并同意用户协议'; + setMsg(text); + toastError(text); return false; } return true; @@ -106,6 +122,7 @@ export default function LoginPage() { await request('PARTNER_H5', '/partner/auth/sms/send', { method: 'POST', body: JSON.stringify({ phone, scene: 'PARTNER_LOGIN' }), + silent: true, }); setMsg('验证码已发送'); setCodeCooldown(60); @@ -119,7 +136,9 @@ export default function LoginPage() { }); }, 1000); } catch (e) { - setMsg(formatPartnerError(e)); + const text = formatPartnerError(e); + setMsg(text); + toastError(text); } } @@ -131,25 +150,24 @@ export default function LoginPage() { if (options?.quick) { await request('PARTNER_H5', '/partner/auth/sms/send', { method: 'POST', - body: JSON.stringify({ phone, scene: 'PARTNER_LOGIN' }), + 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', { method: 'POST', - body: JSON.stringify({ phone, code }), + body: JSON.stringify({ phone: loginPhone, code }), + silent: true, }); saveAuth(data); - persistRememberAccount(phone); - const home = partnerHomePath(data.partner); - if (isWechatEnv() && wxAuthorize) { - setMsg('登录成功,正在关联微信…'); - await bindPartnerWechatAfterSmsLogin(); - navigate(home); - return; - } - navigate(home); + persistRememberAccount(loginPhone); + const account = await refresh(); + navigate(partnerHomePath(account ?? data.partner)); } catch (e) { - setMsg(formatPartnerError(e)); + const text = formatPartnerError(e); + setMsg(text); + toastError(text); } finally { setLoading(false); } @@ -160,14 +178,20 @@ export default function LoginPage() { setMsg(''); if (!isWechatEnv()) { setMsg(WECHAT_INAPP_REQUIRED_MSG); + toastError(WECHAT_INAPP_REQUIRED_MSG); return; } setWxLoading(true); try { const ok = await loginPartnerWithWechat(); - if (ok) navigate('/'); + if (ok) { + const account = await refresh(); + navigate(partnerHomePath(account ?? undefined)); + } } catch (e) { - setMsg(formatWechatError(e)); + const text = formatWechatError(e); + setMsg(text); + toastError(text); } finally { setWxLoading(false); } @@ -191,15 +215,20 @@ export default function LoginPage() {

- 李明 (郑州合伙人) + {quickName} + {quickCompany ? ( + ({quickCompany}) + ) : null}

-

137 **** 0001

+

+ {quickPhone ? maskPhone(quickPhone) : '暂无已保存账号'} +