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.
This commit is contained in:
@@ -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;编辑器打开文件时调用此接口。
|
# 读取脚本正文或 Notebook JSON;编辑器打开文件时调用此接口。
|
||||||
@router.get("/api/v1/scripts/{script_id}/content")
|
@router.get("/api/v1/scripts/{script_id}/content")
|
||||||
async def get_script_content(
|
async def get_script_content(
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -222,6 +222,7 @@ export function useApi(): WorkspaceBoundApi {
|
|||||||
|
|
||||||
return useMemo<WorkspaceBoundApi>(() => ({
|
return useMemo<WorkspaceBoundApi>(() => ({
|
||||||
listScripts: (parentPath) => rawApi.listScripts(workspaceId, parentPath),
|
listScripts: (parentPath) => rawApi.listScripts(workspaceId, parentPath),
|
||||||
|
countScripts: () => rawApi.countScripts(workspaceId),
|
||||||
listResources: (opts) => rawApi.listResources(workspaceId, opts),
|
listResources: (opts) => rawApi.listResources(workspaceId, opts),
|
||||||
createScript: (input) => rawApi.createScript(workspaceId, input),
|
createScript: (input) => rawApi.createScript(workspaceId, input),
|
||||||
uploadScript: (file, parentPath, visibility) =>
|
uploadScript: (file, parentPath, visibility) =>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
|
|
||||||
import { DashboardPage } from "../../components/admin/DashboardPage";
|
import { DashboardPage } from "../../components/admin/DashboardPage";
|
||||||
@@ -5,12 +6,21 @@ import { useScriptWorkspaceStore } from "./state/scriptWorkspaceStore";
|
|||||||
|
|
||||||
export default function DashboardRoute() {
|
export default function DashboardRoute() {
|
||||||
const scripts = useScriptWorkspaceStore((s) => s.scripts);
|
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 apiOnline = useScriptWorkspaceStore((s) => s.apiOnline);
|
||||||
const navigate = useNavigate();
|
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 (
|
return (
|
||||||
<DashboardPage
|
<DashboardPage
|
||||||
scriptCount={scripts.length}
|
scriptCount={scriptCount ?? scripts.length}
|
||||||
online={apiOnline}
|
online={apiOnline}
|
||||||
onNavigate={(page) => {
|
onNavigate={(page) => {
|
||||||
if (page === "scripts") navigate("/scripts");
|
if (page === "scripts") navigate("/scripts");
|
||||||
|
|||||||
@@ -91,6 +91,11 @@ type State = {
|
|||||||
// every cache entry that has been loaded in this session.
|
// every cache entry that has been loaded in this session.
|
||||||
loadedScriptPaths: Set<string>;
|
loadedScriptPaths: Set<string>;
|
||||||
loadingScriptPaths: Set<string>;
|
loadingScriptPaths: Set<string>;
|
||||||
|
// 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;
|
readOnlyRefreshVersion: number;
|
||||||
@@ -131,6 +136,9 @@ type State = {
|
|||||||
// Lazy-load scripts directly under `parentPath`. Idempotent — repeated
|
// Lazy-load scripts directly under `parentPath`. Idempotent — repeated
|
||||||
// calls for an already-loaded path are no-ops; in-flight calls dedupe.
|
// calls for an already-loaded path are no-ops; in-flight calls dedupe.
|
||||||
loadScripts: (parentPath: string) => Promise<void>;
|
loadScripts: (parentPath: string) => Promise<void>;
|
||||||
|
// 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<void>;
|
||||||
toggleScriptLock: (script: ScriptItem) => Promise<void>;
|
toggleScriptLock: (script: ScriptItem) => Promise<void>;
|
||||||
openPublishDialog: (script: ScriptItem) => void;
|
openPublishDialog: (script: ScriptItem) => void;
|
||||||
submitPublish: (releaseNote: string, visibility: Visibility) => Promise<void>;
|
submitPublish: (releaseNote: string, visibility: Visibility) => Promise<void>;
|
||||||
@@ -215,6 +223,9 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
|
|||||||
loadedScriptPaths: new Set<string>(),
|
loadedScriptPaths: new Set<string>(),
|
||||||
loadingScriptPaths: new Set<string>(),
|
loadingScriptPaths: new Set<string>(),
|
||||||
|
|
||||||
|
scriptCount: null,
|
||||||
|
scriptCountLoading: false,
|
||||||
|
|
||||||
readOnlyRefreshVersion: 0,
|
readOnlyRefreshVersion: 0,
|
||||||
|
|
||||||
setApiOnline: (online) => set({ apiOnline: online }),
|
setApiOnline: (online) => set({ apiOnline: online }),
|
||||||
@@ -255,6 +266,8 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
|
|||||||
loadedChildPaths: new Set<string>(),
|
loadedChildPaths: new Set<string>(),
|
||||||
loadedScriptPaths: new Set<string>(),
|
loadedScriptPaths: new Set<string>(),
|
||||||
loadingScriptPaths: new Set<string>(),
|
loadingScriptPaths: new Set<string>(),
|
||||||
|
scriptCount: null,
|
||||||
|
scriptCountLoading: false,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -382,6 +395,22 @@ export const useScriptWorkspaceStore = create<State>((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) => {
|
loadChildren: async (parentPath) => {
|
||||||
const api = requireApi();
|
const api = requireApi();
|
||||||
if (get().loadedChildPaths.has(parentPath)) return;
|
if (get().loadedChildPaths.has(parentPath)) return;
|
||||||
|
|||||||
@@ -296,6 +296,22 @@ export async function listScripts(
|
|||||||
return apiRequest<ScriptItem[]>(`/api/v1/scripts${query}`, {}, workspaceId);
|
return apiRequest<ScriptItem[]>(`/api/v1/scripts${query}`, {}, workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function countScripts(
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<number> {
|
||||||
|
// 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 {
|
function initialContent(scriptType: ScriptType): string {
|
||||||
if (scriptType === "python") {
|
if (scriptType === "python") {
|
||||||
return [
|
return [
|
||||||
@@ -1466,6 +1482,7 @@ export type WorkspaceBoundApi = {
|
|||||||
listScripts: (
|
listScripts: (
|
||||||
parentPath?: Parameters<typeof listScripts>[1],
|
parentPath?: Parameters<typeof listScripts>[1],
|
||||||
) => Promise<ScriptItem[]>;
|
) => Promise<ScriptItem[]>;
|
||||||
|
countScripts: () => Promise<number>;
|
||||||
listResources: (
|
listResources: (
|
||||||
opts?: Parameters<typeof listResources>[1],
|
opts?: Parameters<typeof listResources>[1],
|
||||||
) => Promise<ResourceItem[]>;
|
) => Promise<ResourceItem[]>;
|
||||||
|
|||||||
Reference in New Issue
Block a user