Files
model-platform/frontend/app/context/AuthContext.tsx
T
tao.chenandtao.chen c6ac886133 feat(scripts): GET /api/v1/scripts/count + DashboardRoute wiring
After #34 the workspace store only holds the root-level scripts plus
whatever subfolders the user has expanded. DashboardRoute's
"全部脚本"/"工作副本" counts derived from scripts.length therefore
underreport the workspace total until the user navigates to /scripts
and expands every folder.

Fix: separate count endpoint + dedicated store field, mounted
independently.

Backend — backend/src/backend/scripts.py
- New endpoint GET /api/v1/scripts/count.
- Route declared BEFORE /api/v1/scripts/{script_id}/... so FastAPI's
  declaration-order matching does not interpret "count" as a script_id.
- Returns { data: { total: number }, meta: {} }; SQL is a single
  COUNT(*) on scripts filtered by workspace_id + status='active'.

Frontend — services/api.ts + context/AuthContext.tsx
- countScripts(workspaceId) client; WorkspaceBoundApi gains the field;
  AuthContext binding forwards workspaceId.

Frontend — state/scriptWorkspaceStore.ts
- scriptCount: number | null, scriptCountLoading: boolean.
- loadScriptCount() action: idempotent (no-op while in-flight), silent
  on failure (dashboard tolerates a stale count).
- Initial state and reset() clear both fields.

Frontend — features/platform/DashboardRoute.tsx
- Subscribes to scriptCount; calls loadScriptCount() on mount.
- Falls back to scripts.length until the count resolves so the
  dashboard never blanks.

Tests — backend/tests/test_count_scripts.py (new)
- 3 unit tests: scalar result handling, NULL coercion, route callable.

Verified: pytest 55 passed (52 + 3 new); pnpm typecheck clean.
2026-09-02 10:10:41 +08:00

313 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="auth-loading" role="status">
<span />
正在验证登录状态…
</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) => rawApi.listScripts(workspaceId, parentPath),
countScripts: () => rawApi.countScripts(workspaceId),
listResources: (opts) => rawApi.listResources(workspaceId, 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?: string) =>
rawApi.listWorkspaceDirectories(workspaceId, parentPath ?? ""),
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]);
}