feat;提交管理端和城市合伙人端

This commit is contained in:
ljy
2026-07-05 23:48:20 +08:00
parent fecfc61ec5
commit 569becedaa
73 changed files with 10150 additions and 80 deletions
+4 -3
View File
@@ -13,7 +13,8 @@ import BillsPage from './pages/BillsPage';
import ReshipPage from './pages/ReshipPage';
import WeeklyReportPage from './pages/WeeklyReportPage';
import LeaderboardPage from './pages/LeaderboardPage';
import { handlePartnerWechatCallback, savePartnerWechatAuth } from './lib/wechat-auth';
import { handlePartnerWechatCallback, handlePartnerWechatLoginResult } from './lib/wechat-auth';
import { isLoggedIn } from './lib/api';
import { isWechatEnv } from './lib/weixin';
function WechatOAuthHandler() {
@@ -24,7 +25,7 @@ function WechatOAuthHandler() {
if (!isWechatEnv() || !location.search.includes('code=')) return;
void handlePartnerWechatCallback()
.then((result) => {
if (!result || !savePartnerWechatAuth(result)) return;
if (!result || !handlePartnerWechatLoginResult(result)) return;
const params = new URLSearchParams(location.search);
params.delete('code');
params.delete('state');
@@ -58,7 +59,7 @@ export default function App() {
<Route path="/leaderboard" element={<LeaderboardPage />} />
<Route path="/stores/:id" element={<StoreDetailPage />} />
<Route path="/orders/:id" element={<OrderDetailPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
<Route path="*" element={<Navigate to={isLoggedIn() ? '/' : '/login'} replace />} />
</Routes>
</>
);
@@ -0,0 +1,66 @@
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react';
import { clearAuth, isLoggedIn, request } from '../lib/api';
export type PartnerAccount = {
id: string;
name: string;
phone: string;
isPrimary?: boolean;
companyName?: string;
hasWechat?: boolean;
};
type PartnerSessionValue = {
account: PartnerAccount | null;
loading: boolean;
loggedIn: boolean;
refresh: () => Promise<void>;
logout: () => void;
};
const PartnerSessionContext = createContext<PartnerSessionValue | null>(null);
export function PartnerSessionProvider({ children }: { children: ReactNode }) {
const [account, setAccount] = useState<PartnerAccount | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
if (!isLoggedIn()) {
setAccount(null);
setLoading(false);
return;
}
try {
const data = await request<PartnerAccount>('PARTNER_H5', '/partner/me');
setAccount(data);
} catch {
setAccount(null);
} finally {
setLoading(false);
}
}, []);
const logout = useCallback(() => {
clearAuth();
setAccount(null);
window.location.href = '/login';
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
return (
<PartnerSessionContext.Provider
value={{ account, loading, loggedIn: isLoggedIn(), refresh, logout }}
>
{children}
</PartnerSessionContext.Provider>
);
}
export function usePartnerSession(): PartnerSessionValue {
const ctx = useContext(PartnerSessionContext);
if (!ctx) throw new Error('usePartnerSession 必须在 PartnerSessionProvider 内使用');
return ctx;
}
+2 -4
View File
@@ -1,4 +1,4 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { NavLink, Navigate, Outlet } from 'react-router-dom';
import { isLoggedIn } from '../lib/api';
const TABS = [
@@ -8,10 +8,8 @@ const TABS = [
] as const;
export default function TabLayout() {
const navigate = useNavigate();
if (!isLoggedIn()) {
navigate('/login');
return null;
return <Navigate to="/login" replace />;
}
return (
<>
+8 -1
View File
@@ -9,7 +9,14 @@ export async function request<T>(clientApp: string, path: string, options: Reque
};
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
const json = await res.json();
const json = await res.json().catch(() => ({ code: res.status, message: '网络异常' }));
if (res.status === 401 || json.code === 401) {
clearAuth();
if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) {
window.location.href = '/login';
}
throw new Error(json.message || '登录已过期,请重新登录');
}
if (json.code !== 0) throw new Error(json.message || '请求失败');
return json.data as T;
}
+51 -4
View File
@@ -19,12 +19,59 @@ export function needsWechatAuth(profile: PartnerProfile | null): boolean {
return isWechatEnv() && !!profile && !profile.hasWechat;
}
export function savePartnerWechatAuth(result: WechatLoginResult): boolean {
/** 处理微信登录/绑定结果,返回是否已拿到 token 可进入首页 */
export function handlePartnerWechatLoginResult(result: WechatLoginResult): boolean {
if (!result.accessToken) return false;
saveAuth({ accessToken: result.accessToken });
return true;
}
export async function handlePartnerWechatCallback(): Promise<WechatLoginResult | null> {
if (!isWechatEnv()) return null;
return weixinSdk.handleOAuthCallback();
}
let clientConfigCache: { mockWechat?: boolean } | null = null;
async function fetchClientConfig(): Promise<{ mockWechat?: boolean }> {
if (clientConfigCache) return clientConfigCache;
try {
clientConfigCache = await request<{ mockWechat?: boolean }>('PARTNER_H5', '/common/client-config');
} catch {
clientConfigCache = {};
}
return clientConfigCache;
}
/**
* 微信授权登录(与 C 端一致:微信内走公众号 OAuth)。
* 返回 true = 已登录;void = 已跳转授权页等待回调。
*/
export async function loginPartnerWithWechat(): Promise<boolean | void> {
if (isWechatEnv()) {
const result = await weixinSdk.login();
if (result) return handlePartnerWechatLoginResult(result);
return;
}
const cfg = await fetchClientConfig();
if (!cfg.mockWechat) {
throw new Error('请在微信内打开以使用微信一键授权');
}
const result = await request<WechatLoginResult>('PARTNER_H5', '/partner/auth/login/wechat', {
method: 'POST',
body: JSON.stringify({ code: `mockcode_${Date.now()}`, platform: 'h5' }),
});
return handlePartnerWechatLoginResult(result);
}
/**
* 短信登录成功后于微信内自动发起 OAuth,将 openId 绑定到当前合伙人账号(便于同一微信后续免登)。
*/
export async function bindPartnerWechatAfterSmsLogin(): Promise<void> {
if (!isWechatEnv()) return;
await weixinSdk.login();
}
export async function authorizePartnerWechat(): Promise<WechatLoginResult | void> {
if (!isWechatEnv()) {
throw new Error('请在微信内打开以完成授权');
@@ -32,7 +79,7 @@ export async function authorizePartnerWechat(): Promise<WechatLoginResult | void
return weixinSdk.login();
}
export async function handlePartnerWechatCallback(): Promise<WechatLoginResult | null> {
if (!isWechatEnv()) return null;
return weixinSdk.handleOAuthCallback();
/** @deprecated 使用 handlePartnerWechatLoginResult */
export function savePartnerWechatAuth(result: WechatLoginResult): boolean {
return handlePartnerWechatLoginResult(result);
}
+8 -1
View File
@@ -2,8 +2,15 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import { PartnerSessionProvider } from './contexts/PartnerSessionContext';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode><BrowserRouter><App /></BrowserRouter></React.StrictMode>,
<React.StrictMode>
<BrowserRouter>
<PartnerSessionProvider>
<App />
</PartnerSessionProvider>
</BrowserRouter>
</React.StrictMode>,
);
+5 -4
View File
@@ -1,15 +1,16 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { clearAuth, isLoggedIn, request } from '../lib/api';
import { isLoggedIn, request } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
export default function CenterPage() {
const navigate = useNavigate();
const [me, setMe] = useState<Record<string, unknown> | null>(null);
const { account, logout } = usePartnerSession();
const me = account as unknown as Record<string, unknown> | null;
const [bills, setBills] = useState<Array<Record<string, unknown>>>([]);
useEffect(() => {
if (!isLoggedIn()) { navigate('/login'); return; }
request('PARTNER_H5', '/partner/me').then(setMe);
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/settlement/bills').then(setBills);
}, [navigate]);
@@ -134,7 +135,7 @@ export default function CenterPage() {
</div>
</section>
<button type="button" className="partner-logout-btn" onClick={() => { clearAuth(); navigate('/login'); }}>
<button type="button" className="partner-logout-btn" onClick={logout}>
<span className="material-symbols-outlined">logout</span>
退
</button>
+128 -22
View File
@@ -1,18 +1,109 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { request, saveAuth } from '../lib/api';
import {
bindPartnerWechatAfterSmsLogin,
handlePartnerWechatCallback,
handlePartnerWechatLoginResult,
loginPartnerWithWechat,
} from '../lib/wechat-auth';
import { isWechatEnv } from '../lib/weixin';
const REMEMBER_PHONE_KEY = 'partner_remember_phone';
const REMEMBER_FLAG_KEY = 'partner_remember_account';
function loadRememberedPhone(): { phone: string; remember: boolean } {
try {
const remember = localStorage.getItem(REMEMBER_FLAG_KEY) === '1';
const phone = remember ? localStorage.getItem(REMEMBER_PHONE_KEY) || '' : '';
return { phone, remember };
} catch {
return { phone: '', remember: false };
}
}
export default function LoginPage() {
const navigate = useNavigate();
const [params] = useSearchParams();
const quick = params.get('quick') === '1';
const [phone, setPhone] = useState('13700000001');
const remembered = loadRememberedPhone();
const [phone, setPhone] = useState(remembered.phone || '13700000001');
const [code, setCode] = useState('123456');
const [rememberAccount, setRememberAccount] = useState(remembered.remember);
const [agreed, setAgreed] = useState(true);
const [loading, setLoading] = useState(false);
const [wxLoading, setWxLoading] = useState(false);
const [msg, setMsg] = useState('');
const [codeCooldown, setCodeCooldown] = useState(0);
useEffect(() => {
if (!isWechatEnv()) return;
void handlePartnerWechatCallback()
.then((result) => {
if (!result) return;
if (handlePartnerWechatLoginResult(result)) navigate('/');
})
.catch((e) => setMsg(formatWechatError(e)));
}, [navigate]);
function formatWechatError(e: unknown): string {
const text = e instanceof Error ? e.message : '微信登录失败';
if (text.includes('首次登录') || text.includes('手机验证码')) {
return '该微信尚未绑定合伙人账号,请先使用手机验证码登录,登录后将自动关联微信';
}
return text;
}
function ensureAgreed() {
if (!agreed) {
setMsg('请先勾选并同意用户协议');
return false;
}
return true;
}
function persistRememberAccount(nextPhone: string) {
try {
if (rememberAccount) {
localStorage.setItem(REMEMBER_FLAG_KEY, '1');
localStorage.setItem(REMEMBER_PHONE_KEY, nextPhone);
} else {
localStorage.removeItem(REMEMBER_FLAG_KEY);
localStorage.removeItem(REMEMBER_PHONE_KEY);
}
} catch {
/* ignore */
}
}
async function sendCode() {
if (!ensureAgreed()) return;
setMsg('');
try {
await request('PARTNER_H5', '/partner/auth/sms/send', {
method: 'POST',
body: JSON.stringify({ phone, scene: 'PARTNER_LOGIN' }),
});
setMsg('验证码已发送(Mock: 123456');
setCodeCooldown(60);
const timer = setInterval(() => {
setCodeCooldown((s) => {
if (s <= 1) {
clearInterval(timer);
return 0;
}
return s - 1;
});
}, 1000);
} catch (e) {
setMsg(e instanceof Error ? e.message : '发送失败');
}
}
async function login() {
if (!ensureAgreed()) return;
setLoading(true);
setMsg('');
try {
await request('PARTNER_H5', '/partner/auth/sms/send', {
method: 'POST',
@@ -23,26 +114,33 @@ export default function LoginPage() {
body: JSON.stringify({ phone, code }),
});
saveAuth(data);
persistRememberAccount(phone);
// 微信内:短信登录后自动发起 OAuth 绑定同一微信,下次可一键授权登录
if (isWechatEnv()) {
setMsg('登录成功,正在关联微信…');
await bindPartnerWechatAfterSmsLogin();
return;
}
navigate('/');
} catch (e) {
setMsg(e instanceof Error ? e.message : '登录失败');
} finally {
setLoading(false);
}
}
function sendCode() {
if (codeCooldown > 0) return;
request('PARTNER_H5', '/partner/auth/sms/send', {
method: 'POST',
body: JSON.stringify({ phone, scene: 'PARTNER_LOGIN' }),
}).then(() => {
setCodeCooldown(60);
const timer = setInterval(() => {
setCodeCooldown((s) => {
if (s <= 1) { clearInterval(timer); return 0; }
return s - 1;
});
}, 1000);
});
async function wechatLogin() {
if (!ensureAgreed()) return;
setMsg('');
setWxLoading(true);
try {
const ok = await loginPartnerWithWechat();
if (ok) navigate('/');
} catch (e) {
setMsg(formatWechatError(e));
} finally {
setWxLoading(false);
}
}
if (quick) {
@@ -70,6 +168,7 @@ export default function LoginPage() {
</section>
<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>}
<button type="button" className="partner-btn-primary" onClick={login} disabled={loading}>
<span>{loading ? '登录中...' : '一键登录'}</span>
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
@@ -113,21 +212,28 @@ export default function LoginPage() {
{codeCooldown > 0 ? `${codeCooldown}s 后重发` : '获取验证码'}
</button>
</div>
<label className="partner-checkbox-row">
<input type="checkbox" defaultChecked />
<label className="partner-remember-row">
<input
type="checkbox"
checked={rememberAccount}
onChange={(e) => setRememberAccount(e.target.checked)}
/>
<span></span>
</label>
<button type="button" className="partner-btn-primary" onClick={login} disabled={loading}>
<span>{loading ? '登录中...' : '登录'}</span>
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
</button>
{msg && <p className="partner-auth-msg" style={{ color: 'var(--color-error, #d33)', fontSize: 13, textAlign: 'center' }}>{msg}</p>}
<div className="partner-auth-divider"><span></span></div>
<button type="button" className="partner-btn-outline" style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="#07C160"><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>
<button type="button" className="partner-btn-wechat" onClick={wechatLogin} disabled={wxLoading}>
<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" />
</svg>
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
</button>
<label className="partner-checkbox-row">
<input type="checkbox" />
<input type="checkbox" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} />
<span>
<span className="text-primary" style={{ fontWeight: 600 }}></span> <span className="text-primary" style={{ fontWeight: 600 }}></span>
</span>
+79
View File
@@ -277,6 +277,58 @@ html, body, #root {
cursor: pointer;
}
.partner-btn-wechat {
width: 100%;
padding: 16px;
border: 1px solid rgba(226, 190, 188, 0.2);
border-radius: var(--radius-md);
background: var(--color-surface-container-low);
color: var(--color-on-surface);
font-family: var(--font-headline);
font-size: 18px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: background 0.2s, transform 0.1s;
}
.partner-btn-wechat:hover {
background: var(--color-surface-container-high);
}
.partner-btn-wechat:active {
transform: scale(0.98);
}
.partner-btn-wechat:disabled {
opacity: 0.6;
pointer-events: none;
}
.partner-btn-wechat svg {
width: 24px;
height: 24px;
flex-shrink: 0;
}
.partner-remember-row {
display: flex;
align-items: center;
gap: 8px;
padding: 0 4px;
font-size: 12px;
color: var(--color-on-surface-variant);
}
.partner-remember-row input {
width: 16px;
height: 16px;
accent-color: var(--color-heritage-red);
}
.partner-link {
display: block;
text-align: center;
@@ -1997,3 +2049,30 @@ html, body, #root {
justify-content: center;
gap: 8px;
}
/* ─────────────────────────────────────────────
iOS / Android 机型适配:顶部安全区(刘海 / 状态栏)
viewport-fit=cover 下内容会延伸到状态栏,需为吸顶头部补 inset。
───────────────────────────────────────────── */
.partner-home-header,
.header.app-page-header,
.app-page-header,
.page-header {
padding-top: env(safe-area-inset-top, 0px);
height: auto;
min-height: calc(56px + env(safe-area-inset-top, 0px));
box-sizing: border-box;
}
/* 登录 / 快捷登录页顶部留出状态栏空间,避免品牌区贴到刘海 */
.partner-auth-page {
padding-top: calc(48px + env(safe-area-inset-top, 0px));
}
/* 全屏容器铺满机身背景,安全区外也保持底色一致 */
html,
body {
min-height: 100vh;
min-height: 100dvh;
background: var(--color-background);
}
+3 -1
View File
@@ -11,6 +11,8 @@ export default defineConfig({
},
server: {
port: 5175,
proxy: { '/api': 'http://localhost:3000' },
proxy: {
'/api': process.env.VITE_API_TARGET ?? 'http://localhost:3000',
},
},
});