修两个后端接口问题:
1) /api/v1/workspace-directories 返回为空,目录树结构消失
2) 同 workspace 内脚本/数据互相可见但默认排除 private
后端改动
--------
* list_scripts / list_resources / list_workspace_directories 新增
owner_user_id 可选 query 参数;缺省 = 当前请求者本人(scope 到
workspace/{me}/...),传值时 scope 到该 owner 的子树。前端根加载
默认只见自己一级,其他成员以折叠分组呈现。
* visibility 过滤统一:非 admin 请求者只返回 owner==me 或
visibility ∈ {workspace, public};admin 跳过。owner=me 含自己
的 private,owner=other 只剩其 workspace/public,排除他人 private。
* create_workspace_directory 两个分支 visibility 默认 'public'
(非 private),使跨 owner 目录树可见;响应新增 owner_user_id 字段。
* platform.list_members 鉴权从 system_admin_context 放宽为
系统管理员或该 workspace 活跃成员(让普通用户也能渲染同
workspace 成员名册,用于跨 owner 分组)。
* main.py 注册 platform 模块(随 list_members 改动补齐导入)。
* .env.example 同步 common/config.py 26 个字段。
前端改动
--------
* ScriptExplorer.memberScriptGroups 改由 members 列表播种分组,
display_name 取 members.display_name;inferredDirectories 现在按
owner_user_id 标记,统一跨 owner 目录渲染。删除脚本目录页头与
树分组标题的工作副本数量角标。
* WorkspaceTree 新增 ownerUserId 透传到 store.toggleExpanded;
仅"我"的分组 mount 时 auto-expand,他人分组默认折叠,展开才
调 loadOwnerGroup / owner-scoped loadScripts / loadChildren。
* scriptWorkspaceStore 引入 namespaced cache key
(ownerCacheKey = `${ownerUserId ?? me}:${path}`),loadedScriptPaths
/ loadedChildPaths / loadedOwnerGroups 全部按 owner 隔离;
toggleExpanded 用 loadPath === undefined 区分 group 头与真实
目录,修"他人子目录点击不触发接口"的 loadPath 前缀误判 bug。
* api.ts / AuthContext 透传 ownerUserId 给 listScripts /
listResources / listWorkspaceDirectories。
文档
----
* API.md: §3.2 创建目录 visibility 默认 public + 响应加 owner_user_id;
§3.3.1 GET directories 加 owner_user_id 参数 + 响应字段;
§3.4 GET scripts 改写为 owner 作用域 + visibility 过滤语义;
§五.1 GET data-resources 新增,同一套统一语义;
§7 intro 例外 — GET members 对系统管理员或 workspace 活跃成员开放。
* DEVELOP.md: Code layout 重写以反映 backend api/services/clients/
schemas 拆分 + schedule domain/scheduling/application/execution/
infrastructure 拆分 + common 子包(auth/storage/backends);
Configuration 系统补全 26 个 settings 字段;新增
"Owner-scoping + visibility (cross-owner browsing)" 小节;
Per-service dev 注释用 uv run 的源布局要求;Add a new DAG endpoint /
storage bucket 路径改为 backend/src/backend/api/* 与 services/*。
测试
----
* test_list_scripts_parent_path.py /
test_resources.py 补充 owner_user_id 参数化直接调用 + LIKE
前缀断言(workspace/{owner}/... 前缀)。
Co-Authored-By: Claude <noreply@anthropic.com>
151 lines
4.9 KiB
TypeScript
151 lines
4.9 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import { Outlet, useLocation, useNavigate } from "react-router";
|
||
|
||
import type { Route } from "./+types/platform";
|
||
import { Sidebar } from "../components/common/Sidebar";
|
||
import { Toast } from "../components/common/Toast";
|
||
import { Topbar } from "../components/common/Topbar";
|
||
import { useApi, useAuth } from "../context/AuthContext";
|
||
import { useEditSessionLifecycle } from "../features/platform/hooks/useEditSessionLifecycle";
|
||
import {
|
||
bindScriptWorkspaceApi,
|
||
bindScriptWorkspaceId,
|
||
bindScriptWorkspaceUser,
|
||
editSessionHandle,
|
||
useScriptWorkspaceStore,
|
||
} from "../features/platform/state/scriptWorkspaceStore";
|
||
import { useUiStore } from "../features/platform/state/uiStore";
|
||
import { bindAdminApi } from "../features/admin/state/adminStore";
|
||
import { bindSchedulesApi } from "../features/schedules/state/schedulesStore";
|
||
|
||
import "../styles/platform.css";
|
||
|
||
type ActivePage = "home" | "scripts" | "schedules" | "system";
|
||
|
||
function pageFromPath(pathname: string): ActivePage {
|
||
const page = pathname.replace(/^\/+|\/+$/g, "");
|
||
return ["scripts", "schedules", "system"].includes(page)
|
||
? (page as ActivePage)
|
||
: "home";
|
||
}
|
||
|
||
function pathForPage(page: ActivePage): string {
|
||
if (page === "home") return "/workbench";
|
||
return `/${page}`;
|
||
}
|
||
|
||
export function meta({}: Route.MetaArgs) {
|
||
return [
|
||
{ title: "模型实验开发平台" },
|
||
{ name: "description", content: "模型实验、脚本版本与 DAG 调度平台" },
|
||
];
|
||
}
|
||
|
||
export default function PlatformLayout() {
|
||
const { currentWorkspace } = useAuth();
|
||
if (!currentWorkspace) {
|
||
return (
|
||
<div
|
||
className="app-shell"
|
||
style={{ display: "flex", alignItems: "center", justifyContent: "center" }}
|
||
>
|
||
<div style={{ textAlign: "center" }}>
|
||
<span style={{ fontSize: 18 }}>加载中…</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
return <AuthenticatedLayout />;
|
||
}
|
||
|
||
function AuthenticatedLayout() {
|
||
const api = useApi();
|
||
const location = useLocation();
|
||
const navigate = useNavigate();
|
||
const activePage = pageFromPath(location.pathname);
|
||
const auth = useAuth();
|
||
const {
|
||
user,
|
||
workspaces,
|
||
currentWorkspace,
|
||
setCurrentWorkspace,
|
||
logout,
|
||
} = auth;
|
||
|
||
const apiOnline = useScriptWorkspaceStore((s) => s.apiOnline);
|
||
const endEditing = useScriptWorkspaceStore((s) => s.endEditing);
|
||
const selectScript = useScriptWorkspaceStore((s) => s.selectScript);
|
||
|
||
const toast = useUiStore((s) => s.toast);
|
||
const dismissToast = useUiStore((s) => s.dismissToast);
|
||
const workspaceMenuOpen = useUiStore((s) => s.workspaceMenuOpen);
|
||
const setWorkspaceMenuOpen = useUiStore((s) => s.setWorkspaceMenuOpen);
|
||
|
||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||
|
||
// 绑定 api 到 script workspace store。
|
||
// 必须放在 render body(同步),不能用 useEffect([api]) + cleanup —
|
||
// 后者会在 deps 变化时先 cleanup(null),再让 ScriptsPage 的 useEffect 跑,
|
||
// 此时 _api 为 null,load() 抛 "script workspace API 未绑定"。
|
||
bindScriptWorkspaceApi(api);
|
||
bindSchedulesApi(api);
|
||
bindAdminApi(api);
|
||
// 当前用户 id + 工作区 id 同步绑定到 script workspace store(render body,
|
||
// 与 bindScriptWorkspaceApi 同理)。store 的 lazy 跨 owner 逻辑据此区分
|
||
// "我"与他人、并调用需要显式 workspaceId 的 listWorkspaceMembers。
|
||
bindScriptWorkspaceUser(user?.user_id ?? null);
|
||
bindScriptWorkspaceId(currentWorkspace?.workspace_id ?? null);
|
||
useEffect(() => {
|
||
return () => {
|
||
bindScriptWorkspaceApi(null);
|
||
bindSchedulesApi(null);
|
||
bindAdminApi(null);
|
||
bindScriptWorkspaceUser(null);
|
||
bindScriptWorkspaceId(null);
|
||
};
|
||
}, []);
|
||
|
||
// 心跳 / cleanup / 切页结束编辑 / 卸载前释放
|
||
useEditSessionLifecycle({ activePage });
|
||
|
||
// toast 自动消失
|
||
useEffect(() => {
|
||
if (!toast) return;
|
||
const timer = window.setTimeout(dismissToast, 3200);
|
||
return () => window.clearTimeout(timer);
|
||
}, [toast, dismissToast]);
|
||
|
||
return (
|
||
<div className="app-shell">
|
||
<Sidebar
|
||
activePage={activePage}
|
||
collapsed={sidebarCollapsed}
|
||
onNavigate={(page) => navigate(pathForPage(page))}
|
||
onToggleCollapse={() => setSidebarCollapsed((v) => !v)}
|
||
onEndEditing={() => void endEditing(true)}
|
||
onSelectScript={selectScript}
|
||
editSessionRef={editSessionHandle}
|
||
isSystemAdmin={user?.is_system_admin ?? false}
|
||
/>
|
||
|
||
<main className="main-area">
|
||
<Topbar
|
||
activePage={activePage}
|
||
apiOnline={apiOnline}
|
||
user={user}
|
||
currentWorkspace={currentWorkspace!}
|
||
workspaces={workspaces}
|
||
workspaceMenuOpen={workspaceMenuOpen}
|
||
onSetWorkspaceMenuOpen={setWorkspaceMenuOpen}
|
||
onSetCurrentWorkspace={setCurrentWorkspace}
|
||
onLogout={logout}
|
||
/>
|
||
|
||
<Outlet />
|
||
</main>
|
||
|
||
<Toast toast={toast} />
|
||
</div>
|
||
);
|
||
}
|