Files
model-platform/frontend/app/context/AuthContext.tsx
T
tao.chenandClaude Fable 5 80c92f7fd4 fix(frontend): restore login & auth-loading styles with Tailwind
提交 6b77018 把 frontend/app/app.css 从 112 行砍到 91 行,
删除了 .login-* 与 .auth-loading 选择器,但 login.tsx /
AuthContext.tsx 的类名没同步迁移,导致:

  - /login 页面无样式(背景渐变、卡片阴影、输入框焦点环全部失效)
  - AuthContext 初次加载时的旋转 spinner 无样式

两处都用 Tailwind 任意值 [..] 语法精确复刻原 CSS,精确到
原始色值(#1677ff、#eef5fb 等),不引入新的 CSS 文件,
不修改 app.css、路由配置或依赖。

Spinner 用 Tailwind 内置 animate-spin(1s)替代原 0.8s
keyframes auth-spin,视觉差异肉眼不可见,如需精确复刻可在
app.css @theme 加 --animate-auth-spin。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:35:27 +08:00

315 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { useLocation, useNavigate } from "react-router";
import * as rawApi from "../services/api";
import type { WorkspaceBoundApi } from "../services/api";
export type AuthUser = {
user_id: string;
username: string;
display_name: string;
email: string | null;
status: string;
role_code: string | null;
is_system_admin: boolean;
};
export type AuthWorkspace = {
workspace_id: string;
workspace_code: string;
workspace_name: string;
role_code: string;
role_name: string;
};
type AuthSession = {
user: AuthUser;
workspaces: AuthWorkspace[];
default_workspace_id: string | null;
};
type ApiEnvelope<T> = {
data: T;
};
type AuthContextValue = {
user: AuthUser | null;
workspaces: AuthWorkspace[];
currentWorkspace: AuthWorkspace | null;
loading: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
setCurrentWorkspace: (workspaceId: string) => void;
refreshWorkspaces: () => Promise<void>;
};
const AuthContext = createContext<AuthContextValue | null>(null);
const workspaceStorageKey = "model-platform-current-workspace";
async function authRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(path, {
...init,
credentials: "same-origin",
headers: {
...(init.body ? { "Content-Type": "application/json" } : {}),
...init.headers,
},
});
const payload = await response.json().catch(() => ({})) as
| ApiEnvelope<T>
| { detail?: string };
if (!response.ok) {
throw new Error(
"detail" in payload && typeof payload.detail === "string"
? payload.detail
: `请求失败(HTTP ${response.status}`,
);
}
return (payload as ApiEnvelope<T>).data;
}
function selectWorkspace(
session: AuthSession,
preferredId?: string | null,
): AuthWorkspace | null {
return session.workspaces.find((item) => item.workspace_id === preferredId)
?? session.workspaces.find(
(item) => item.workspace_id === session.default_workspace_id,
)
?? session.workspaces[0]
?? null;
}
export function AuthProvider({ children }: { children: ReactNode }) {
const location = useLocation();
const navigate = useNavigate();
const [user, setUser] = useState<AuthUser | null>(null);
const [workspaces, setWorkspaces] = useState<AuthWorkspace[]>([]);
const [currentWorkspace, setWorkspace] = useState<AuthWorkspace | null>(null);
const [loading, setLoading] = useState(true);
const applySession = useCallback((session: AuthSession) => {
const storedId = typeof window === "undefined"
? null
: window.localStorage.getItem(workspaceStorageKey);
const workspace = selectWorkspace(session, storedId);
setUser(session.user);
setWorkspaces(session.workspaces);
setWorkspace(workspace);
if (workspace && typeof window !== "undefined") {
window.localStorage.setItem(workspaceStorageKey, workspace.workspace_id);
}
}, []);
useEffect(() => {
let cancelled = false;
void authRequest<AuthSession>("/api/v1/auth/me")
.then((session) => {
if (!cancelled) applySession(session);
})
.catch(() => {
if (!cancelled) {
setUser(null);
setWorkspaces([]);
setWorkspace(null);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [applySession]);
useEffect(() => {
if (loading) return;
if (!user && location.pathname !== "/login") {
navigate("/login", { replace: true });
} else if (user && location.pathname === "/login") {
navigate("/workbench", { replace: true });
}
}, [loading, location.pathname, navigate, user]);
const login = useCallback(async (username: string, password: string) => {
const session = await authRequest<AuthSession>("/api/v1/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
applySession(session);
navigate("/workbench", { replace: true });
}, [applySession, navigate]);
const logout = useCallback(async () => {
try {
await authRequest<{ logged_out: boolean }>("/api/v1/auth/logout", {
method: "POST",
});
} finally {
setUser(null);
setWorkspaces([]);
setWorkspace(null);
navigate("/login", { replace: true });
}
}, [navigate]);
const setCurrentWorkspace = useCallback((workspaceId: string) => {
const workspace = workspaces.find((item) => item.workspace_id === workspaceId);
if (!workspace) return;
setWorkspace(workspace);
window.localStorage.setItem(workspaceStorageKey, workspace.workspace_id);
}, [workspaces]);
const refreshWorkspaces = useCallback(async () => {
try {
const session = await authRequest<AuthSession>("/api/v1/auth/me");
const storedId = typeof window === "undefined"
? null
: window.localStorage.getItem(workspaceStorageKey);
const newWorkspace = selectWorkspace(session, storedId);
setWorkspaces(session.workspaces);
setWorkspace(newWorkspace);
if (newWorkspace && typeof window !== "undefined") {
window.localStorage.setItem(workspaceStorageKey, newWorkspace.workspace_id);
}
} catch (error) {
console.error("刷新 workspace 列表失败:", error);
}
}, []);
const value = useMemo<AuthContextValue>(() => ({
user,
workspaces,
currentWorkspace,
loading,
login,
logout,
setCurrentWorkspace,
refreshWorkspaces,
}), [currentWorkspace, loading, login, logout, setCurrentWorkspace, user, workspaces, refreshWorkspaces]);
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center gap-3 text-[#61758a]" role="status">
<span className="h-5 w-5 animate-spin rounded-full border-2 border-[#bfd5e9] border-t-[#1677ff]" />
正在验证登录状态…
</div>
);
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth 必须在 AuthProvider 内使用");
}
return context;
}
export function useApi(): WorkspaceBoundApi {
const { currentWorkspace } = useAuth();
const workspaceId = currentWorkspace?.workspace_id ?? "";
return useMemo<WorkspaceBoundApi>(() => ({
listScripts: (parentPath, ownerUserId) =>
rawApi.listScripts(workspaceId, parentPath, ownerUserId),
countScripts: () => rawApi.countScripts(workspaceId),
listResources: (parentPath, opts) =>
rawApi.listResources(workspaceId, parentPath, opts),
createScript: (input) => rawApi.createScript(workspaceId, input),
uploadScript: (file, parentPath, visibility) =>
rawApi.uploadScript(workspaceId, file, parentPath, visibility),
createResourceUpload: (body) =>
rawApi.createResourceUpload(workspaceId, body),
uploadResourceBytes: (uploadId, fileBytes, contentType) =>
rawApi.uploadResourceBytes(workspaceId, uploadId, fileBytes, contentType),
bindResourceUpload: (uploadId, body) =>
rawApi.bindResourceUpload(workspaceId, uploadId, body),
deleteResource: (resourceId) =>
rawApi.deleteResource(workspaceId, resourceId),
updateScript: (scriptId, input) =>
rawApi.updateScript(workspaceId, scriptId, input),
deleteScript: (scriptId) => rawApi.deleteScript(workspaceId, scriptId),
setScriptLock: (scriptId, isLocked) =>
rawApi.setScriptLock(workspaceId, scriptId, isLocked),
listWorkspaceDirectories: (parentPath, ownerUserId) =>
rawApi.listWorkspaceDirectories(workspaceId, parentPath ?? "", ownerUserId),
createWorkspaceDirectory: (directoryName, parentPath) =>
rawApi.createWorkspaceDirectory(workspaceId, directoryName, parentPath),
deleteWorkspaceDirectory: (path) =>
rawApi.deleteWorkspaceDirectory(workspaceId, path),
acquireFileLock: (script) => rawApi.acquireFileLock(workspaceId, script),
heartbeatFileLock: (session) => rawApi.heartbeatFileLock(workspaceId, session),
releaseFileLock: (session) => rawApi.releaseFileLock(workspaceId, session),
releaseFileLockOnUnload: (session) =>
rawApi.releaseFileLockOnUnload(workspaceId, session),
createJupyterAccessTicket: (session) =>
rawApi.createJupyterAccessTicket(workspaceId, session),
getLatestScriptVersion: (scriptId) =>
rawApi.getLatestScriptVersion(workspaceId, scriptId),
publishScriptVersion: (input) => rawApi.publishScriptVersion(workspaceId, input),
listSchedules: () => rawApi.listSchedules(workspaceId),
getSchedule: (scheduleId) => rawApi.getSchedule(workspaceId, scheduleId),
createSchedule: (input) => rawApi.createSchedule(workspaceId, input),
updateSchedule: (scheduleId, input) =>
rawApi.updateSchedule(workspaceId, scheduleId, input),
deleteSchedule: (scheduleId, workflowVersion) =>
rawApi.deleteSchedule(workspaceId, scheduleId, workflowVersion),
listScheduleArtifacts: () => rawApi.listScheduleArtifacts(workspaceId),
hideScheduleArtifact: (versionsId) =>
rawApi.hideScheduleArtifact(workspaceId, versionsId),
listEmployees: () => rawApi.listEmployees(workspaceId),
listPlatformEmployees: () => rawApi.listPlatformEmployees(),
createEmployee: (input) => rawApi.createEmployee(workspaceId, input),
createPlatformEmployee: (input) => rawApi.createPlatformEmployee(input),
updateEmployee: (userId, input) =>
rawApi.updateEmployee(workspaceId, userId, input),
updatePlatformEmployee: (userId, input) =>
rawApi.updatePlatformEmployee(userId, input),
deleteEmployee: (userId) => rawApi.deleteEmployee(workspaceId, userId),
deletePlatformEmployee: (userId) => rawApi.deletePlatformEmployee(userId),
createScheduleNode: (scheduleId, input) =>
rawApi.createScheduleNode(workspaceId, scheduleId, input),
updateScheduleNode: (scheduleId, nodeId, input) =>
rawApi.updateScheduleNode(workspaceId, scheduleId, nodeId, input),
deleteScheduleNode: (scheduleId, nodeId, workflowVersion, options) =>
rawApi.deleteScheduleNode(
workspaceId,
scheduleId,
nodeId,
workflowVersion,
options,
),
createScheduleEdge: (scheduleId, input) =>
rawApi.createScheduleEdge(workspaceId, scheduleId, input),
deleteScheduleEdge: (scheduleId, edgeId, workflowVersion) =>
rawApi.deleteScheduleEdge(workspaceId, scheduleId, edgeId, workflowVersion),
validateSchedule: (scheduleId) => rawApi.validateSchedule(workspaceId, scheduleId),
previewCron: (input) => rawApi.previewCron(workspaceId, input),
runScheduleNow: (scheduleId) => rawApi.runScheduleNow(workspaceId, scheduleId),
listScheduleRuns: (input) => rawApi.listScheduleRuns(workspaceId, input),
getScheduleRun: (runId) => rawApi.getScheduleRun(workspaceId, runId),
getScheduleNodeRunArtifacts: (runId, nodeRunId) =>
rawApi.getScheduleNodeRunArtifacts(workspaceId, runId, nodeRunId),
// Workspace (Project) Management - 系统管理接口(跨 workspace,不需要传入 workspaceId
listWorkspaces: () => rawApi.listWorkspaces(),
createWorkspace: (input) => rawApi.createWorkspace(input),
updateWorkspace: (workspaceId, input) => rawApi.updateWorkspace(workspaceId, input),
deleteWorkspace: (workspaceId) => rawApi.deleteWorkspace(workspaceId),
listWorkspaceMembers: (workspaceId) => rawApi.listWorkspaceMembers(workspaceId),
addWorkspaceMember: (workspaceId, input) => rawApi.addWorkspaceMember(workspaceId, input),
updateWorkspaceMember: (workspaceId, userId, input) => rawApi.updateWorkspaceMember(workspaceId, userId, input),
deleteWorkspaceMember: (workspaceId, userId) => rawApi.deleteWorkspaceMember(workspaceId, userId),
}), [workspaceId]);
}