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:
tao.chen
2026-08-21 19:26:53 +08:00
co-authored by Claude
parent 8cf4b53dd4
commit b493907775
15 changed files with 929 additions and 303 deletions
@@ -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;
}
}
+4 -3
View File
@@ -221,7 +221,8 @@ export function useApi(): WorkspaceBoundApi {
const workspaceId = currentWorkspace?.workspace_id ?? "";
return useMemo<WorkspaceBoundApi>(() => ({
listScripts: (parentPath) => rawApi.listScripts(workspaceId, parentPath),
listScripts: (parentPath, ownerUserId) =>
rawApi.listScripts(workspaceId, parentPath, ownerUserId),
countScripts: () => rawApi.countScripts(workspaceId),
listResources: (parentPath, opts) =>
rawApi.listResources(workspaceId, parentPath, opts),
@@ -241,8 +242,8 @@ export function useApi(): WorkspaceBoundApi {
deleteScript: (scriptId) => rawApi.deleteScript(workspaceId, scriptId),
setScriptLock: (scriptId, isLocked) =>
rawApi.setScriptLock(workspaceId, scriptId, isLocked),
listWorkspaceDirectories: (parentPath?: string) =>
rawApi.listWorkspaceDirectories(workspaceId, parentPath ?? ""),
listWorkspaceDirectories: (parentPath, ownerUserId) =>
rawApi.listWorkspaceDirectories(workspaceId, parentPath ?? "", ownerUserId),
createWorkspaceDirectory: (directoryName, parentPath) =>
rawApi.createWorkspaceDirectory(workspaceId, directoryName, parentPath),
deleteWorkspaceDirectory: (path) =>
@@ -28,8 +28,11 @@ type WorkspaceTreeProps = {
onCopyResourcePath?: (jupyterPath: string) => void;
// 唯一标识此 group(通常 `__group__<owner_user_id>`),让多个 owner 的 group 各自独立展开。
groupKey: string;
// 该 group 所属 owner 的 user_id —— 透传给 store.toggleExpanded,使他人
// 分组展开时走 loadOwnerGroup / 带 owner 的 loadScripts(懒加载)。
ownerUserId: string;
expandedPaths: Set<string>;
onToggle: (path: string, loadPath?: string) => void;
onToggle: (path: string, loadPath?: string, ownerUserId?: string) => void;
loadingChildrenPaths: Set<string>;
};
@@ -79,6 +82,7 @@ export function WorkspaceTreeGroup({
dataResources,
onCopyResourcePath,
groupKey,
ownerUserId,
expandedPaths,
onToggle,
loadingChildrenPaths,
@@ -125,14 +129,16 @@ export function WorkspaceTreeGroup({
parent_path: path.includes("/")
? path.split("/").slice(0, -1).join("/")
: "",
owner_user_id: ownerUserId,
}));
}, [dataResources]);
}, [dataResources, ownerUserId]);
// 首次挂载自动展开(保留原本 useState(true) 的默认展开行为)。
// toggleExpanded 内识别 `__group__` 前缀,不会触发 loadChildren。
// 默认只展开"我"的分组(!readOnly);其他成员分组默认折叠,点击才
// 按需拉取其可见内容(懒加载设计)。原本对所有 group 无条件 onToggle
// 会让所有 owner 的内容在根加载时就被全量拉取,违背"默认只拉取自己的一级"。
useEffect(() => {
if (!expandedPaths.has(groupKey)) {
void onToggle(groupKey);
if (!readOnly && !expandedPaths.has(groupKey)) {
void onToggle(groupKey, undefined, ownerUserId);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [groupKey]);
@@ -142,7 +148,7 @@ export function WorkspaceTreeGroup({
className={`tree-group__title${open ? " is-open" : ""}`}
type="button"
aria-expanded={open}
onClick={() => onToggle(groupKey)}
onClick={() => onToggle(groupKey, undefined, ownerUserId)}
onContextMenu={onContextMenu
? (event) => onContextMenu(event, { kind: "root", path: "" })
: undefined}
@@ -150,12 +156,12 @@ export function WorkspaceTreeGroup({
<Icon name="chevron" size={14} />
<Icon name="folder" size={17} />
<span>{title}</span>
<em>{scripts.length + (dataResources ?? []).length}</em>
</button>
{open && (
<div className="tree-group__items">
<WorkspaceTreeItems
groupKey={groupKey}
ownerUserId={ownerUserId}
path=""
depth={0}
scripts={[...scripts, ...dataResourceScripts]}
@@ -186,6 +192,7 @@ export function WorkspaceTreeGroup({
function WorkspaceTreeItems({
groupKey,
ownerUserId,
path,
depth,
scripts,
@@ -210,6 +217,7 @@ function WorkspaceTreeItems({
<DirectoryBranch
key={directory.path}
groupKey={groupKey}
ownerUserId={ownerUserId}
directory={directory}
depth={depth}
scripts={scripts}
@@ -271,6 +279,7 @@ function WorkspaceTreeItems({
function DirectoryBranch({
groupKey,
ownerUserId,
directory,
depth,
scripts,
@@ -294,7 +303,7 @@ function DirectoryBranch({
className="directory-row"
style={{ paddingLeft: 10 + depth * 16 }}
type="button"
onClick={() => onToggle(expandKey, directory.path)}
onClick={() => onToggle(expandKey, directory.path, ownerUserId)}
onContextMenu={onContextMenu
? (event) => onContextMenu(event, {
kind: "directory",
@@ -312,6 +321,7 @@ function DirectoryBranch({
{open && (
<WorkspaceTreeItems
groupKey={groupKey}
ownerUserId={ownerUserId}
path={directory.path}
depth={depth + 1}
scripts={scripts}
@@ -9,6 +9,7 @@ import type {
Visibility,
WorkspaceBoundApi,
WorkspaceDirectory,
WorkspaceMember,
} from "~/services/api";
import type { NewScriptForm } from "./uiStore";
@@ -41,11 +42,35 @@ let _previewController: AbortController | null = null;
let _previewRequest = 0;
let _pythonEditorOpeningIds = new Set<string>();
let _scriptCountSeq = 0;
// 当前登录用户 id —— 与 `_api` 一样由 layout 在 render body 绑定。
// 用于:(1) `loadScripts`/`loadOwnerGroup` 区分"我"与他人;
// (2) `toggleExpanded` 判定展开真实目录时是否需要 loadChildren(他人的
// 目录全靠脚本路径推断,不调 listWorkspaceDirectories)。
let _currentUserId: string | null = null;
// 当前工作区 id —— listWorkspaceMembers 不像 listScripts 那样把 workspaceId
// 烤进 bound api,需要显式传入,故由 layout 绑定。
let _workspaceId: string | null = null;
export const bindScriptWorkspaceApi = (api: WorkspaceBoundApi | null) => {
_api = api;
};
export const bindScriptWorkspaceUser = (userId: string | null) => {
_currentUserId = userId;
};
export const bindScriptWorkspaceId = (workspaceId: string | null) => {
_workspaceId = workspaceId;
};
// `loadedScriptPaths` / `loadedChildPaths` 的 key 命名空间:
// `${owner_user_id}:${parent_path}`,让"我"与他人的同名子目录缓存互不串扰。
// owner 缺省时回退当前用户,再回退字面量 "me"(仅作占位 key,不会发到后端)。
function ownerCacheKey(ownerUserId: string | undefined, path: string): string {
const owner = ownerUserId ?? _currentUserId ?? "me";
return `${owner}:${path}`;
}
export const getSessionCache = () => sessionCache;
export const clearSessionCache = () => {
sessionCache.clear();
@@ -86,10 +111,9 @@ type State = {
expandedPaths: Set<string>;
loadingChildrenPaths: Set<string>;
loadedChildPaths: Set<string>;
// Per-parent-path script cache. Keys are user-relative parent paths;
// values are scripts whose storage path lives directly under that parent.
// The flat `scripts` array above is the union (deduped by script_id) of
// every cache entry that has been loaded in this session.
// Per-owner × parent-path script cache. Keys are namespaced
// `${owner_user_id}:${parent_path}` (see ownerCacheKey) so "我"与他人
// 的同名子目录互不串扰。flat `scripts` 数组是其并集(按 script_id 去重)。
loadedScriptPaths: Set<string>;
loadingScriptPaths: Set<string>;
// Workspace-wide active-script total — separate from the lazy-loaded
@@ -98,6 +122,14 @@ type State = {
scriptCount: number | null;
scriptCountLoading: boolean;
// 工作区成员列表 —— 目录树顶层"我 / user1 / user2 / …"折叠分组的来源。
// 默认只加载"我"的一级目录;其他成员分组折叠,点击才按需拉取
// 其可见(workspace/public)内容、排除其 private。
members: WorkspaceMember[];
// 已拉取根级内容的 ownerloadOwnerGroup 标记),避免重复拉取。
loadedOwnerGroups: Set<string>;
loadingOwnerGroups: Set<string>;
// 只读内容刷新版本号(用于触发已打开标签页的内容刷新)
readOnlyRefreshVersion: number;
@@ -106,7 +138,7 @@ type State = {
setKeyword: (keyword: string) => void;
reset: () => void;
load: (silent?: boolean) => Promise<void>;
loadDataResources: (parentPath?: string) => Promise<void>;
loadDataResources: (parentPath?: string, ownerUserId?: string) => Promise<void>;
selectScript: (id: string | null) => void;
openTab: (id: string) => void;
closeTab: (id: string, event?: { stopPropagation: () => void }) => Promise<void>;
@@ -132,11 +164,16 @@ type State = {
deleteScript: (script: ScriptItem) => Promise<void>;
deleteDataResource: (resourceId: string) => Promise<void>;
deleteDirectory: (path: string) => Promise<void>;
toggleExpanded: (path: string, loadPath?: string) => Promise<void>;
loadChildren: (parentPath: string) => Promise<void>;
// 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<void>;
toggleExpanded: (path: string, loadPath?: string, ownerUserId?: string) => Promise<void>;
loadChildren: (parentPath: string, ownerUserId?: string) => Promise<void>;
// Lazy-load scripts directly under `parentPath` for `ownerUserId`(缺省
// = 当前用户)。Idempotent — repeated calls for an already-loaded
// owner×path are no-ops; in-flight calls dedupe.
loadScripts: (parentPath: string, ownerUserId?: string) => Promise<void>;
// 按需拉取某成员的根级可见脚本 + 数据资源(点击其折叠分组时触发)。
// Always fetches (refresh-safe); toggleExpanded 的分组头分支负责守门
// 避免重复拉取,loadedOwnerGroups 标记已加载状态。
loadOwnerGroup: (ownerUserId: 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>;
@@ -227,6 +264,10 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
scriptCount: null,
scriptCountLoading: false,
members: [],
loadedOwnerGroups: new Set<string>(),
loadingOwnerGroups: new Set<string>(),
readOnlyRefreshVersion: 0,
setApiOnline: (online) => set({ apiOnline: online }),
@@ -269,6 +310,9 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
loadingScriptPaths: new Set<string>(),
scriptCount: null,
scriptCountLoading: false,
members: [],
loadedOwnerGroups: new Set<string>(),
loadingOwnerGroups: new Set<string>(),
});
},
@@ -277,54 +321,86 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
if (!silent) set({ loading: true });
set({ refreshing: silent });
try {
// Re-fetch every path currently in the cache. On initial mount the
// cache is empty so this degrades to a single root fetch; on
// toolbar refresh / after createFolder / after deleteScript the
// previously-expanded folders are also re-fetched so the UI stays
// consistent (otherwise the cache would keep stale "loaded"
// markers while the corresponding scripts had been replaced by
// the root-only payload, leaving subfolders empty on re-expand).
const cachedScriptPaths = Array.from(get().loadedScriptPaths);
const cachedChildPaths = Array.from(get().loadedChildPaths).filter(
(p) => p !== "",
const me = _currentUserId;
// 拉取工作区成员列表 —— 顶层"我 / user1 / user2 / …"折叠分组的来源。
const memberList =
_workspaceId != null
? await api
.listWorkspaceMembers(_workspaceId)
.catch(() => [] as WorkspaceMember[])
: [];
// 只重新拉取"我"的已缓存脚本路径(含根)。首次挂载缓存为空 → 退化为
// 单次根级拉取。他人脚本不动(保留在 flat scripts 里,见下方合并)。
const myCachedScriptKeys = Array.from(get().loadedScriptPaths).filter(
(k) => k.startsWith(`${me}:`) || k.startsWith("me:"),
);
const rootScriptKey = ownerCacheKey(undefined, "");
const scriptFetches =
cachedScriptPaths.length > 0
? cachedScriptPaths.map((p) =>
api.listScripts(p).catch(() => [] as ScriptItem[]),
)
: [api.listScripts("")];
const dirFetches = [
api.listWorkspaceDirectories(""),
...cachedChildPaths.map((p) =>
api.listWorkspaceDirectories(p).catch(() => [] as WorkspaceDirectory[]),
),
];
myCachedScriptKeys.length > 0
? myCachedScriptKeys.map((k) => {
const p = k.slice(k.indexOf(":") + 1);
return api.listScripts(p).catch(() => [] as ScriptItem[]);
})
: [api.listScripts("").catch(() => [] as ScriptItem[])];
// 只重新拉取"我"的已缓存目录路径(含根)。他人目录不动(保留在
// flat directories 里,按 (owner,path) 去重合并)。
const myCachedDirKeys = Array.from(get().loadedChildPaths).filter(
(k) => (me != null && k.startsWith(`${me}:`)) || k.startsWith("me:"),
);
const rootDirKey = ownerCacheKey(undefined, "");
const dirFetches =
myCachedDirKeys.length > 0
? myCachedDirKeys.map((k) => {
const p = k.slice(k.indexOf(":") + 1);
return api
.listWorkspaceDirectories(p)
.catch(() => [] as WorkspaceDirectory[]);
})
: [api
.listWorkspaceDirectories("")
.catch(() => [] as WorkspaceDirectory[])];
const [scriptLists, dirLists] = await Promise.all([
Promise.all(scriptFetches),
Promise.all(dirFetches),
]);
const freshScripts = scriptLists.flat();
const freshDirs = dirLists.flat();
// Dedup: later occurrences win so fresh per-path payloads override
// any duplicates coming through different fetch slots.
const myFreshScripts = scriptLists.flat();
// "我"的脚本用 fresh 集合替换;他人脚本原样保留(按 script_id 去重合并)。
const otherScripts = get().scripts.filter(
(s) => s.owner_user_id !== me,
);
const dedupedScripts = Array.from(
new Map(freshScripts.map((s) => [s.script_id, s])).values(),
new Map(
[...otherScripts, ...myFreshScripts].map((s) => [s.script_id, s]),
).values(),
);
// "我"的目录用 fresh 集合替换;他人目录原样保留(按 (owner,path) 去重)。
const otherDirs = get().directories.filter(
(d) => d.owner_user_id !== me,
);
const dedupedDirs = Array.from(
new Map(freshDirs.map((d) => [d.path, d])).values(),
new Map(
[...otherDirs, ...dirLists.flat()].map((d) => [
`${d.owner_user_id}:${d.path}`,
d,
]),
).values(),
);
const nextLoadedScripts = new Set(cachedScriptPaths);
nextLoadedScripts.add("");
const nextLoadedScripts = new Set(myCachedScriptKeys);
nextLoadedScripts.add(rootScriptKey);
const nextLoadedChildren = new Set(get().loadedChildPaths);
nextLoadedChildren.add("");
nextLoadedChildren.add(rootDirKey);
set({
scripts: dedupedScripts,
directories: dedupedDirs,
members: memberList,
apiOnline: true,
loadedScriptPaths: nextLoadedScripts,
loadedChildPaths: nextLoadedChildren,
});
// 刷新已展开的其他成员分组(loadOwnerGroup 总是发起请求,刷新安全)。
for (const owner of get().loadedOwnerGroups) {
if (owner !== me) void get().loadOwnerGroup(owner);
}
const validIds = new Set(dedupedScripts.map((item) => item.script_id));
const currentSelected = get().selectedId;
if (!currentSelected || !validIds.has(currentSelected)) {
@@ -347,33 +423,37 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
}
},
loadScripts: async (parentPath) => {
loadScripts: async (parentPath, ownerUserId) => {
const api = requireApi();
if (get().loadedScriptPaths.has(parentPath)) return;
// Dedupe in-flight requests for the same path.
if (get().loadingScriptPaths.has(parentPath)) return;
const cacheKey = ownerCacheKey(ownerUserId, parentPath);
if (get().loadedScriptPaths.has(cacheKey)) return;
// Dedupe in-flight requests for the same owner×path.
if (get().loadingScriptPaths.has(cacheKey)) return;
const next = new Set(get().loadingScriptPaths);
next.add(parentPath);
next.add(cacheKey);
set({ loadingScriptPaths: next });
try {
const items = await api.listScripts(parentPath);
const items = await api.listScripts(parentPath, ownerUserId);
set((state) => {
const existingIds = new Set(state.scripts.map((s) => s.script_id));
const fresh = items.filter((s) => !existingIds.has(s.script_id));
// append-only 合并(按 script_id 去重,fresh 覆盖 stale 同 id 值)。
// 该 owner×path 的全量刷新由 load()(我)/ loadOwnerGroup(他人)
// 负责 drop-by-owner 后重并入;这里是子目录展开,append 即可。
const byId = new Map(state.scripts.map((s) => [s.script_id, s]));
for (const item of items) byId.set(item.script_id, item);
const nextLoaded = new Set(state.loadedScriptPaths);
nextLoaded.add(parentPath);
nextLoaded.add(cacheKey);
return {
scripts: [...state.scripts, ...fresh],
scripts: Array.from(byId.values()),
loadedScriptPaths: nextLoaded,
loadingScriptPaths: new Set(
[...state.loadingScriptPaths].filter((p) => p !== parentPath),
[...state.loadingScriptPaths].filter((p) => p !== cacheKey),
),
};
});
} catch (error) {
set((state) => ({
loadingScriptPaths: new Set(
[...state.loadingScriptPaths].filter((p) => p !== parentPath),
[...state.loadingScriptPaths].filter((p) => p !== cacheKey),
),
}));
pushToast(
@@ -383,14 +463,96 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
}
},
loadDataResources: async (parentPath = "") => {
loadOwnerGroup: async (ownerUserId) => {
const api = requireApi();
if (get().loadingOwnerGroups.has(ownerUserId)) return;
const nextLoading = new Set(get().loadingOwnerGroups);
nextLoading.add(ownerUserId);
set({ loadingOwnerGroups: nextLoading });
try {
const [scripts, resources, directories] = await Promise.all([
api.listScripts("", ownerUserId).catch(() => [] as ScriptItem[]),
api.listResources("", { ownerUserId }).catch(() => [] as ResourceItem[]),
api.listWorkspaceDirectories("", ownerUserId).catch(
() => [] as WorkspaceDirectory[],
),
]);
set((state) => {
// 丢弃该 owner 的旧脚本/资源/目录(按 owner 过滤后保留他人),再并入 fresh。
const keptScripts = state.scripts.filter(
(s) => s.owner_user_id !== ownerUserId,
);
const keptResources = state.dataResources.filter(
(r) => r.owner_user_id !== ownerUserId,
);
const keptDirs = state.directories.filter(
(d) => d.owner_user_id !== ownerUserId,
);
const scriptIds = new Set(keptScripts.map((s) => s.script_id));
const freshScripts = scripts.filter((s) => !scriptIds.has(s.script_id));
const resourceIds = new Set(keptResources.map((r) => r.resource_id));
const freshResources = resources.filter(
(r) => !resourceIds.has(r.resource_id),
);
const dirIds = new Set(
keptDirs.map((d) => `${d.owner_user_id}:${d.path}`),
);
const freshDirs = directories.filter(
(d) => !dirIds.has(`${d.owner_user_id}:${d.path}`),
);
const nextLoaded = new Set(state.loadedOwnerGroups);
nextLoaded.add(ownerUserId);
const nextScriptPaths = new Set(state.loadedScriptPaths);
nextScriptPaths.add(ownerCacheKey(ownerUserId, ""));
const nextChildPaths = new Set(state.loadedChildPaths);
nextChildPaths.add(ownerCacheKey(ownerUserId, ""));
return {
scripts: [...keptScripts, ...freshScripts],
dataResources: [...keptResources, ...freshResources],
directories: [...keptDirs, ...freshDirs],
loadedOwnerGroups: nextLoaded,
loadedScriptPaths: nextScriptPaths,
loadedChildPaths: nextChildPaths,
loadingOwnerGroups: new Set(
[...state.loadingOwnerGroups].filter((o) => o !== ownerUserId),
),
};
});
} catch {
set((state) => ({
loadingOwnerGroups: new Set(
[...state.loadingOwnerGroups].filter((o) => o !== ownerUserId),
),
}));
}
},
loadDataResources: async (parentPath = "", ownerUserId) => {
const api = requireApi();
set({ dataResourcesLoading: true });
try {
const list = await api.listResources(parentPath);
set({ dataResources: Array.isArray(list) ? list : [] });
const list = await api.listResources(parentPath, { ownerUserId });
const fresh = Array.isArray(list) ? list : [];
set((state) => {
// 按 owner 范围合并:丢弃该 owner 的旧资源再并入 freshfresh 覆盖
// 同 id)。owner 缺省=我,故根加载/刷新我的数据时替换我的一级资源。
const targetOwner = ownerUserId ?? _currentUserId ?? null;
const kept = state.dataResources.filter(
(r) => r.owner_user_id !== targetOwner,
);
const byId = new Map(kept.map((r) => [r.resource_id, r]));
for (const item of fresh) byId.set(item.resource_id, item);
return { dataResources: Array.from(byId.values()) };
});
} catch {
set({ dataResources: [] });
set((state) => {
const targetOwner = ownerUserId ?? _currentUserId ?? null;
return {
dataResources: state.dataResources.filter(
(r) => r.owner_user_id !== targetOwner,
),
};
});
} finally {
set({ dataResourcesLoading: false });
}
@@ -420,32 +582,40 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
}
},
loadChildren: async (parentPath) => {
loadChildren: async (parentPath, ownerUserId) => {
const api = requireApi();
if (get().loadedChildPaths.has(parentPath)) return;
const cacheKey = ownerCacheKey(ownerUserId, parentPath);
if (get().loadedChildPaths.has(cacheKey)) return;
const next = new Set(get().loadingChildrenPaths);
next.add(parentPath);
next.add(cacheKey);
set({ loadingChildrenPaths: next });
try {
const children = await api.listWorkspaceDirectories(parentPath);
const children = await api.listWorkspaceDirectories(parentPath, ownerUserId);
set((state) => {
const nextLoaded = new Set(state.loadedChildPaths);
nextLoaded.add(parentPath);
nextLoaded.add(cacheKey);
// 丢弃该 owner 该 parent 下的旧目录行,再并入 fresh(按 (owner,path) 去重)。
const targetOwner = ownerUserId ?? _currentUserId ?? null;
const trimmed = state.directories.filter(
(d) => d.parent_path !== parentPath,
(d) =>
!(d.owner_user_id === targetOwner && d.parent_path === parentPath),
);
const byId = new Map(
trimmed.map((d) => [`${d.owner_user_id}:${d.path}`, d]),
);
for (const c of children) byId.set(`${c.owner_user_id}:${c.path}`, c);
return {
directories: [...trimmed, ...children],
directories: Array.from(byId.values()),
loadedChildPaths: nextLoaded,
loadingChildrenPaths: new Set(
[...state.loadingChildrenPaths].filter((p) => p !== parentPath),
[...state.loadingChildrenPaths].filter((p) => p !== cacheKey),
),
};
});
} catch (error) {
set((state) => ({
loadingChildrenPaths: new Set(
[...state.loadingChildrenPaths].filter((p) => p !== parentPath),
[...state.loadingChildrenPaths].filter((p) => p !== cacheKey),
),
}));
pushToast(
@@ -455,7 +625,7 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
}
},
toggleExpanded: async (path, loadPath) => {
toggleExpanded: async (path, loadPath, ownerUserId) => {
const state = get();
const isOpen = state.expandedPaths.has(path);
const next = new Set(state.expandedPaths);
@@ -463,16 +633,30 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
next.delete(path);
} else {
next.add(path);
// group key (`__group__<owner_id>`) 不是 workspace 路径,不调 loadChildren / loadScripts
const actualLoadPath = loadPath ?? path;
if (!actualLoadPath.startsWith("__group__")) {
// Load both sub-directories and scripts directly under this folder
// in parallel. Both are idempotent + cached; cheap when already loaded.
if (!state.loadedChildPaths.has(actualLoadPath)) {
void get().loadChildren(actualLoadPath);
const me = _currentUserId;
// 用 `loadPath === undefined` 区分分组头与真实目录,而不是用
// `path.startsWith("__group__")`:目录的 expandKey 是
// `${groupKey}/${dir.path}` 即 `__group__<owner>/dir`,同样以
// `__group__` 开头,前缀判断会把子目录点击误当成分组头,导致
// 既不调 loadChildren 也不调 loadScripts"子目录点击不触发接口")。
// 分组头 always 传 loadPath=undefined;目录 always 传 loadPath=dir.path。
if (loadPath === undefined) {
// 分组头:他人分组首次展开 → loadOwnerGroup 按需拉取其根级可见
// 脚本+数据+目录(守门去重)。仅翻转 expand;真实子目录的懒加载
// 由目录分支(loadPath !== undefined)负责。
if (ownerUserId && ownerUserId !== me && !state.loadedOwnerGroups.has(ownerUserId)) {
void get().loadOwnerGroup(ownerUserId);
}
if (!state.loadedScriptPaths.has(actualLoadPath)) {
void get().loadScripts(actualLoadPath);
} else {
// 真实目录展开:loadChildrenowner 限定的显式目录行)+ loadScripts
// 并行。两者都 idempotent + 缓存;ownerUserId 缺省=我。他人目录同样
// 调 loadChildren(owner) 拉取其目录结构,否则嵌套子目录无法被发现
// list_scripts 非递归,只能看到直接子脚本)。
if (!state.loadedChildPaths.has(ownerCacheKey(ownerUserId, loadPath))) {
void get().loadChildren(loadPath, ownerUserId);
}
if (!state.loadedScriptPaths.has(ownerCacheKey(ownerUserId, loadPath))) {
void get().loadScripts(loadPath, ownerUserId);
}
}
}
@@ -1028,16 +1212,25 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
if (parentPath === "") {
await get().load(true);
} else {
const me = _currentUserId ?? "me";
const parentKey = ownerCacheKey(me, parentPath);
set((state) => {
const nextLoaded = new Set(state.loadedChildPaths);
for (const p of state.loadedChildPaths) {
if (p.startsWith(`${parentPath}/`)) nextLoaded.delete(p);
// 仅失效"我"在该 parent 之下的缓存(namespaced key)。
if (p.startsWith(`${me}:`) && p.slice(me.length + 1).startsWith(`${parentPath}/`)) {
nextLoaded.delete(p);
}
}
nextLoaded.delete(parentPath);
nextLoaded.delete(parentKey);
return {
loadedChildPaths: nextLoaded,
directories: state.directories.filter(
(d) => !d.parent_path.startsWith(`${parentPath}/`),
(d) =>
!(
d.owner_user_id === me
&& d.parent_path.startsWith(`${parentPath}/`)
),
),
expandedPaths: new Set(state.expandedPaths),
};
@@ -1124,20 +1317,30 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
) {
get().selectScript(null);
}
const me = _currentUserId ?? "me";
const pathKey = ownerCacheKey(me, path);
set((state) => {
const nextLoaded = new Set(state.loadedChildPaths);
const nextExpanded = new Set(state.expandedPaths);
for (const p of state.loadedChildPaths) {
if (p === path || p.startsWith(`${path}/`)) nextLoaded.delete(p);
// 仅失效"我"该 path 及其子目录的缓存(namespaced key)。
if (!p.startsWith(`${me}:`)) continue;
const bare = p.slice(me.length + 1);
if (bare === path || bare.startsWith(`${path}/`)) nextLoaded.delete(p);
}
for (const p of state.expandedPaths) {
if (p === path || p.startsWith(`${path}/`)) nextExpanded.delete(p);
}
void pathKey;
return {
loadedChildPaths: nextLoaded,
expandedPaths: nextExpanded,
directories: state.directories.filter(
(d) => d.parent_path !== path && !d.parent_path.startsWith(`${path}/`),
(d) =>
!(
d.owner_user_id === me
&& (d.parent_path === path || d.parent_path.startsWith(`${path}/`))
),
),
};
});
+9
View File
@@ -9,6 +9,8 @@ import { useApi, useAuth } from "../context/AuthContext";
import { useEditSessionLifecycle } from "../features/platform/hooks/useEditSessionLifecycle";
import {
bindScriptWorkspaceApi,
bindScriptWorkspaceId,
bindScriptWorkspaceUser,
editSessionHandle,
useScriptWorkspaceStore,
} from "../features/platform/state/scriptWorkspaceStore";
@@ -88,11 +90,18 @@ function AuthenticatedLayout() {
bindScriptWorkspaceApi(api);
bindSchedulesApi(api);
bindAdminApi(api);
// 当前用户 id + 工作区 id 同步绑定到 script workspace storerender 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);
};
}, []);
+35 -15
View File
@@ -191,6 +191,7 @@ export type WorkspaceDirectory = {
path: string;
name: string;
parent_path: string;
owner_user_id: string;
has_children?: boolean;
};
@@ -287,13 +288,21 @@ async function apiRequest<T>(
export async function listScripts(
workspaceId: string,
parentPath: string = "",
ownerUserId?: string,
): Promise<ScriptItem[]> {
// Empty parentPath omits the query string entirely so the backend's
// root-level filter is applied symmetrically with non-empty paths.
const query = parentPath
? `?parent_path=${encodeURIComponent(parentPath)}`
: "";
return apiRequest<ScriptItem[]>(`/api/v1/scripts${query}`, {}, workspaceId);
// Default (no ownerUserId) scopes to the requester's own subtree; passing
// ownerUserId scopes to that owner's subtree (workspace/public only — the
// backend excludes their private) so the tree can lazily fetch another
// member's content when their group is expanded.
const parameters = new URLSearchParams();
if (parentPath) parameters.set("parent_path", parentPath);
if (ownerUserId) parameters.set("owner_user_id", ownerUserId);
const query = parameters.toString();
return apiRequest<ScriptItem[]>(
`/api/v1/scripts${query ? `?${query}` : ""}`,
{},
workspaceId,
);
}
export async function countScripts(
@@ -439,15 +448,16 @@ export type ResourceItem = {
export async function listResources(
workspaceId: string,
parentPath: string = "",
opts?: { visibility?: string; keyword?: string },
opts?: { visibility?: string; keyword?: string; ownerUserId?: string },
): Promise<ResourceItem[]> {
// Empty parentPath omits the query string entirely so the backend's
// workspace-wide (root-level) filter is applied symmetrically with
// non-empty paths, matching listScripts.
// Default (no ownerUserId) scopes to the requester's own object_key
// subtree; passing ownerUserId scopes to that owner's subtree so the tree
// can lazily fetch another member's data resources on group expand.
const parameters = new URLSearchParams();
if (parentPath) parameters.set("parent_path", parentPath);
if (opts?.visibility) parameters.set("visibility", opts.visibility);
if (opts?.keyword) parameters.set("keyword", opts.keyword);
if (opts?.ownerUserId) parameters.set("owner_user_id", opts.ownerUserId);
const query = parameters.toString();
// apiRequest<T> already unwraps the envelope's `data` field, so we
// request `ResourceItem[]` directly here (matching listScripts).
@@ -613,12 +623,18 @@ export async function deleteScript(
export async function listWorkspaceDirectories(
workspaceId: string,
parentPath: string = "",
ownerUserId?: string,
): Promise<WorkspaceDirectory[]> {
const query = parentPath
? `?parent_path=${encodeURIComponent(parentPath)}`
: "";
// Default (no ownerUserId) scopes to the requester's own subtree; passing
// ownerUserId scopes to that owner so the tree can lazily render their
// directory structure on expand. Directories are structural rows; file
// visibility is still enforced by the scripts/data-resources endpoints.
const parameters = new URLSearchParams();
if (parentPath) parameters.set("parent_path", parentPath);
if (ownerUserId) parameters.set("owner_user_id", ownerUserId);
const query = parameters.toString();
const data = await apiRequest<{ directories: WorkspaceDirectory[] }>(
`/api/v1/workspace-directories${query}`,
`/api/v1/workspace-directories${query ? `?${query}` : ""}`,
{},
workspaceId,
);
@@ -1487,6 +1503,7 @@ export async function getScheduleNodeRunArtifacts(
export type WorkspaceBoundApi = {
listScripts: (
parentPath?: Parameters<typeof listScripts>[1],
ownerUserId?: Parameters<typeof listScripts>[2],
) => Promise<ScriptItem[]>;
countScripts: () => Promise<number>;
listResources: (
@@ -1527,7 +1544,10 @@ export type WorkspaceBoundApi = {
scriptId: string,
isLocked: boolean,
) => Promise<ScriptItem>;
listWorkspaceDirectories: (parentPath?: string) => Promise<WorkspaceDirectory[]>;
listWorkspaceDirectories: (
parentPath?: Parameters<typeof listWorkspaceDirectories>[1],
ownerUserId?: Parameters<typeof listWorkspaceDirectories>[2],
) => Promise<WorkspaceDirectory[]>;
createWorkspaceDirectory: (
directoryName: string,
parentPath?: string,