登陆优化

This commit is contained in:
Azmat@qq.com
2026-09-17 10:28:37 +08:00
parent b487b65575
commit bd4799151c
8 changed files with 222 additions and 95 deletions
+124 -67
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { api, ApiError, getToken, setToken } from "./api";
import { MonitorOff } from "lucide-react";
import { api, ApiError, AUTH_INVALIDATED_EVENT, getToken, setToken } from "./api";
import { IconKitSvg } from "./components/IconKitSvg";
import type {
AITask,
@@ -24,6 +25,7 @@ import { generationErrorText } from "./generation-error";
import { isQuickCreateBusy, lockedQuickCreateProject, rememberQuickCreateJob, withQuickCreateStatus } from "./quick-create-lock";
import { AccountMenu, CornerMarks, Decorations, ModeTabs, openCommandPalette, Sidebar, ToastLike, topModuleForPage } from "./components/app-shell";
import { SystemLoading } from "./components/loading";
import { ConfirmModal } from "./components/overlays";
import {
AccountPage,
AssetFactoryPage,
@@ -110,6 +112,7 @@ export function App() {
const [authMode, setAuthMode] = useState<AuthMode>(route.authMode);
const [authed, setAuthed] = useState<boolean>(() => Boolean(getToken()));
const [booting, setBooting] = useState<boolean>(() => Boolean(getToken()));
const [sessionInvalidated, setSessionInvalidated] = useState(false);
const [user, setUser] = useState<User | null>(null);
const [team, setTeam] = useState<Team | null>(null);
@@ -157,6 +160,13 @@ export function App() {
return () => clearTimeout(timer);
}, [notice]);
// 页面自己的轮询即使吞掉接口错误,也不能吞掉“账号已在其他设备登录”的全局提示。
useEffect(() => {
const onInvalidated = () => setSessionInvalidated(true);
window.addEventListener(AUTH_INVALIDATED_EVENT, onInvalidated);
return () => window.removeEventListener(AUTH_INVALIDATED_EVENT, onInvalidated);
}, []);
const activeProduct = useMemo(
() => products.find((product) => product.id === activeProductId) || products[0],
[products, activeProductId]
@@ -292,6 +302,12 @@ export function App() {
} catch (bootError) {
// 仅「身份校验失败」才登出;me() 成功后的数据加载失败不在此处
console.error("[boot] identity failed:", bootError);
const status = bootError instanceof ApiError ? bootError.status : 0;
if (status === 401) {
if (!cancelled) setSessionInvalidated(true);
if (!cancelled) setBooting(false);
return;
}
setToken(null);
if (!cancelled) setAuthed(false);
if (!cancelled) setBooting(false);
@@ -867,6 +883,7 @@ export function App() {
async function onAuthed(payload: { token: string; user: User; team: Team; role?: string; remember?: boolean }) {
setToken(payload.token, payload.remember ?? true);
setSessionInvalidated(false);
setUser(payload.user);
setTeam(payload.team);
setRole(payload.role || "");
@@ -901,42 +918,79 @@ export function App() {
window.history.replaceState(null, "", "/login");
}
function returnToLoginAfterInvalidation() {
setToken(null);
setSessionInvalidated(false);
setAuthed(false);
setBooting(false);
setUser(null);
setTeam(null);
setRole("");
setAuthMode("login");
window.history.replaceState(null, "", "/login");
}
const sessionInvalidatedModal = (
<ConfirmModal
open={sessionInvalidated}
title="当前账号已在其他设备登录"
subtitle="// SESSION REPLACED"
icon={<MonitorOff size={16} />}
detail="为保护账号安全,当前设备的登录已失效。点击确认后返回登录页面。"
confirmText="确认并返回登录"
dismissable={false}
showCancel={false}
priority
onCancel={() => undefined}
onConfirm={returnToLoginAfterInvalidation}
/>
);
// ---- Auth gate ----
if (!authed) {
return (
<AuthScreen
initialMode={authMode}
onModeChange={(next) => {
setAuthMode(next);
window.history.pushState(null, "", next === "register" ? "/register" : "/login");
}}
onAuthed={onAuthed}
/>
<>
<AuthScreen
initialMode={authMode}
onModeChange={(next) => {
setAuthMode(next);
window.history.pushState(null, "", next === "register" ? "/register" : "/login");
}}
onAuthed={onAuthed}
/>
{sessionInvalidatedModal}
</>
);
}
// 平台超管后台:独立外壳,不依赖团队(超管可无团队),须在 !team 守卫之前分流。
if (!booting && user && route.admin !== undefined && user.is_platform_admin) {
return (
<AdminApp
section={route.admin}
user={user}
team={team}
navigateAdmin={navigateAdmin}
navigate={navigate}
logout={logout}
/>
<>
<AdminApp
section={route.admin}
user={user}
team={team}
navigateAdmin={navigateAdmin}
navigate={navigate}
logout={logout}
/>
{sessionInvalidatedModal}
</>
);
}
if (booting || !user || !team) {
return (
<SystemLoading
variant="fullscreen"
title="正在进入工作台"
description="正在同步账号与团队数据,请稍候。"
icon="layoutDashboard"
/>
<>
<SystemLoading
variant="fullscreen"
title="正在进入工作台"
description="正在同步账号与团队数据,请稍候。"
icon="layoutDashboard"
/>
{sessionInvalidatedModal}
</>
);
}
@@ -1303,50 +1357,53 @@ export function App() {
) : null;
return (
<div className="app">
<Sidebar page={page} navigate={navigate} user={currentUser} team={currentTeam} canManageBilling={isOwner} products={products} projects={projects} productTotal={productTotal} projectTotal={projectTotal} aiUnread={aiUnread} onOpenAdmin={() => navigateAdmin("")} accountOpen={accountAnchor !== null} onOpenAccount={(rect) => setAccountAnchor((prev) => (prev ? null : rect))} />
<header className="topbar">
{page === "omniSession" ? (
<div id="omni-session-topbar-slot" className="omni-session-topbar-slot" />
) : (
<ModeTabs active={topModule} navigate={navigate} />
)}
<div className="right">
{page !== "omniSession" && (
<button className="top-search" type="button" onClick={() => openCommandPalette()} aria-label="搜索">
<IconKitSvg name="search" />
<span></span>
<span className="kbd">{searchKbd}</span>
</button>
<>
<div className="app">
<Sidebar page={page} navigate={navigate} user={currentUser} team={currentTeam} canManageBilling={isOwner} products={products} projects={projects} productTotal={productTotal} projectTotal={projectTotal} aiUnread={aiUnread} onOpenAdmin={() => navigateAdmin("")} accountOpen={accountAnchor !== null} onOpenAccount={(rect) => setAccountAnchor((prev) => (prev ? null : rect))} />
<header className="topbar">
{page === "omniSession" ? (
<div id="omni-session-topbar-slot" className="omni-session-topbar-slot" />
) : (
<ModeTabs active={topModule} navigate={navigate} />
)}
<span className="balance-chip" onClick={() => navigate("account")}>
<IconKitSvg name="creditCard" />
<strong>{money(billing?.account.balance)}</strong>
</span>
<button className="icon-btn" type="button" onClick={() => navigate("messages")} title="消息中心">
<IconKitSvg name="bell" />
{unreadCount > 0 && <span className="count-noti">{unreadCount}</span>}
</button>
</div>
</header>
<main>
<Decorations />
<div className="content" id="page-content" key={page}>
<CornerMarks />
{notice && <ToastLike notice={notice} />}
{pipelinePage || renderPage()}
</div>
</main>
<AccountMenu
open={accountAnchor !== null}
anchorRect={accountAnchor}
onClose={() => setAccountAnchor(null)}
navigate={navigate}
logout={logout}
user={currentUser}
team={currentTeam}
canManageBilling={isOwner}
/>
</div>
<div className="right">
{page !== "omniSession" && (
<button className="top-search" type="button" onClick={() => openCommandPalette()} aria-label="搜索">
<IconKitSvg name="search" />
<span></span>
<span className="kbd">{searchKbd}</span>
</button>
)}
<span className="balance-chip" onClick={() => navigate("account")}>
<IconKitSvg name="creditCard" />
<strong>{money(billing?.account.balance)}</strong>
</span>
<button className="icon-btn" type="button" onClick={() => navigate("messages")} title="消息中心">
<IconKitSvg name="bell" />
{unreadCount > 0 && <span className="count-noti">{unreadCount}</span>}
</button>
</div>
</header>
<main>
<Decorations />
<div className="content" id="page-content" key={page}>
<CornerMarks />
{notice && <ToastLike notice={notice} />}
{pipelinePage || renderPage()}
</div>
</main>
<AccountMenu
open={accountAnchor !== null}
anchorRect={accountAnchor}
onClose={() => setAccountAnchor(null)}
navigate={navigate}
logout={logout}
user={currentUser}
team={currentTeam}
canManageBilling={isOwner}
/>
</div>
{sessionInvalidatedModal}
</>
);
}