Merge commit 'fcf1d45522e3ba6e6faf2590b60f2db1ef90e73c' into dev_jacy

This commit is contained in:
2026-07-07 13:55:10 +08:00
85 changed files with 11210 additions and 164 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() {
@@ -25,7 +26,7 @@ function WechatOAuthHandler() {
if (location.pathname === '/login') 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');
@@ -59,7 +60,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;
}
+35 -8
View File
@@ -1,4 +1,5 @@
import type { WechatLoginResult } from '@dukang/shared-types';
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
import { isWechatEnv, weixinSdk } from './weixin';
import { request, saveAuth } from './api';
@@ -19,20 +20,46 @@ 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 authorizePartnerWechat(): Promise<WechatLoginResult | void> {
if (!isWechatEnv()) {
throw new Error('请在微信内打开以完成授权');
}
return weixinSdk.login();
}
export async function handlePartnerWechatCallback(): Promise<WechatLoginResult | null> {
if (!isWechatEnv()) return null;
return weixinSdk.handleOAuthCallback();
}
/**
* 微信授权登录(对齐 C 端:仅微信内置浏览器走 OAuth)。
* 返回 true = 已登录;void = 已跳转授权页等待回调。
*/
export async function loginPartnerWithWechat(): Promise<boolean | void> {
if (!isWechatEnv()) {
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
}
const result = await weixinSdk.login();
if (result) 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(WECHAT_INAPP_REQUIRED_MSG);
}
return weixinSdk.login();
}
/** @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>
+111 -43
View File
@@ -2,37 +2,109 @@ import { useEffect, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { request, saveAuth } from '../lib/api';
import {
authorizePartnerWechat,
bindPartnerWechatAfterSmsLogin,
handlePartnerWechatCallback,
savePartnerWechatAuth,
handlePartnerWechatLoginResult,
loginPartnerWithWechat,
} from '../lib/wechat-auth';
import { isWechatEnv } from '../lib/weixin';
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
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 [wechatLoading, setWechatLoading] = useState(false);
const [codeCooldown, setCodeCooldown] = useState(0);
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 (savePartnerWechatAuth(result)) {
navigate('/');
}
if (handlePartnerWechatLoginResult(result)) navigate('/');
})
.catch((e) => setMsg(e instanceof Error ? e.message : '微信登录失败'));
.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',
@@ -43,43 +115,38 @@ export default function LoginPage() {
body: JSON.stringify({ phone, code }),
});
saveAuth(data);
persistRememberAccount(phone);
if (isWechatEnv()) {
setMsg('登录成功,正在关联微信…');
await bindPartnerWechatAfterSmsLogin();
return;
}
navigate('/');
} catch (e) {
setMsg(e instanceof Error ? e.message : '登录失败');
} finally {
setLoading(false);
}
}
async function wechatLogin() {
if (!ensureAgreed()) return;
setMsg('');
if (!isWechatEnv()) {
setMsg('请在微信内打开以使用微信一键登录');
setMsg(WECHAT_INAPP_REQUIRED_MSG);
return;
}
setWechatLoading(true);
setWxLoading(true);
try {
await authorizePartnerWechat();
const ok = await loginPartnerWithWechat();
if (ok) navigate('/');
} catch (e) {
setMsg(e instanceof Error ? e.message : '微信登录失败');
setWechatLoading(false);
setMsg(formatWechatError(e));
} finally {
setWxLoading(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);
});
}
if (quick) {
return (
<div className="partner-auth-page partner-auth-page--quick">
@@ -105,6 +172,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>}
@@ -148,28 +216,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 }}
disabled={wechatLoading || loading}
onClick={() => void wechatLogin()}
>
<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>
{wechatLoading ? '跳转授权中…' : '微信一键登录'}
<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>
{msg && <p className="partner-form-error" role="alert">{msg}</p>}
<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>
+140 -12
View File
@@ -6,6 +6,10 @@ html, body, #root {
max-width: 100%;
}
html {
scrollbar-gutter: stable;
}
/* ── Partner auth ── */
.partner-auth-page {
min-height: 100vh;
@@ -277,6 +281,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;
@@ -681,30 +737,75 @@ html, body, #root {
flex-shrink: 0;
}
/* ── Partner bottom tabbar ── */
.app-tabbar {
display: grid;
grid-template-columns: repeat(3, 1fr);
/* ── Partner bottom tabbar(锁定尺寸,仅切换颜色)── */
nav.app-tabbar {
display: flex;
flex-direction: row;
align-items: stretch;
justify-items: stretch;
padding: 8px 0 calc(8px + env(safe-area-inset-bottom, 0px));
justify-content: stretch;
padding: 0;
padding-bottom: env(safe-area-inset-bottom, 0px);
height: calc(56px + env(safe-area-inset-bottom, 0px));
box-sizing: border-box;
}
.app-tabbar-item {
flex: 1;
nav.app-tabbar .app-tabbar-item {
flex: 1 1 0;
min-width: 0;
width: 100%;
padding: 4px 2px;
max-width: 33.333%;
height: 56px;
padding: 6px 0 4px;
border-radius: 0;
gap: 2px;
color: var(--color-subtle-gray);
text-decoration: none;
transition: color 0.15s ease;
transform: none;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
user-select: none;
}
nav.app-tabbar .app-tabbar-item.active {
color: var(--color-heritage-red);
}
nav.app-tabbar .app-tabbar-item:active,
nav.app-tabbar .app-tabbar-item:focus,
nav.app-tabbar .app-tabbar-item:focus-visible {
transform: none;
outline: none;
}
nav.app-tabbar .app-tabbar-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 24px;
height: 24px;
font-size: 24px;
line-height: 1;
overflow: hidden;
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
.app-tabbar-label {
max-width: 100%;
nav.app-tabbar .app-tabbar-label {
display: block;
flex-shrink: 0;
width: 100%;
height: 14px;
line-height: 14px;
margin: 0;
padding: 0 1px;
font-size: 11px;
font-weight: 500;
letter-spacing: 0;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
box-sizing: border-box;
}
/* ── Store create stepper ── */
@@ -1997,3 +2098,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',
},
},
});