feat(scripts/data-resources): merge tree + add parent_path filter

- Backend: GET /api/v1/data-resources accepts parent_path; LIKE
  '{ws_id}/%/{escaped}/%' AND NOT LIKE '{ws_id}/%/{escaped}/%/%' on
  StorageObjects.object_key (workspace-wide, escapes _ and %, mirrors
  list_scripts parent_path semantics). 13 new tests in
  test_resources.py (helper unit / SQL compile / SQLite behavioral).
- Frontend: listResources gains parentPath arg, propagated through
  WorkspaceBoundApi + AuthContext binding. WorkspaceTreeGroup title
  count and ScriptExplorer header count now include dataResources.
  memberScriptGroups backfills data-only owners so users with only
  data resources still render a group. loadDataResources accepts an
  optional parentPath, default empty preserves prior behavior.
This commit is contained in:
tao.chen
2026-08-21 12:36:43 +08:00
parent a9b682c1a1
commit 48fe49df3b
7 changed files with 296 additions and 9 deletions
@@ -94,6 +94,14 @@ export function ScriptExplorer({
byOwner.set(item.owner_user_id, list);
}
// data-only owner(只有数据资源、没有 scripts 的用户)也要出现在分组里,
// 因为 data resources 与 scripts 共享同一棵目录树。
for (const ownerUserId of dataByOwner.keys()) {
if (!byOwner.has(ownerUserId)) {
byOwner.set(ownerUserId, []);
}
}
const groups: {
user: AuthUser | null;
scripts: ScriptItem[];
@@ -143,7 +151,7 @@ export function ScriptExplorer({
<div className="explorer__header">
<div>
<h2></h2>
<span>{scripts.length} </span>
<span>{scripts.length + dataResources.length} </span>
</div>
<div className="explorer__actions">
<button
+2 -1
View File
@@ -223,7 +223,8 @@ export function useApi(): WorkspaceBoundApi {
return useMemo<WorkspaceBoundApi>(() => ({
listScripts: (parentPath) => rawApi.listScripts(workspaceId, parentPath),
countScripts: () => rawApi.countScripts(workspaceId),
listResources: (opts) => rawApi.listResources(workspaceId, opts),
listResources: (parentPath, opts) =>
rawApi.listResources(workspaceId, parentPath, opts),
createScript: (input) => rawApi.createScript(workspaceId, input),
uploadScript: (file, parentPath, visibility) =>
rawApi.uploadScript(workspaceId, file, parentPath, visibility),
@@ -150,7 +150,7 @@ export function WorkspaceTreeGroup({
<Icon name="chevron" size={14} />
<Icon name="folder" size={17} />
<span>{title}</span>
<em>{scripts.length}</em>
<em>{scripts.length + (dataResources ?? []).length}</em>
</button>
{open && (
<div className="tree-group__items">
@@ -106,7 +106,7 @@ type State = {
setKeyword: (keyword: string) => void;
reset: () => void;
load: (silent?: boolean) => Promise<void>;
loadDataResources: () => Promise<void>;
loadDataResources: (parentPath?: string) => Promise<void>;
selectScript: (id: string | null) => void;
openTab: (id: string) => void;
closeTab: (id: string, event?: { stopPropagation: () => void }) => Promise<void>;
@@ -383,11 +383,11 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
}
},
loadDataResources: async () => {
loadDataResources: async (parentPath = "") => {
const api = requireApi();
set({ dataResourcesLoading: true });
try {
const list = await api.listResources();
const list = await api.listResources(parentPath);
set({ dataResources: Array.isArray(list) ? list : [] });
} catch {
set({ dataResources: [] });
+7 -1
View File
@@ -437,9 +437,14 @@ export type ResourceItem = {
export async function listResources(
workspaceId: string,
parentPath: string = "",
opts?: { visibility?: string; keyword?: 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.
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);
const query = parameters.toString();
@@ -1484,7 +1489,8 @@ export type WorkspaceBoundApi = {
) => Promise<ScriptItem[]>;
countScripts: () => Promise<number>;
listResources: (
opts?: Parameters<typeof listResources>[1],
parentPath?: Parameters<typeof listResources>[1],
opts?: Parameters<typeof listResources>[2],
) => Promise<ResourceItem[]>;
createScript: (
input: Parameters<typeof createScript>[1],