feat(scripts): 跨 owner 懒加载目录树 + 跨用户可见 workspace/public
修两个后端接口问题:
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>
This commit is contained in:
@@ -60,6 +60,7 @@ export function ScriptExplorer({
|
||||
const loadingChildrenPaths = useScriptWorkspaceStore(
|
||||
(s) => s.loadingChildrenPaths,
|
||||
);
|
||||
const members = useScriptWorkspaceStore((s) => s.members);
|
||||
const onToggle = useScriptWorkspaceStore((s) => s.toggleExpanded);
|
||||
|
||||
const dataByOwner = useMemo(() => {
|
||||
@@ -83,9 +84,15 @@ export function ScriptExplorer({
|
||||
item.visibility === "public",
|
||||
);
|
||||
|
||||
// 用工作区成员列表播种分组 —— 顶层"我 / user1 / user2 / …"折叠分组
|
||||
// 的来源。即使某成员尚未加载任何脚本/数据(默认折叠、点击才拉取),
|
||||
// 也作为空分组出现,保证目录树结构稳定可见(修"目录树结构消失")。
|
||||
const byOwner = new Map<string, ScriptItem[]>();
|
||||
// 当前用户的目录树即使没有脚本也要渲染,所以预置空组。
|
||||
if (user?.user_id) {
|
||||
for (const m of members) {
|
||||
if (!byOwner.has(m.user_id)) byOwner.set(m.user_id, []);
|
||||
}
|
||||
// 当前用户兜底(members 未就绪时仍渲染"我"的分组)。
|
||||
if (user?.user_id && !byOwner.has(user.user_id)) {
|
||||
byOwner.set(user.user_id, []);
|
||||
}
|
||||
for (const item of visibleScripts) {
|
||||
@@ -93,15 +100,17 @@ export function ScriptExplorer({
|
||||
list.push(item);
|
||||
byOwner.set(item.owner_user_id, list);
|
||||
}
|
||||
|
||||
// data-only owner(只有数据资源、没有 scripts 的用户)也要出现在分组里,
|
||||
// 因为 data resources 与 scripts 共享同一棵目录树。
|
||||
// data-only owner(只有数据资源、没有 scripts 的用户,且不在 members 列表
|
||||
// 里,如已移除成员遗留的资源)也要出现在分组里。
|
||||
for (const ownerUserId of dataByOwner.keys()) {
|
||||
if (!byOwner.has(ownerUserId)) {
|
||||
byOwner.set(ownerUserId, []);
|
||||
}
|
||||
}
|
||||
|
||||
// 成员 id → display_name 优先取 members 列表(最准)。
|
||||
const memberName = new Map(members.map((m) => [m.user_id, m.display_name]));
|
||||
|
||||
const groups: {
|
||||
user: AuthUser | null;
|
||||
scripts: ScriptItem[];
|
||||
@@ -110,9 +119,8 @@ export function ScriptExplorer({
|
||||
}[] = [];
|
||||
for (const [ownerUserId, groupScripts] of byOwner.entries()) {
|
||||
const groupDataResources = dataByOwner.get(ownerUserId) ?? [];
|
||||
// data-only owner(没有 scripts 的用户)回退到 data resources 的
|
||||
// owner_display_name,否则 data-only owner 前端只能显示 userId 末 6 位。
|
||||
const displayName =
|
||||
memberName.get(ownerUserId) ??
|
||||
groupScripts[0]?.owner_display_name ??
|
||||
groupDataResources[0]?.owner_display_name ??
|
||||
(ownerUserId === user?.user_id ? user?.display_name : null) ??
|
||||
@@ -129,14 +137,16 @@ export function ScriptExplorer({
|
||||
role_code: null,
|
||||
is_system_admin: false,
|
||||
} as AuthUser);
|
||||
const inferred = inferredDirectories(groupScripts);
|
||||
const inferred = inferredDirectories(groupScripts, ownerUserId);
|
||||
// directories flat 数组现在按 owner_user_id 标记,按 owner 切分后与
|
||||
// inferred 合并(inferred 补全 fetched 目录行未覆盖的祖先路径)。
|
||||
const ownerDirs = directories.filter(
|
||||
(d) => d.owner_user_id === ownerUserId,
|
||||
);
|
||||
groups.push({
|
||||
user: groupUser,
|
||||
scripts: groupScripts,
|
||||
directories:
|
||||
ownerUserId === user?.user_id
|
||||
? mergeDirectories(directories, inferred)
|
||||
: inferred,
|
||||
directories: mergeDirectories(ownerDirs, inferred),
|
||||
dataResources: groupDataResources,
|
||||
});
|
||||
}
|
||||
@@ -144,18 +154,19 @@ export function ScriptExplorer({
|
||||
groups.sort((a, b) => {
|
||||
if (a.user?.user_id === user?.user_id) return -1;
|
||||
if (b.user?.user_id === user?.user_id) return 1;
|
||||
return (a.user?.user_id ?? "").localeCompare(b.user?.user_id ?? "");
|
||||
return (a.user?.display_name ?? a.user?.user_id ?? "").localeCompare(
|
||||
b.user?.display_name ?? b.user?.user_id ?? "",
|
||||
);
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [filteredScripts, directories, user, dataByOwner]);
|
||||
}, [filteredScripts, directories, user, dataByOwner, members]);
|
||||
|
||||
return (
|
||||
<aside className="explorer">
|
||||
<div className="explorer__header">
|
||||
<div>
|
||||
<h2>脚本目录</h2>
|
||||
<span>{scripts.length + dataResources.length} 个工作副本</span>
|
||||
</div>
|
||||
<div className="explorer__actions">
|
||||
<button
|
||||
@@ -217,6 +228,7 @@ export function ScriptExplorer({
|
||||
<WorkspaceTreeGroup
|
||||
key={ownerKey}
|
||||
groupKey={`__group__${ownerKey}`}
|
||||
ownerUserId={ownerKey}
|
||||
title={`${group.user?.display_name}`}
|
||||
scripts={group.scripts}
|
||||
directories={group.directories}
|
||||
@@ -272,14 +284,22 @@ function ownedScriptPath(item: ScriptItem) {
|
||||
return item.relative_path.replaceAll("\\", "/").split("/").slice(2).join("/");
|
||||
}
|
||||
|
||||
function inferredDirectories(items: ScriptItem[]): WorkspaceDirectory[] {
|
||||
function inferredDirectories(
|
||||
items: ScriptItem[],
|
||||
ownerUserId: string,
|
||||
): WorkspaceDirectory[] {
|
||||
const result = new Map<string, WorkspaceDirectory>();
|
||||
for (const item of items) {
|
||||
const parts = parentOf(ownedScriptPath(item)).split("/").filter(Boolean);
|
||||
let parentPath = "";
|
||||
for (const name of parts) {
|
||||
const path = parentPath ? `${parentPath}/${name}` : name;
|
||||
result.set(path, { path, name, parent_path: parentPath });
|
||||
result.set(path, {
|
||||
path,
|
||||
name,
|
||||
parent_path: parentPath,
|
||||
owner_user_id: ownerUserId,
|
||||
});
|
||||
parentPath = path;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user