子账号登录微信授权
This commit is contained in:
@@ -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 LoginPage from './pages/LoginPage';
|
||||||
import PartnerAppRoutes from './PartnerAppRoutes';
|
import PartnerAppRoutes from './PartnerAppRoutes';
|
||||||
import { isLoggedIn } from './lib/api';
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<AuthGate>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Routes>
|
||||||
<Route path="/*" element={isLoggedIn() ? <PartnerAppRoutes /> : <Navigate to="/login" replace />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
</Routes>
|
<Route path="/*" element={<PartnerAppRoutes />} />
|
||||||
|
</Routes>
|
||||||
|
</AuthGate>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||||
import { usePartnerSession } from './contexts/PartnerSessionContext';
|
import { usePartnerSession } from './contexts/PartnerSessionContext';
|
||||||
import { isLoggedIn } from './lib/api';
|
|
||||||
import { isSubAccount } from './lib/partnerAccess';
|
import { isSubAccount } from './lib/partnerAccess';
|
||||||
import TabLayout from './layouts/TabLayout';
|
import TabLayout from './layouts/TabLayout';
|
||||||
import SubAccountLayout from './layouts/SubAccountLayout';
|
import SubAccountLayout from './layouts/SubAccountLayout';
|
||||||
@@ -19,10 +18,6 @@ import LeaderboardPage from './pages/LeaderboardPage';
|
|||||||
import StaffListPage from './pages/StaffListPage';
|
import StaffListPage from './pages/StaffListPage';
|
||||||
import StaffCreatePage from './pages/StaffCreatePage';
|
import StaffCreatePage from './pages/StaffCreatePage';
|
||||||
|
|
||||||
function SessionLoading() {
|
|
||||||
return <div className="empty">加载中...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function PrimaryRoutes() {
|
function PrimaryRoutes() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -61,14 +56,12 @@ function SubAccountRoutes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function PartnerAppRoutes() {
|
export default function PartnerAppRoutes() {
|
||||||
const { account, loading, loggedIn } = usePartnerSession();
|
const { account, authenticated } = usePartnerSession();
|
||||||
|
|
||||||
if (!loggedIn && !isLoggedIn()) {
|
if (!authenticated) {
|
||||||
return <Navigate to="/login" replace />;
|
return null;
|
||||||
}
|
|
||||||
if (loading) {
|
|
||||||
return <SessionLoading />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSubAccount(account)) {
|
if (isSubAccount(account)) {
|
||||||
return <SubAccountRoutes />;
|
return <SubAccountRoutes />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 <div className="empty">加载中...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authenticated && location.pathname === '/login') {
|
||||||
|
return <Navigate to={partnerHomePath(account ?? getPartnerProfile())} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
|
||||||
|
const profile = getPartnerProfile();
|
||||||
|
if (profile && hasPartnerWxSession()) {
|
||||||
|
return <Navigate to="/login?quick=1" replace state={{ from: location }} />;
|
||||||
|
}
|
||||||
|
return <Navigate to="/login" replace state={{ from: location }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -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 { uploadFileToOss, type OssMediaType } from '../lib/upload';
|
||||||
import { toastError } from '../lib/toast';
|
import { toastError } from '../lib/toast';
|
||||||
import {
|
import {
|
||||||
@@ -33,11 +33,15 @@ function formatWechatUploadError(e: unknown): string {
|
|||||||
const formatted = formatChooseImageFailMessage(msg);
|
const formatted = formatChooseImageFailMessage(msg);
|
||||||
if (formatted) return formatted;
|
if (formatted) return formatted;
|
||||||
if (/invalid signature/i.test(msg)) {
|
if (/invalid signature/i.test(msg)) {
|
||||||
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名为 user.runxian.top,并刷新页面后重试';
|
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名,并刷新页面后重试';
|
||||||
}
|
}
|
||||||
return msg;
|
return msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function acceptsImages(accept: string) {
|
||||||
|
return accept.includes('image');
|
||||||
|
}
|
||||||
|
|
||||||
export default function OssUploadField({
|
export default function OssUploadField({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -50,7 +54,6 @@ export default function OssUploadField({
|
|||||||
wechatReady,
|
wechatReady,
|
||||||
onWechatReadyChange,
|
onWechatReadyChange,
|
||||||
}: OssUploadFieldProps) {
|
}: OssUploadFieldProps) {
|
||||||
const inputId = useId();
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [authorizing, setAuthorizing] = useState(false);
|
const [authorizing, setAuthorizing] = useState(false);
|
||||||
@@ -60,7 +63,9 @@ export default function OssUploadField({
|
|||||||
|
|
||||||
const resolvedAccept =
|
const resolvedAccept =
|
||||||
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? 'image/*,.pdf' : 'image/*');
|
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;
|
const needsAuth = useWechatPicker && needsWechatAuth(profile, clientConfig) && wechatReady !== true;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -124,7 +129,7 @@ export default function OssUploadField({
|
|||||||
await weixinSdk.init();
|
await weixinSdk.init();
|
||||||
const files = await weixinSdk.chooseImages({
|
const files = await weixinSdk.chooseImages({
|
||||||
count: 1,
|
count: 1,
|
||||||
sourceType: ['album'],
|
sourceType: ['album', 'camera'],
|
||||||
});
|
});
|
||||||
if (files?.[0]) {
|
if (files?.[0]) {
|
||||||
await uploadSelectedFile(files[0]);
|
await uploadSelectedFile(files[0]);
|
||||||
@@ -161,6 +166,7 @@ export default function OssUploadField({
|
|||||||
const isImage = mediaType === 'IMAGE' && value;
|
const isImage = mediaType === 'IMAGE' && value;
|
||||||
const isFile = mediaType === 'FILE' && value;
|
const isFile = mediaType === 'FILE' && value;
|
||||||
const busy = uploading || authorizing;
|
const busy = uploading || authorizing;
|
||||||
|
const pickerLabel = label ?? (useWechatPicker ? '拍照 / 从相册选择' : '点击上传');
|
||||||
|
|
||||||
const triggerProps = {
|
const triggerProps = {
|
||||||
type: 'button' as const,
|
type: 'button' as const,
|
||||||
@@ -172,7 +178,7 @@ export default function OssUploadField({
|
|||||||
<div className="partner-oss-upload">
|
<div className="partner-oss-upload">
|
||||||
{needsAuth && (
|
{needsAuth && (
|
||||||
<div className="partner-wechat-auth-hint" role="status">
|
<div className="partner-wechat-auth-hint" role="status">
|
||||||
<p className="body-md">上传照片需先完成微信授权</p>
|
<p className="body-md">上传照片需先完成微信授权绑定</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="partner-btn-outline"
|
className="partner-btn-outline"
|
||||||
@@ -180,23 +186,23 @@ export default function OssUploadField({
|
|||||||
disabled={authorizing}
|
disabled={authorizing}
|
||||||
onClick={() => void startWechatAuth()}
|
onClick={() => void startWechatAuth()}
|
||||||
>
|
>
|
||||||
{authorizing ? '跳转授权中…' : '微信授权'}
|
{authorizing ? '跳转授权中…' : '微信授权绑定'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<input
|
{!useWechatPicker && (
|
||||||
id={inputId}
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept={resolvedAccept}
|
accept={resolvedAccept}
|
||||||
capture={mediaType === 'FILE' && isWechatEnv() ? 'environment' : undefined}
|
className="partner-oss-upload-input"
|
||||||
className="partner-oss-upload-input"
|
disabled={busy}
|
||||||
disabled={busy}
|
onChange={(e) => {
|
||||||
onChange={(e) => {
|
const file = e.target.files?.[0];
|
||||||
const file = e.target.files?.[0];
|
if (file) void uploadSelectedFile(file);
|
||||||
if (file) void uploadSelectedFile(file);
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
)}
|
||||||
{isImage ? (
|
{isImage ? (
|
||||||
<button {...triggerProps} className={`partner-upload-preview${wide ? ' partner-upload-preview--wide' : ''}`}>
|
<button {...triggerProps} className={`partner-upload-preview${wide ? ' partner-upload-preview--wide' : ''}`}>
|
||||||
<img src={value} alt={label ?? '已上传'} />
|
<img src={value} alt={label ?? '已上传'} />
|
||||||
@@ -224,7 +230,7 @@ export default function OssUploadField({
|
|||||||
{busy ? 'hourglass_top' : 'add_a_photo'}
|
{busy ? 'hourglass_top' : 'add_a_photo'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-primary" style={{ fontWeight: 500 }}>
|
<span className="text-primary" style={{ fontWeight: 500 }}>
|
||||||
{uploading ? '上传中…' : needsAuth ? '请先微信授权' : (label ?? (useWechatPicker ? '从相册选择' : '点击上传'))}
|
{uploading ? '上传中…' : needsAuth ? '请先微信授权' : pickerLabel}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,17 +1,39 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react';
|
import {
|
||||||
import { toAppPath } from '@dukang/weixin-sdk';
|
createContext,
|
||||||
import { clearAuth, isLoggedIn, request } from '../lib/api';
|
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 type { PartnerMe, PartnerStaffRole } from '@dukang/shared-types';
|
||||||
|
import { fetchClientConfig, processPartnerWechatOAuthCallback } from '../lib/wechat-auth';
|
||||||
|
import { isWechatEnv } from '../lib/weixin';
|
||||||
|
|
||||||
export type PartnerAccount = PartnerMe & {
|
export type PartnerAccount = PartnerMe & {
|
||||||
staffRole?: PartnerStaffRole;
|
staffRole?: PartnerStaffRole;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PartnerSessionValue = {
|
type PartnerSessionValue = {
|
||||||
|
ready: boolean;
|
||||||
|
authenticated: boolean;
|
||||||
account: PartnerAccount | null;
|
account: PartnerAccount | null;
|
||||||
loading: boolean;
|
/** @deprecated 使用 authenticated */
|
||||||
loggedIn: boolean;
|
loggedIn: boolean;
|
||||||
|
/** @deprecated 使用 ready */
|
||||||
|
loading: boolean;
|
||||||
|
applySession: (session: PartnerSessionPayload) => void;
|
||||||
refresh: () => Promise<PartnerAccount | null>;
|
refresh: () => Promise<PartnerAccount | null>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
};
|
};
|
||||||
@@ -19,46 +41,111 @@ type PartnerSessionValue = {
|
|||||||
const PartnerSessionContext = createContext<PartnerSessionValue | null>(null);
|
const PartnerSessionContext = createContext<PartnerSessionValue | null>(null);
|
||||||
|
|
||||||
export function PartnerSessionProvider({ children }: { children: ReactNode }) {
|
export function PartnerSessionProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const [authenticated, setAuthenticated] = useState(false);
|
||||||
const [account, setAccount] = useState<PartnerAccount | null>(null);
|
const [account, setAccount] = useState<PartnerAccount | null>(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<PartnerAccount | null> => {
|
const refresh = useCallback(async (): Promise<PartnerAccount | null> => {
|
||||||
if (!isLoggedIn()) {
|
|
||||||
setAccount(null);
|
|
||||||
setLoggedIn(false);
|
|
||||||
setLoading(false);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
setLoggedIn(true);
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
try {
|
||||||
const data = await request<PartnerAccount>('PARTNER_H5', '/partner/me', { silent: true });
|
const result = await ensureSession();
|
||||||
setAccount(data);
|
setAuthenticated(result.authenticated);
|
||||||
return data;
|
if (!result.authenticated || !result.partner) {
|
||||||
|
setAccount(null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const me = await request<PartnerAccount>('PARTNER_H5', '/partner/me', { silent: true });
|
||||||
|
setAccount(me);
|
||||||
|
return me;
|
||||||
} catch {
|
} catch {
|
||||||
setAccount(null);
|
setAccount(null);
|
||||||
|
setAuthenticated(false);
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
clearAuth();
|
clearAuth();
|
||||||
setAccount(null);
|
setAccount(null);
|
||||||
setLoggedIn(false);
|
setAuthenticated(false);
|
||||||
window.location.href = toAppPath('/login');
|
window.location.href = toAppPath('/login');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh();
|
let cancelled = false;
|
||||||
}, [refresh]);
|
(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<PartnerAccount>('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<PartnerAccount>('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 (
|
return (
|
||||||
<PartnerSessionContext.Provider
|
<PartnerSessionContext.Provider value={value}>
|
||||||
value={{ account, loading, loggedIn, refresh, logout }}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</PartnerSessionContext.Provider>
|
</PartnerSessionContext.Provider>
|
||||||
);
|
);
|
||||||
@@ -69,3 +156,5 @@ export function usePartnerSession(): PartnerSessionValue {
|
|||||||
if (!ctx) throw new Error('usePartnerSession 必须在 PartnerSessionProvider 内使用');
|
if (!ctx) throw new Error('usePartnerSession 必须在 PartnerSessionProvider 内使用');
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type { PartnerSessionProfile };
|
||||||
|
|||||||
+191
-40
@@ -3,14 +3,30 @@ import { isOnAppPath, toAppPath } from '@dukang/weixin-sdk';
|
|||||||
import { showPartnerToast } from './toast';
|
import { showPartnerToast } from './toast';
|
||||||
|
|
||||||
export const apiBase = '/api/v1';
|
export const apiBase = '/api/v1';
|
||||||
|
const CLIENT_APP = 'PARTNER_H5';
|
||||||
|
|
||||||
|
const ACCESS_TOKEN = 'accessToken';
|
||||||
|
const REFRESH_TOKEN = 'refreshToken';
|
||||||
const LAST_PHONE = 'partnerLastPhone';
|
const LAST_PHONE = 'partnerLastPhone';
|
||||||
const PARTNER_PROFILE = 'partnerProfile';
|
const PARTNER_PROFILE = 'partnerProfile';
|
||||||
|
const SESSION_EXPIRES_AT = 'partnerSessionExpiresAt';
|
||||||
|
export const PARTNER_WX_BOUND = 'partnerWxBound';
|
||||||
|
|
||||||
export type PartnerSessionProfile = Pick<PartnerMe, 'id' | 'name' | 'phone' | 'companyName'>;
|
/** 微信验证通过后的免登录时长 */
|
||||||
|
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<PartnerMe, 'id' | 'name' | 'phone' | 'companyName' | 'isPrimary'>;
|
||||||
|
|
||||||
|
export type PartnerSessionPayload = {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
partner?: PartnerSessionProfile;
|
partner?: PartnerSessionProfile;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -32,53 +48,188 @@ export function getPartnerProfile(): PartnerSessionProfile | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function request<T>(
|
export function hasPartnerWxSession() {
|
||||||
clientApp: string,
|
return localStorage.getItem(PARTNER_WX_BOUND) === '1';
|
||||||
path: string,
|
|
||||||
options: ApiRequestOptions = {},
|
|
||||||
): Promise<T> {
|
|
||||||
const { silent, ...fetchOptions } = options;
|
|
||||||
const token = localStorage.getItem('accessToken');
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Client-App': clientApp,
|
|
||||||
...(fetchOptions.headers as Record<string, string>),
|
|
||||||
};
|
|
||||||
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 saveAuth(data: PartnerAuthPayload) {
|
export function isPartnerSessionExpired() {
|
||||||
localStorage.setItem('accessToken', data.accessToken);
|
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) {
|
if (data.partner) {
|
||||||
localStorage.setItem(PARTNER_PROFILE, JSON.stringify(data.partner));
|
localStorage.setItem(PARTNER_PROFILE, JSON.stringify(data.partner));
|
||||||
localStorage.setItem(LAST_PHONE, data.partner.phone);
|
localStorage.setItem(LAST_PHONE, data.partner.phone);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearAuth() {
|
/** 微信登录/绑定成功后写入 7 天免登录 session */
|
||||||
localStorage.removeItem('accessToken');
|
export function saveWechatSession(data: PartnerSessionPayload) {
|
||||||
localStorage.removeItem(PARTNER_PROFILE);
|
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() {
|
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<T>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
token?: string | null,
|
||||||
|
): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Client-App': CLIENT_APP,
|
||||||
|
...(options.headers as Record<string, string>),
|
||||||
|
};
|
||||||
|
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<PartnerSessionPayload | null> {
|
||||||
|
const refreshToken = localStorage.getItem(REFRESH_TOKEN);
|
||||||
|
if (!refreshToken) return null;
|
||||||
|
try {
|
||||||
|
const data = await rawRequest<PartnerSessionPayload>(
|
||||||
|
'/partner/auth/token/refresh',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ refreshToken }),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
saveAuth(data);
|
||||||
|
touchPartnerSession();
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestWithAuthRetry<T>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
retried = false,
|
||||||
|
): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await rawRequest<T>(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<T>(path, options, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function request<T>(
|
||||||
|
clientApp: string,
|
||||||
|
path: string,
|
||||||
|
options: ApiRequestOptions = {},
|
||||||
|
): Promise<T> {
|
||||||
|
void clientApp;
|
||||||
|
const { silent, ...fetchOptions } = options;
|
||||||
|
try {
|
||||||
|
return await requestWithAuthRetry<T>(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<PartnerMe>('/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;
|
||||||
|
|||||||
@@ -2,14 +2,15 @@ import type { ClientRuntimeConfig, WechatLoginResult } from '@dukang/shared-type
|
|||||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||||
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
|
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
|
||||||
import { isWechatEnv, weixinSdk } from './weixin';
|
import { isWechatEnv, weixinSdk } from './weixin';
|
||||||
import { request, saveAuth } from './api';
|
import { request, saveWechatSession, type PartnerSessionPayload } from './api';
|
||||||
|
|
||||||
export type PartnerProfile = {
|
export type PartnerProfile = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
companyName: string;
|
companyName?: string;
|
||||||
hasWechat?: boolean;
|
hasWechat?: boolean;
|
||||||
|
isPrimary?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function fetchClientConfig(): Promise<ClientRuntimeConfig> {
|
export async function fetchClientConfig(): Promise<ClientRuntimeConfig> {
|
||||||
@@ -20,7 +21,7 @@ export async function fetchPartnerProfile(): Promise<PartnerProfile> {
|
|||||||
return request<PartnerProfile>('PARTNER_H5', '/partner/me');
|
return request<PartnerProfile>('PARTNER_H5', '/partner/me');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 微信内上传照片前需完成公众号授权绑定 */
|
/** 微信内上传照片前需完成公众号 OAuth 绑定 */
|
||||||
export function needsWechatAuth(
|
export function needsWechatAuth(
|
||||||
profile: PartnerProfile | null,
|
profile: PartnerProfile | null,
|
||||||
config?: Pick<ClientRuntimeConfig, 'wxAuthorize'> | null,
|
config?: Pick<ClientRuntimeConfig, 'wxAuthorize'> | null,
|
||||||
@@ -34,24 +35,30 @@ export async function checkNeedsWechatAuth(profile: PartnerProfile | null): Prom
|
|||||||
return needsWechatAuth(profile, config);
|
return needsWechatAuth(profile, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 处理微信登录/绑定结果,返回是否已拿到 token 可进入首页 */
|
export function sessionFromWechatLogin(result: WechatLoginResult): PartnerSessionPayload | null {
|
||||||
export function handlePartnerWechatLoginResult(result: WechatLoginResult): boolean {
|
if (!result.accessToken || !result.refreshToken) return null;
|
||||||
if (!result.accessToken) return false;
|
const partner = result.partner;
|
||||||
saveAuth({ accessToken: result.accessToken });
|
return {
|
||||||
return true;
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 微信登录成功后拉取并缓存合伙人资料,供一键登录页展示 */
|
/** 处理微信登录/绑定结果,写入 7 天免登录 session */
|
||||||
export async function persistPartnerProfileAfterLogin(): Promise<void> {
|
export function handlePartnerWechatLoginResult(result: WechatLoginResult): PartnerSessionPayload | null {
|
||||||
try {
|
const session = sessionFromWechatLogin(result);
|
||||||
const profile = await fetchPartnerProfile();
|
if (!session) return null;
|
||||||
saveAuth({
|
saveWechatSession(session);
|
||||||
accessToken: localStorage.getItem('accessToken') ?? '',
|
return session;
|
||||||
partner: profile,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handlePartnerWechatCallback(): Promise<WechatLoginResult | null> {
|
export async function handlePartnerWechatCallback(): Promise<WechatLoginResult | null> {
|
||||||
@@ -62,12 +69,12 @@ export async function handlePartnerWechatCallback(): Promise<WechatLoginResult |
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 微信授权登录(对齐 C 端:仅微信内置浏览器走 OAuth)。
|
* 微信一键登录(已绑定微信的合伙人账号免验证码)。
|
||||||
* 返回 true = 已登录;void = 已跳转授权页等待回调。
|
* 返回 session = 已登录;void = 已跳转授权页等待回调。
|
||||||
*/
|
*/
|
||||||
export async function loginPartnerWithWechat(): Promise<boolean | void> {
|
export async function loginPartnerWithWechat(): Promise<PartnerSessionPayload | null | void> {
|
||||||
const config = await fetchClientConfig();
|
const config = await fetchClientConfig();
|
||||||
if (!isWxAuthorizeEnabled(config)) return false;
|
if (!isWxAuthorizeEnabled(config)) return null;
|
||||||
if (!isWechatEnv()) {
|
if (!isWechatEnv()) {
|
||||||
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
|
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
|
||||||
}
|
}
|
||||||
@@ -75,6 +82,14 @@ export async function loginPartnerWithWechat(): Promise<boolean | void> {
|
|||||||
if (result) return handlePartnerWechatLoginResult(result);
|
if (result) return handlePartnerWechatLoginResult(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 短信登录成功后于微信内自动发起 OAuth,绑定 openId 便于后续免登 */
|
||||||
|
export async function bindPartnerWechatAfterSmsLogin(): Promise<void> {
|
||||||
|
const config = await fetchClientConfig();
|
||||||
|
if (!isWxAuthorizeEnabled(config)) return;
|
||||||
|
if (!isWechatEnv()) return;
|
||||||
|
await weixinSdk.login();
|
||||||
|
}
|
||||||
|
|
||||||
export async function authorizePartnerWechat(): Promise<WechatLoginResult | void> {
|
export async function authorizePartnerWechat(): Promise<WechatLoginResult | void> {
|
||||||
const config = await fetchClientConfig();
|
const config = await fetchClientConfig();
|
||||||
if (!isWxAuthorizeEnabled(config)) return;
|
if (!isWxAuthorizeEnabled(config)) return;
|
||||||
@@ -84,7 +99,14 @@ export async function authorizePartnerWechat(): Promise<WechatLoginResult | void
|
|||||||
return weixinSdk.login();
|
return weixinSdk.login();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @deprecated 使用 handlePartnerWechatLoginResult */
|
/** OAuth 回跳统一处理(登录页 / 录店页等) */
|
||||||
export function savePartnerWechatAuth(result: WechatLoginResult): boolean {
|
export async function processPartnerWechatOAuthCallback(): Promise<PartnerSessionPayload | null> {
|
||||||
|
const result = await handlePartnerWechatCallback();
|
||||||
|
if (!result) return null;
|
||||||
return handlePartnerWechatLoginResult(result);
|
return handlePartnerWechatLoginResult(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated 使用 handlePartnerWechatLoginResult */
|
||||||
|
export function savePartnerWechatAuth(result: WechatLoginResult): boolean {
|
||||||
|
return !!handlePartnerWechatLoginResult(result);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
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 { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||||
import { partnerHomePath } from '../lib/partnerAccess';
|
import { partnerHomePath } from '../lib/partnerAccess';
|
||||||
import type { PartnerMe } from '@dukang/shared-types';
|
|
||||||
import {
|
import {
|
||||||
|
bindPartnerWechatAfterSmsLogin,
|
||||||
fetchClientConfig,
|
fetchClientConfig,
|
||||||
handlePartnerWechatCallback,
|
|
||||||
handlePartnerWechatLoginResult,
|
|
||||||
loginPartnerWithWechat,
|
loginPartnerWithWechat,
|
||||||
} from '../lib/wechat-auth';
|
} from '../lib/wechat-auth';
|
||||||
import { isWechatEnv } from '../lib/weixin';
|
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';
|
import { toastError } from '../lib/toast';
|
||||||
|
|
||||||
const REMEMBER_PHONE_KEY = 'partner_remember_phone';
|
const REMEMBER_PHONE_KEY = 'partner_remember_phone';
|
||||||
@@ -44,9 +49,17 @@ function formatPartnerError(e: unknown): string {
|
|||||||
return text;
|
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() {
|
export default function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { refresh } = usePartnerSession();
|
const { applySession, refresh, account } = usePartnerSession();
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const quick = params.get('quick') === '1';
|
const quick = params.get('quick') === '1';
|
||||||
const savedProfile = getPartnerProfile();
|
const savedProfile = getPartnerProfile();
|
||||||
@@ -71,26 +84,6 @@ export default function LoginPage() {
|
|||||||
const quickCompany = savedProfile?.companyName ?? '';
|
const quickCompany = savedProfile?.companyName ?? '';
|
||||||
const quickPhone = savedProfile?.phone || phone;
|
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() {
|
function ensureAgreed() {
|
||||||
if (!agreed) {
|
if (!agreed) {
|
||||||
const text = '请先勾选并同意用户协议';
|
const text = '请先勾选并同意用户协议';
|
||||||
@@ -142,28 +135,30 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function login(options?: { quick?: boolean }) {
|
async function finishLoginNavigate() {
|
||||||
if (!options?.quick && !ensureAgreed()) return;
|
const me = await refresh();
|
||||||
|
navigate(partnerHomePath(me ?? account ?? savedProfile));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
if (!ensureAgreed()) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMsg('');
|
setMsg('');
|
||||||
try {
|
try {
|
||||||
if (options?.quick) {
|
const data = await request<PartnerSessionPayload>('PARTNER_H5', '/partner/auth/login/sms', {
|
||||||
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', {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ phone: loginPhone, code }),
|
body: JSON.stringify({ phone, code }),
|
||||||
silent: true,
|
silent: true,
|
||||||
});
|
});
|
||||||
saveAuth(data);
|
saveAuth(data);
|
||||||
persistRememberAccount(loginPhone);
|
applySession(data);
|
||||||
const account = await refresh();
|
persistRememberAccount(phone);
|
||||||
navigate(partnerHomePath(account ?? data.partner));
|
if (isWechatEnv() && wxAuthorize) {
|
||||||
|
setMsg('登录成功,正在关联微信…');
|
||||||
|
await bindPartnerWechatAfterSmsLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await finishLoginNavigate();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const text = formatPartnerError(e);
|
const text = formatPartnerError(e);
|
||||||
setMsg(text);
|
setMsg(text);
|
||||||
@@ -183,10 +178,10 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
setWxLoading(true);
|
setWxLoading(true);
|
||||||
try {
|
try {
|
||||||
const ok = await loginPartnerWithWechat();
|
const session = await loginPartnerWithWechat();
|
||||||
if (ok) {
|
if (session) {
|
||||||
const account = await refresh();
|
applySession(session);
|
||||||
navigate(partnerHomePath(account ?? undefined));
|
await finishLoginNavigate();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const text = formatWechatError(e);
|
const text = formatWechatError(e);
|
||||||
@@ -198,6 +193,8 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (quick) {
|
if (quick) {
|
||||||
|
const canWechatQuick = wxAuthorize && isWechatEnv() && hasPartnerWxSession() && !!savedProfile;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="partner-auth-page partner-auth-page--quick">
|
<div className="partner-auth-page partner-auth-page--quick">
|
||||||
<header className="partner-auth-brand">
|
<header className="partner-auth-brand">
|
||||||
@@ -228,17 +225,37 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
<nav style={{ width: '100%', maxWidth: 384, marginTop: 16 }}>
|
<nav style={{ width: '100%', maxWidth: 384, marginTop: 16 }}>
|
||||||
{msg && <p className="partner-auth-msg" style={{ color: 'var(--color-error, #d33)', fontSize: 13, textAlign: 'center', marginBottom: 12 }}>{msg}</p>}
|
{msg && <p className="partner-auth-msg" style={{ color: 'var(--color-error, #d33)', fontSize: 13, textAlign: 'center', marginBottom: 12 }}>{msg}</p>}
|
||||||
<button type="button" className="partner-btn-primary" onClick={() => void login({ quick: true })} disabled={loading || !quickPhone}>
|
{canWechatQuick ? (
|
||||||
<span>{loading ? '登录中...' : '一键登录'}</span>
|
<button
|
||||||
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
|
type="button"
|
||||||
</button>
|
className="partner-btn-primary"
|
||||||
|
disabled={wxLoading}
|
||||||
|
onClick={() => void wechatLogin()}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined">chat</span>
|
||||||
|
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<p className="partner-auth-msg" style={{ textAlign: 'center', marginBottom: 12 }}>
|
||||||
|
{wxAuthorize && !isWechatEnv()
|
||||||
|
? '请在微信内打开以使用一键登录'
|
||||||
|
: '请使用验证码登录并绑定微信后,即可 7 天内免登录'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!canWechatQuick && (
|
||||||
|
<Link to="/login" className="partner-btn-primary" style={{ display: 'block', textAlign: 'center', textDecoration: 'none', marginTop: 12 }}>
|
||||||
|
验证码登录
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<Link to="/login" className="partner-btn-ghost" style={{ display: 'block', marginTop: 12 }}>切换账号</Link>
|
<Link to="/login" className="partner-btn-ghost" style={{ display: 'block', marginTop: 12 }}>切换账号</Link>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<footer className="partner-auth-footer">
|
<footer className="partner-auth-footer">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
<span className="material-symbols-outlined">verified_user</span>
|
<span className="material-symbols-outlined">verified_user</span>
|
||||||
<span className="label-md" style={{ fontSize: 10, letterSpacing: '0.1em', textTransform: 'uppercase' }}>Secured by Dukang Heritage</span>
|
<span className="label-md" style={{ fontSize: 10, letterSpacing: '0.1em', textTransform: 'uppercase' }}>
|
||||||
|
{canWechatQuick ? '微信验证 · 7 天内免登录' : 'Secured by Dukang Heritage'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
@@ -308,13 +325,13 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
{wxAuthorize && (
|
{wxAuthorize && (
|
||||||
<>
|
<>
|
||||||
<div className="partner-auth-divider"><span>其他登录方式</span></div>
|
<div className="partner-auth-divider"><span>其他登录方式</span></div>
|
||||||
<button type="button" className="partner-btn-wechat" onClick={wechatLogin} disabled={wxLoading}>
|
<button type="button" className="partner-btn-wechat" onClick={() => void wechatLogin()} disabled={wxLoading}>
|
||||||
<svg viewBox="0 0 24 24" fill="#07C160" aria-hidden>
|
<svg viewBox="0 0 24 24" fill="#07C160" aria-hidden>
|
||||||
<path d="M8.25 4.5C4.52 4.5 1.5 7.04 1.5 10.17c0 1.78.98 3.37 2.5 4.48l-.63 1.88 2.19-1.09c.84.24 1.74.38 2.69.38.25 0 .5 0 .75-.03-.16-.53-.25-1.09-.25-1.66 0-3.13 3.02-5.67 6.75-5.67.57 0 1.13.06 1.66.17C15.17 6.13 12 4.5 8.25 4.5zm10.5 6.33c-3.11 0-5.62 2.12-5.62 4.73 0 2.61 2.51 4.73 5.62 4.73.79 0 1.54-.14 2.24-.38l1.83.91-.53-1.57c1.27-.92 2.08-2.25 2.08-3.73 0-2.61-2.51-4.73-5.62-4.73z" />
|
<path d="M8.25 4.5C4.52 4.5 1.5 7.04 1.5 10.17c0 1.78.98 3.37 2.5 4.48l-.63 1.88 2.19-1.09c.84.24 1.74.38 2.69.38.25 0 .5 0 .75-.03-.16-.53-.25-1.09-.25-1.66 0-3.13 3.02-5.67 6.75-5.67.57 0 1.13.06 1.66.17C15.17 6.13 12 4.5 8.25 4.5zm10.5 6.33c-3.11 0-5.62 2.12-5.62 4.73 0 2.61 2.51 4.73 5.62 4.73.79 0 1.54-.14 2.24-.38l1.83.91-.53-1.57c1.27-.92 2.08-2.25 2.08-3.73 0-2.61-2.51-4.73-5.62-4.73z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>{wxLoading ? '登录中...' : '微信一键授权'}</span>
|
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -327,7 +344,9 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Link to="/login?quick=1" className="partner-link">快捷登录</Link>
|
{hasPartnerWxSession() && savedProfile && (
|
||||||
|
<Link to="/login?quick=1" className="partner-link">微信快捷登录</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
<footer className="partner-auth-footer">
|
<footer className="partner-auth-footer">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
|||||||
@@ -17,20 +17,8 @@ import { checkStorePhoneAvailable } from '../lib/storePhone';
|
|||||||
|
|
||||||
import { fetchPartnerCities, type OpenCityOption } from '../lib/upload';
|
import { fetchPartnerCities, type OpenCityOption } from '../lib/upload';
|
||||||
|
|
||||||
import {
|
|
||||||
|
|
||||||
fetchPartnerProfile,
|
|
||||||
|
|
||||||
handlePartnerWechatCallback,
|
|
||||||
|
|
||||||
savePartnerWechatAuth,
|
|
||||||
|
|
||||||
} from '../lib/wechat-auth';
|
|
||||||
|
|
||||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||||
|
|
||||||
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
|
|
||||||
|
|
||||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -79,10 +67,12 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { account } = usePartnerSession();
|
const { account, refresh } = usePartnerSession();
|
||||||
|
|
||||||
const accountId = account?.id;
|
const accountId = account?.id;
|
||||||
|
|
||||||
|
const wechatReady = !!account?.hasWechat;
|
||||||
|
|
||||||
const [params, setParams] = useSearchParams();
|
const [params, setParams] = useSearchParams();
|
||||||
|
|
||||||
const saved = loadStoreDraft(accountId);
|
const saved = loadStoreDraft(accountId);
|
||||||
@@ -101,8 +91,6 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
const [citiesError, setCitiesError] = useState('');
|
const [citiesError, setCitiesError] = useState('');
|
||||||
|
|
||||||
const [wechatReady, setWechatReady] = useState(false);
|
|
||||||
|
|
||||||
function reportFormError(message: string) {
|
function reportFormError(message: string) {
|
||||||
setSubmitError(message);
|
setSubmitError(message);
|
||||||
toastError(message);
|
toastError(message);
|
||||||
@@ -138,11 +126,7 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
if (step !== 2 || !isWechatEnv()) return;
|
if (step !== 2 || !isWechatEnv()) return;
|
||||||
|
|
||||||
void fetchPartnerProfile()
|
void refresh();
|
||||||
|
|
||||||
.then((me) => setWechatReady(!!me.hasWechat))
|
|
||||||
|
|
||||||
.catch(() => setWechatReady(false));
|
|
||||||
|
|
||||||
void weixinSdk.init().catch(() => {
|
void weixinSdk.init().catch(() => {
|
||||||
|
|
||||||
@@ -150,45 +134,7 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
}, [step]);
|
}, [step, refresh]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
|
|
||||||
if (!isWechatEnv() || !params.get('code')) return;
|
|
||||||
|
|
||||||
void handlePartnerWechatCallback()
|
|
||||||
|
|
||||||
.then((result) => {
|
|
||||||
|
|
||||||
if (result && savePartnerWechatAuth(result)) {
|
|
||||||
|
|
||||||
setWechatReady(true);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
stripOAuthParamsFromLocation();
|
|
||||||
|
|
||||||
const next = new URLSearchParams(params);
|
|
||||||
|
|
||||||
next.delete('code');
|
|
||||||
|
|
||||||
next.delete('state');
|
|
||||||
|
|
||||||
setParams(next, { replace: true });
|
|
||||||
|
|
||||||
void weixinSdk.init().catch(() => {
|
|
||||||
|
|
||||||
/* OssUploadField 点击时会再次初始化 */
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
})
|
|
||||||
|
|
||||||
.catch((e) => reportFormError(e instanceof Error ? e.message : '微信授权失败'));
|
|
||||||
|
|
||||||
}, [params, setParams]);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -693,7 +639,7 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
wechatReady={wechatReady}
|
wechatReady={wechatReady}
|
||||||
|
|
||||||
onWechatReadyChange={setWechatReady}
|
onWechatReadyChange={() => { void refresh(); }}
|
||||||
|
|
||||||
onChange={(coverUrl) => patchForm({ coverUrl })}
|
onChange={(coverUrl) => patchForm({ coverUrl })}
|
||||||
|
|
||||||
@@ -729,7 +675,7 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
wechatReady={wechatReady}
|
wechatReady={wechatReady}
|
||||||
|
|
||||||
onWechatReadyChange={setWechatReady}
|
onWechatReadyChange={() => { void refresh(); }}
|
||||||
|
|
||||||
onChange={(nextUrl) => {
|
onChange={(nextUrl) => {
|
||||||
|
|
||||||
@@ -769,7 +715,7 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
wechatReady={wechatReady}
|
wechatReady={wechatReady}
|
||||||
|
|
||||||
onWechatReadyChange={setWechatReady}
|
onWechatReadyChange={() => { void refresh(); }}
|
||||||
|
|
||||||
onChange={(contractUrl) => patchForm({ contractUrl })}
|
onChange={(contractUrl) => patchForm({ contractUrl })}
|
||||||
|
|
||||||
|
|||||||
@@ -172,6 +172,11 @@ export class PartnerAuthController {
|
|||||||
}
|
}
|
||||||
return this.authService.loginPartnerWechat(dto.code, ClientApp.PARTNER_H5, dto.platform ?? 'h5');
|
return this.authService.loginPartnerWechat(dto.code, ClientApp.PARTNER_H5, dto.platform ?? 'h5');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('token/refresh')
|
||||||
|
refresh(@Body() dto: RefreshTokenDto) {
|
||||||
|
return this.authService.refreshAccessToken(dto.refreshToken, ClientApp.PARTNER_H5);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Controller('user')
|
@Controller('user')
|
||||||
|
|||||||
@@ -385,6 +385,9 @@ export class AuthService {
|
|||||||
if (payload.actorType === 'STORE' && clientApp === ClientApp.SHOP_H5) {
|
if (payload.actorType === 'STORE' && clientApp === ClientApp.SHOP_H5) {
|
||||||
return this.buildStoreSessionResponse(BigInt(payload.actorId), clientApp);
|
return this.buildStoreSessionResponse(BigInt(payload.actorId), clientApp);
|
||||||
}
|
}
|
||||||
|
if (payload.actorType === 'PARTNER' && clientApp === ClientApp.PARTNER_H5) {
|
||||||
|
return this.buildPartnerSessionResponse(BigInt(payload.actorId), clientApp);
|
||||||
|
}
|
||||||
throw new UnauthorizedException('Invalid refresh token');
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof UnauthorizedException) throw err;
|
if (err instanceof UnauthorizedException) throw err;
|
||||||
@@ -409,6 +412,25 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async buildPartnerSessionResponse(accountId: bigint, clientApp: ClientApp) {
|
||||||
|
const account = await this.prisma.partnerAccount.findUnique({
|
||||||
|
where: { id: accountId },
|
||||||
|
include: { partner: true },
|
||||||
|
});
|
||||||
|
if (!account || account.status !== 'ACTIVE') {
|
||||||
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
|
}
|
||||||
|
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, {
|
||||||
|
id: account.id.toString(),
|
||||||
|
partnerId: account.partnerId.toString(),
|
||||||
|
name: account.name,
|
||||||
|
phone: account.phone,
|
||||||
|
isPrimary: account.isPrimary === 1,
|
||||||
|
staffRole: account.staffRole ?? undefined,
|
||||||
|
companyName: account.partner.companyName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async loginUser(phone: string, code: string, clientApp: ClientApp, guestId?: bigint) {
|
async loginUser(phone: string, code: string, clientApp: ClientApp, guestId?: bigint) {
|
||||||
const normalizedPhone = this.assertMobilePhone(phone);
|
const normalizedPhone = this.assertMobilePhone(phone);
|
||||||
const existingUser = await this.prisma.user.findUnique({
|
const existingUser = await this.prisma.user.findUnique({
|
||||||
@@ -1393,7 +1415,7 @@ export class AuthService {
|
|||||||
phoneVerified,
|
phoneVerified,
|
||||||
};
|
};
|
||||||
const accessToken = this.jwtService.sign(payload);
|
const accessToken = this.jwtService.sign(payload);
|
||||||
const refreshExpiresIn = actorType === 'STORE' ? '7d' : '30d';
|
const refreshExpiresIn = actorType === 'STORE' || actorType === 'PARTNER' ? '7d' : '30d';
|
||||||
const refreshToken = this.jwtService.sign(payload, { expiresIn: refreshExpiresIn });
|
const refreshToken = this.jwtService.sign(payload, { expiresIn: refreshExpiresIn });
|
||||||
return {
|
return {
|
||||||
accessToken,
|
accessToken,
|
||||||
|
|||||||
Reference in New Issue
Block a user