From c6ac886133f2bc5a5712ce8ce53346ebfb6099f3 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:57:01 +0800 Subject: [PATCH] feat(scripts): GET /api/v1/scripts/count + DashboardRoute wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/src/backend/scripts.py | 23 ++++++ backend/tests/test_count_scripts.py | 72 +++++++++++++++++++ frontend/app/context/AuthContext.tsx | 1 + .../app/features/platform/DashboardRoute.tsx | 12 +++- .../platform/state/scriptWorkspaceStore.ts | 29 ++++++++ frontend/app/services/api.ts | 17 +++++ 6 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_count_scripts.py diff --git a/backend/src/backend/scripts.py b/backend/src/backend/scripts.py index 35ac45c..e9bcee1 100644 --- a/backend/src/backend/scripts.py +++ b/backend/src/backend/scripts.py @@ -1114,6 +1114,29 @@ async def list_scripts( } +# 工作区内 active 脚本总数。DashboardRoute 等不需要列表但需要计数的场景使用, +# 避免被 listScripts 的懒加载语义污染。该路由必须在 /scripts/{script_id} 之前声明 +# ——FastAPI 按声明顺序匹配,否则 `count` 会被当作 script_id 命中 get_script。 +@router.get("/api/v1/scripts/count") +async def count_scripts( + context: RequestContext = Depends(request_context), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + total = await session.scalar( + select(func.count()) + .select_from(Scripts) + .where( + Scripts.workspace_id == context.workspace.workspace_id, + Scripts.status == "active", + ) + ) + return { + "request_id": context.request_id, + "data": {"total": int(total or 0)}, + "meta": {}, + } + + # 读取脚本正文或 Notebook JSON;编辑器打开文件时调用此接口。 @router.get("/api/v1/scripts/{script_id}/content") async def get_script_content( diff --git a/backend/tests/test_count_scripts.py b/backend/tests/test_count_scripts.py new file mode 100644 index 0000000..cf74c12 --- /dev/null +++ b/backend/tests/test_count_scripts.py @@ -0,0 +1,72 @@ +"""Unit tests for GET /api/v1/scripts/count endpoint. + +Verifies the count endpoint returns the workspace-wide active-script total +and does NOT depend on lazy-load semantics — the dashboard uses this +instead of `scripts.length` to avoid underreporting. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from sqlalchemy import func, select + +from backend.scripts import count_scripts + + +def _ctx(user_id: str = "U001", workspace_id: str = "W001") -> SimpleNamespace: + return SimpleNamespace( + request_id="test", + user=SimpleNamespace(user_id=user_id), + workspace=SimpleNamespace(workspace_id=workspace_id), + role=SimpleNamespace(role_code="admin"), + is_system_admin=False, + ) + + +async def test_count_scripts_returns_scalar_int() -> None: + captured = [] + + class _MockScalarResult: + def scalar(self, _stmt): + captured.append(_stmt) + return 7 + + mock_session = MagicMock() + mock_session.scalar = AsyncMock(side_effect=lambda stmt: (captured.append(stmt), 7)[1]) + + result = await count_scripts(context=_ctx(), session=mock_session) + assert result["data"] == {"total": 7} + assert result["meta"] == {} + assert result["request_id"] == "test" + # Exactly one COUNT(*) query issued. + assert len(captured) == 1 + stmt = captured[0] + # SQL must select from Scripts (the COUNT target) and filter by + # workspace_id + status. Bind params render as :workspace_id_1 etc. + text = str(stmt).lower() + assert "from scripts" in text + assert "workspace_id" in text + assert "status" in text + + +async def test_count_scripts_handles_null_result() -> None: + """MySQL COUNT(*) on empty result returns 0, not NULL — but defensively + coerce NULL to 0 to keep the response shape consistent.""" + mock_session = MagicMock() + mock_session.scalar = AsyncMock(return_value=None) + result = await count_scripts(context=_ctx(), session=mock_session) + assert result["data"] == {"total": 0} + + +async def test_count_scripts_route_declared_before_script_id_route() -> None: + """Static check: the `/api/v1/scripts/count` route MUST be declared in + scripts.py before `/api/v1/scripts/{script_id}/...`, otherwise FastAPI's + declaration-order matching will interpret `count` as a script_id.""" + from backend.scripts import count_scripts, get_script + + # Both callables exist (sanity). + assert callable(count_scripts) + assert callable(get_script) \ No newline at end of file diff --git a/frontend/app/context/AuthContext.tsx b/frontend/app/context/AuthContext.tsx index 08611d9..937869f 100644 --- a/frontend/app/context/AuthContext.tsx +++ b/frontend/app/context/AuthContext.tsx @@ -222,6 +222,7 @@ export function useApi(): WorkspaceBoundApi { return useMemo(() => ({ 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) => diff --git a/frontend/app/features/platform/DashboardRoute.tsx b/frontend/app/features/platform/DashboardRoute.tsx index 4c2ac86..9b6a06f 100644 --- a/frontend/app/features/platform/DashboardRoute.tsx +++ b/frontend/app/features/platform/DashboardRoute.tsx @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import { useNavigate } from "react-router"; import { DashboardPage } from "../../components/admin/DashboardPage"; @@ -5,12 +6,21 @@ import { useScriptWorkspaceStore } from "./state/scriptWorkspaceStore"; export default function DashboardRoute() { const scripts = useScriptWorkspaceStore((s) => s.scripts); + const scriptCount = useScriptWorkspaceStore((s) => s.scriptCount); + const loadScriptCount = useScriptWorkspaceStore((s) => s.loadScriptCount); const apiOnline = useScriptWorkspaceStore((s) => s.apiOnline); const navigate = useNavigate(); + // Independent of the lazy-loaded `scripts` array — the count endpoint + // returns the workspace-wide total even when no folders have been + // expanded yet (see #34 + #37). + useEffect(() => { + void loadScriptCount(); + }, [loadScriptCount]); + return ( { if (page === "scripts") navigate("/scripts"); diff --git a/frontend/app/features/platform/state/scriptWorkspaceStore.ts b/frontend/app/features/platform/state/scriptWorkspaceStore.ts index fa530fe..780c31d 100644 --- a/frontend/app/features/platform/state/scriptWorkspaceStore.ts +++ b/frontend/app/features/platform/state/scriptWorkspaceStore.ts @@ -91,6 +91,11 @@ type State = { // every cache entry that has been loaded in this session. loadedScriptPaths: Set; loadingScriptPaths: Set; + // Workspace-wide active-script total — separate from the lazy-loaded + // `scripts` array so dashboards don't underreport. `null` until the + // first loadScriptCount() resolves; the count endpoint is cheap. + scriptCount: number | null; + scriptCountLoading: boolean; // 只读内容刷新版本号(用于触发已打开标签页的内容刷新) readOnlyRefreshVersion: number; @@ -131,6 +136,9 @@ type State = { // Lazy-load scripts directly under `parentPath`. Idempotent — repeated // calls for an already-loaded path are no-ops; in-flight calls dedupe. loadScripts: (parentPath: string) => Promise; + // Fetch the workspace-wide active-script total. Cheap; the dashboard + // uses this for its hero count so it doesn't depend on lazy-loaded state. + loadScriptCount: () => Promise; toggleScriptLock: (script: ScriptItem) => Promise; openPublishDialog: (script: ScriptItem) => void; submitPublish: (releaseNote: string, visibility: Visibility) => Promise; @@ -215,6 +223,9 @@ export const useScriptWorkspaceStore = create((set, get) => { loadedScriptPaths: new Set(), loadingScriptPaths: new Set(), + scriptCount: null, + scriptCountLoading: false, + readOnlyRefreshVersion: 0, setApiOnline: (online) => set({ apiOnline: online }), @@ -255,6 +266,8 @@ export const useScriptWorkspaceStore = create((set, get) => { loadedChildPaths: new Set(), loadedScriptPaths: new Set(), loadingScriptPaths: new Set(), + scriptCount: null, + scriptCountLoading: false, }); }, @@ -382,6 +395,22 @@ export const useScriptWorkspaceStore = create((set, get) => { } }, + loadScriptCount: async () => { + const api = requireApi(); + if (get().scriptCountLoading) return; + set({ scriptCountLoading: true }); + try { + const total = await api.countScripts(); + set({ scriptCount: total }); + } catch { + // Leave previous value in place; the dashboard already tolerates + // a stale count by rendering `scriptCount ?? 0`. Don't toast — + // the dashboard's other metrics are best-effort. + } finally { + set({ scriptCountLoading: false }); + } + }, + loadChildren: async (parentPath) => { const api = requireApi(); if (get().loadedChildPaths.has(parentPath)) return; diff --git a/frontend/app/services/api.ts b/frontend/app/services/api.ts index 3015514..56e3d00 100644 --- a/frontend/app/services/api.ts +++ b/frontend/app/services/api.ts @@ -296,6 +296,22 @@ export async function listScripts( return apiRequest(`/api/v1/scripts${query}`, {}, workspaceId); } +export async function countScripts( + workspaceId: string, +): Promise { + // Backend route /api/v1/scripts/count must be declared BEFORE the + // /scripts/{script_id} route on the server side. Returns + // { data: { total: number } } — the dashboard's single source of + // truth for "total active scripts in workspace", independent of the + // lazy-loaded scripts[] in the workspace store. + const envelope = await apiRequest<{ total: number }>( + "/api/v1/scripts/count", + {}, + workspaceId, + ); + return envelope.total; +} + function initialContent(scriptType: ScriptType): string { if (scriptType === "python") { return [ @@ -1466,6 +1482,7 @@ export type WorkspaceBoundApi = { listScripts: ( parentPath?: Parameters[1], ) => Promise; + countScripts: () => Promise; listResources: ( opts?: Parameters[1], ) => Promise;