登陆优化
This commit is contained in:
+124
-67
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user