refactor(frontend): consolidate features & components into single tree
把 components/admin/* 和 components/platform/* 共 11 个文件
移到对应 features/<name>/ 下,components/ 只保留跨特性共享的
common/ 子目录(features/admin/* 之前和 components/admin/* 同名
共存,迫使 features/admin/AdminPages.tsx 出现 barrel,顺带消除了
barrel 副作用 CSS 丢失的隐患)。
新的目录约定:
- features/<name>/ = 特性模块,所有页面 / Modal / state / hooks
/ routes 都在内,无 components/<name>/ 并列
- components/common/ = 仅放 ≥2 个特性共用的 widgets
(Icon、Sidebar、Toast、Topbar、WelcomePanel)
改动:
- 11 文件 git mv (components/{admin,platform}/* → features/*)
- 8 文件的 Icon 相对路径 '../common/Icon' → '../../components/common/Icon'
- 2 文件补 admin.css side-effect import(UserManagementPage /
ProjectManagementPage 之前依赖 AdminPages barrel)
- 3 文件(引用方)更新 import:SystemAdminPage、DashboardRoute、
ScriptsPage
- 删除 features/admin/AdminPages.tsx barrel(无消费者)
- 删除空目录 components/admin、components/platform
验证:
- pnpm typecheck 通过
- pnpm build 通过,CSS 体积 35.11 kB 与重构前一致(无样式增减)
- routes.ts / routes/platform.tsx 路径未动,route id 不变
踩坑:
第一轮把 '../common/Icon' 错改成 '../../../components/common/Icon',
typecheck 报 11 个 Cannot find module。原因:features/admin/ 和
components/admin/ 都是 depth 2,'../../X' 在两边都解析到 app/X,
只有 '../X' 才需要多一层。正确改法是 '../../components/common/Icon'。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
e9d9bcfc97
commit
c3947d6066
@@ -0,0 +1,80 @@
|
||||
import { type FormEvent } from "react";
|
||||
import Icon from "../../components/common/Icon";
|
||||
|
||||
type CreateFolderModalProps = {
|
||||
open: boolean;
|
||||
parentPath: string;
|
||||
name: string;
|
||||
busy: boolean;
|
||||
onNameChange: (name: string) => void;
|
||||
onSubmit: (event: FormEvent) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function CreateFolderModal({
|
||||
open,
|
||||
parentPath,
|
||||
name,
|
||||
busy,
|
||||
onNameChange,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: CreateFolderModalProps) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal modal--compact" role="dialog" aria-modal="true">
|
||||
<div className="modal__header">
|
||||
<div>
|
||||
<span className="modal__eyebrow">WORKSPACE</span>
|
||||
<h2>新建文件夹</h2>
|
||||
</div>
|
||||
<button
|
||||
className="icon-button"
|
||||
type="button"
|
||||
aria-label="关闭"
|
||||
onClick={onClose}
|
||||
>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="destination-chip">
|
||||
<Icon name="folder" size={16} />
|
||||
创建到:{parentPath || "个人根目录"}
|
||||
</div>
|
||||
<label className="form-field">
|
||||
<span>文件夹名称</span>
|
||||
<input
|
||||
autoFocus
|
||||
maxLength={255}
|
||||
placeholder="例如:模型训练"
|
||||
value={name}
|
||||
onChange={(event) => onNameChange(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="modal__footer">
|
||||
<button
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="primary-button"
|
||||
type="submit"
|
||||
disabled={busy || !name.trim()}
|
||||
>
|
||||
{busy
|
||||
? <span className="button-spinner" />
|
||||
: <Icon name="folder" size={16} />}
|
||||
{busy ? "正在创建…" : "创建文件夹"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { type FormEvent } from "react";
|
||||
import Icon from "../../components/common/Icon";
|
||||
import type { ScriptType, Visibility } from "../../services/api";
|
||||
|
||||
type NewScriptForm = {
|
||||
name: string;
|
||||
scriptType: ScriptType;
|
||||
visibility: Visibility;
|
||||
parentPath: string;
|
||||
};
|
||||
|
||||
type CreateScriptModalProps = {
|
||||
open: boolean;
|
||||
creating: boolean;
|
||||
form: NewScriptForm;
|
||||
scripts: Array<{
|
||||
script_type: ScriptType;
|
||||
script_name: string;
|
||||
relative_path?: string;
|
||||
}>;
|
||||
onFormChange: (form: NewScriptForm) => void;
|
||||
onSubmit: (event: FormEvent) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function CreateScriptModal({
|
||||
open,
|
||||
creating,
|
||||
form,
|
||||
scripts,
|
||||
onFormChange,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: CreateScriptModalProps) {
|
||||
if (!open) return null;
|
||||
|
||||
const suffix = form.scriptType === "notebook" ? ".ipynb" : ".py";
|
||||
const requestedName = form.name.trim();
|
||||
const normalizedName = requestedName.toLocaleLowerCase().endsWith(suffix)
|
||||
? requestedName
|
||||
: `${requestedName}${suffix}`;
|
||||
const duplicate = scripts.some((script) => {
|
||||
if (script.script_type !== form.scriptType) return false;
|
||||
if (script.script_name.toLocaleLowerCase()
|
||||
!== normalizedName.toLocaleLowerCase()) return false;
|
||||
// Same name in a different subdirectory is allowed; mirror the
|
||||
// backend name_clash scope (StorageObjects.relative_path JOIN).
|
||||
if (!script.relative_path) return true;
|
||||
const segments = script.relative_path.replaceAll("\\", "/")
|
||||
.split("/").slice(2);
|
||||
const existingUserPath = segments.join("/");
|
||||
const existingParent = existingUserPath.includes("/")
|
||||
? existingUserPath.slice(0, existingUserPath.lastIndexOf("/"))
|
||||
: "";
|
||||
return existingParent === form.parentPath;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal" role="dialog" aria-modal="true">
|
||||
<div className="modal__header">
|
||||
<div>
|
||||
<span className="modal__eyebrow">工作副本</span>
|
||||
<h2>新建构建脚本</h2>
|
||||
</div>
|
||||
<button
|
||||
className="icon-button"
|
||||
type="button"
|
||||
aria-label="关闭"
|
||||
onClick={onClose}
|
||||
>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="destination-chip">
|
||||
<Icon name="folder" size={16} />
|
||||
保存到:{form.parentPath || "个人根目录"}
|
||||
</div>
|
||||
<label className="form-field">
|
||||
<span>脚本名称</span>
|
||||
<input
|
||||
autoFocus
|
||||
maxLength={255}
|
||||
placeholder={form.scriptType === "notebook"
|
||||
? "例如:数据探索"
|
||||
: "例如:data_process"}
|
||||
value={form.name}
|
||||
onChange={(event) =>
|
||||
onFormChange({
|
||||
...form,
|
||||
name: event.target.value,
|
||||
})}
|
||||
/>
|
||||
<small>
|
||||
系统会自动补充
|
||||
{form.scriptType === "notebook" ? " .ipynb" : " .py"} 后缀
|
||||
</small>
|
||||
</label>
|
||||
|
||||
<fieldset className="type-picker">
|
||||
<legend>脚本类型</legend>
|
||||
<button
|
||||
className={form.scriptType === "notebook" ? "is-selected" : ""}
|
||||
type="button"
|
||||
onClick={() => onFormChange({
|
||||
...form,
|
||||
scriptType: "notebook",
|
||||
})}
|
||||
>
|
||||
<span className="type-picker__icon type-picker__icon--notebook">
|
||||
<Icon name="notebook" size={22} />
|
||||
</span>
|
||||
<span>
|
||||
<strong>Jupyter Notebook</strong>
|
||||
<small>交互式数据探索与模型实验</small>
|
||||
</span>
|
||||
<span className="type-picker__check">
|
||||
<Icon name="check" size={14} />
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className={form.scriptType === "python" ? "is-selected" : ""}
|
||||
type="button"
|
||||
onClick={() => onFormChange({
|
||||
...form,
|
||||
scriptType: "python",
|
||||
})}
|
||||
>
|
||||
<span className="type-picker__icon type-picker__icon--python">
|
||||
<Icon name="python" size={23} />
|
||||
</span>
|
||||
<span>
|
||||
<strong>Python 脚本</strong>
|
||||
<small>批处理、训练和模型调用任务</small>
|
||||
</span>
|
||||
<span className="type-picker__check">
|
||||
<Icon name="check" size={14} />
|
||||
</span>
|
||||
</button>
|
||||
</fieldset>
|
||||
|
||||
<label className="form-field">
|
||||
<span>可见范围</span>
|
||||
<select
|
||||
value={form.visibility}
|
||||
onChange={(event) =>
|
||||
onFormChange({
|
||||
...form,
|
||||
visibility: event.target.value as Visibility,
|
||||
})}
|
||||
>
|
||||
<option value="private">仅自己可见</option>
|
||||
<option value="workspace">Workspace 成员可见</option>
|
||||
<option value="public">公开</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="modal__footer">
|
||||
<button
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="primary-button"
|
||||
type="submit"
|
||||
disabled={creating || !form.name.trim()}
|
||||
>
|
||||
{creating ? <span className="button-spinner" /> : <Icon name="plus" size={16} />}
|
||||
{creating ? "正在创建…" : "创建脚本"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useEffect } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
import { useAuth } from "~/context/AuthContext";
|
||||
import { DashboardPage } from "../../components/admin/DashboardPage";
|
||||
import { DashboardPage } from "../admin/DashboardPage";
|
||||
import { useScriptWorkspaceStore } from "./state/scriptWorkspaceStore";
|
||||
|
||||
export default function DashboardRoute() {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { type FormEvent } from "react";
|
||||
import Icon from "../../components/common/Icon";
|
||||
import type { Visibility } from "../../services/api";
|
||||
|
||||
type DataResourceUploadModalProps = {
|
||||
open: boolean;
|
||||
file: File | null;
|
||||
resourceName: string;
|
||||
visibility: Visibility;
|
||||
description: string;
|
||||
uploading: boolean;
|
||||
parentPath: string;
|
||||
targetPath: string;
|
||||
onNameChange: (name: string) => void;
|
||||
onVisibilityChange: (visibility: Visibility) => void;
|
||||
onDescriptionChange: (description: string) => void;
|
||||
onTargetPathChange: (path: string) => void;
|
||||
onSubmit: (event: FormEvent) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function DataResourceUploadModal({
|
||||
open,
|
||||
file,
|
||||
resourceName,
|
||||
visibility,
|
||||
description,
|
||||
uploading,
|
||||
parentPath,
|
||||
targetPath,
|
||||
onNameChange,
|
||||
onVisibilityChange,
|
||||
onDescriptionChange,
|
||||
onTargetPathChange,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: DataResourceUploadModalProps) {
|
||||
if (!open) return null;
|
||||
|
||||
const finalTarget = targetPath.trim();
|
||||
const finalPath = finalTarget
|
||||
? `${finalTarget}/${file?.name ?? ""}`
|
||||
: file?.name ?? "";
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal modal--compact" role="dialog" aria-modal="true">
|
||||
<div className="modal__header">
|
||||
<div>
|
||||
<span className="modal__eyebrow">数据资源</span>
|
||||
<h2>上传数据文件</h2>
|
||||
</div>
|
||||
<button
|
||||
className="icon-button"
|
||||
type="button"
|
||||
aria-label="关闭"
|
||||
onClick={onClose}
|
||||
>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={onSubmit}>
|
||||
<label className="form-field">
|
||||
<span>已选文件</span>
|
||||
<div>{file ? file.name : "未选择文件"}</div>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
<span>资源名称</span>
|
||||
<input
|
||||
autoFocus
|
||||
maxLength={255}
|
||||
placeholder="例如:训练数据"
|
||||
value={resourceName}
|
||||
onChange={(event) => onNameChange(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
<span>子目录</span>
|
||||
<input
|
||||
type="text"
|
||||
maxLength={1024}
|
||||
placeholder="例如:train/v1(留空上传到当前目录)"
|
||||
value={targetPath}
|
||||
onChange={(event) => onTargetPathChange(event.target.value)}
|
||||
/>
|
||||
<small className="form-field__hint">
|
||||
{parentPath
|
||||
? `当前位于 ${parentPath || "根目录"} · 最终路径:${finalPath || "—"}`
|
||||
: `最终路径:${finalPath || "—"}`}
|
||||
</small>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
<span>可见性</span>
|
||||
<select
|
||||
value={visibility}
|
||||
onChange={(event) =>
|
||||
onVisibilityChange(event.target.value as Visibility)
|
||||
}
|
||||
>
|
||||
<option value="private">私有</option>
|
||||
<option value="workspace">工作区</option>
|
||||
<option value="public">公开</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
<span>描述</span>
|
||||
<textarea
|
||||
rows={3}
|
||||
placeholder="可选描述"
|
||||
value={description}
|
||||
onChange={(event) => onDescriptionChange(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="modal__footer">
|
||||
<button
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={uploading}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="primary-button"
|
||||
type="submit"
|
||||
disabled={uploading || !resourceName.trim()}
|
||||
>
|
||||
{uploading ? (
|
||||
<span className="button-spinner" />
|
||||
) : (
|
||||
<Icon name="upload" size={16} />
|
||||
)}
|
||||
{uploading ? "上传中…" : "上传"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { type FormEvent } from "react";
|
||||
import Icon from "../../components/common/Icon";
|
||||
import type { ScriptItem, Visibility } from "../../services/api";
|
||||
import { scriptIcon } from "../../features/platform/WorkspaceTree";
|
||||
|
||||
type PublishModalProps = {
|
||||
publishTarget: ScriptItem | null;
|
||||
releaseNote: string;
|
||||
publishVisibility: Visibility;
|
||||
publishing: boolean;
|
||||
onReleaseNoteChange: (note: string) => void;
|
||||
onPublishVisibilityChange: (visibility: Visibility) => void;
|
||||
onSubmit: (event: FormEvent) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function PublishModal({
|
||||
publishTarget,
|
||||
releaseNote,
|
||||
publishVisibility,
|
||||
publishing,
|
||||
onReleaseNoteChange,
|
||||
onPublishVisibilityChange,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: PublishModalProps) {
|
||||
if (!publishTarget) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal publish-modal" role="dialog" aria-modal="true">
|
||||
<div className="modal__header">
|
||||
<div>
|
||||
<span className="modal__eyebrow">不可变制品</span>
|
||||
<h2>发布稳定版本</h2>
|
||||
</div>
|
||||
<button
|
||||
className="icon-button"
|
||||
type="button"
|
||||
aria-label="关闭"
|
||||
onClick={onClose}
|
||||
>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="publish-source">
|
||||
<span className={`file-icon file-icon--${publishTarget.script_type}`}>
|
||||
<Icon name={scriptIcon(publishTarget)} size={18} />
|
||||
</span>
|
||||
<span>
|
||||
<strong>{publishTarget.script_name}</strong>
|
||||
<small>当前 Workspace 工作副本</small>
|
||||
</span>
|
||||
</div>
|
||||
<label className="form-field">
|
||||
<span>发布说明</span>
|
||||
<textarea
|
||||
maxLength={1000}
|
||||
placeholder="例如:完成数据清洗和特征工程"
|
||||
value={releaseNote}
|
||||
onChange={(event) => onReleaseNoteChange(event.target.value)}
|
||||
/>
|
||||
<small>稳定版本内容不可修改,可作为后续调度节点输入。</small>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
<span>可见范围</span>
|
||||
<select
|
||||
value={publishVisibility}
|
||||
onChange={(event) =>
|
||||
onPublishVisibilityChange(event.target.value as Visibility)}
|
||||
>
|
||||
<option value="private">仅自己可见</option>
|
||||
<option value="workspace">Workspace 成员可见</option>
|
||||
<option value="public">公开</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="modal__footer">
|
||||
<button
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="primary-button"
|
||||
type="submit"
|
||||
disabled={publishing}
|
||||
>
|
||||
{publishing
|
||||
? <span className="button-spinner" />
|
||||
: <Icon name="release" size={16} />}
|
||||
{publishing ? "正在发布…" : "确认发布"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import { type ChangeEvent, type MouseEvent as ReactMouseEvent, type RefObject, useMemo } from "react";
|
||||
import Icon from "../../components/common/Icon";
|
||||
import { WorkspaceTreeGroup } from "~/features/platform/WorkspaceTree";
|
||||
import { useScriptWorkspaceStore } from "~/features/platform/state/scriptWorkspaceStore";
|
||||
import type { ScriptItem, WorkspaceDirectory } from "~/services/api";
|
||||
import { type AuthUser } from "~/context/AuthContext";
|
||||
import type { ResourceItem } from "~/services/api";
|
||||
|
||||
type ScriptExplorerProps = {
|
||||
scripts: ScriptItem[];
|
||||
filteredScripts: ScriptItem[];
|
||||
directories: WorkspaceDirectory[];
|
||||
dataResources: ResourceItem[];
|
||||
user: AuthUser | null;
|
||||
selectedId: string | null;
|
||||
loading: boolean;
|
||||
refreshing: boolean;
|
||||
uploading: boolean;
|
||||
keyword: string;
|
||||
onKeywordChange: (keyword: string) => void;
|
||||
onRefresh: () => void;
|
||||
onUpload: () => void;
|
||||
onOpenCreateDialog: (parentPath?: string, scriptType?: "notebook" | "python") => void;
|
||||
onOpenFolderDialog: (parentPath?: string) => void;
|
||||
onChooseUpload: (parentPath?: string) => void;
|
||||
onContextMenu: (
|
||||
event: ReactMouseEvent,
|
||||
target: { kind: "root" | "directory" | "file"; path: string; script?: ScriptItem }
|
||||
) => void;
|
||||
onSelect: (scriptId: string) => void;
|
||||
onCopyResourcePath: (jupyterPath: string) => void;
|
||||
uploadInputRef: RefObject<HTMLInputElement | null>;
|
||||
onHandleUpload: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
};
|
||||
|
||||
export function ScriptExplorer({
|
||||
scripts,
|
||||
filteredScripts,
|
||||
directories,
|
||||
dataResources,
|
||||
user,
|
||||
selectedId,
|
||||
loading,
|
||||
refreshing,
|
||||
uploading,
|
||||
keyword,
|
||||
onKeywordChange,
|
||||
onRefresh,
|
||||
onUpload,
|
||||
onOpenCreateDialog,
|
||||
onOpenFolderDialog,
|
||||
onChooseUpload,
|
||||
onContextMenu,
|
||||
onSelect,
|
||||
onCopyResourcePath,
|
||||
uploadInputRef,
|
||||
onHandleUpload,
|
||||
}: ScriptExplorerProps) {
|
||||
const expandedPaths = useScriptWorkspaceStore((s) => s.expandedPaths);
|
||||
const loadingChildrenPaths = useScriptWorkspaceStore(
|
||||
(s) => s.loadingChildrenPaths,
|
||||
);
|
||||
const members = useScriptWorkspaceStore((s) => s.members);
|
||||
const onToggle = useScriptWorkspaceStore((s) => s.toggleExpanded);
|
||||
|
||||
const dataByOwner = useMemo(() => {
|
||||
const map = new Map<string, ResourceItem[]>();
|
||||
for (const r of dataResources) {
|
||||
const list = map.get(r.owner_user_id) ?? [];
|
||||
list.push(r);
|
||||
map.set(r.owner_user_id, list);
|
||||
}
|
||||
return map;
|
||||
}, [dataResources]);
|
||||
|
||||
const memberScriptGroups = useMemo(() => {
|
||||
const visibleScripts =
|
||||
user?.is_system_admin === true
|
||||
? filteredScripts
|
||||
: filteredScripts.filter(
|
||||
(item) =>
|
||||
item.owner_user_id === user?.user_id ||
|
||||
item.visibility === "workspace" ||
|
||||
item.visibility === "public",
|
||||
);
|
||||
|
||||
// 用工作区成员列表播种分组 —— 顶层"我 / user1 / user2 / …"折叠分组
|
||||
// 的来源。即使某成员尚未加载任何脚本/数据(默认折叠、点击才拉取),
|
||||
// 也作为空分组出现,保证目录树结构稳定可见(修"目录树结构消失")。
|
||||
const byOwner = new Map<string, ScriptItem[]>();
|
||||
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) {
|
||||
const list = byOwner.get(item.owner_user_id) ?? [];
|
||||
list.push(item);
|
||||
byOwner.set(item.owner_user_id, list);
|
||||
}
|
||||
// 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[];
|
||||
directories: WorkspaceDirectory[];
|
||||
dataResources: ResourceItem[];
|
||||
}[] = [];
|
||||
for (const [ownerUserId, groupScripts] of byOwner.entries()) {
|
||||
const groupDataResources = dataByOwner.get(ownerUserId) ?? [];
|
||||
const displayName =
|
||||
memberName.get(ownerUserId) ??
|
||||
groupScripts[0]?.owner_display_name ??
|
||||
groupDataResources[0]?.owner_display_name ??
|
||||
(ownerUserId === user?.user_id ? user?.display_name : null) ??
|
||||
`${ownerUserId.slice(-6)}…`;
|
||||
const groupUser =
|
||||
ownerUserId === user?.user_id
|
||||
? user
|
||||
: ({
|
||||
user_id: ownerUserId,
|
||||
username: ownerUserId,
|
||||
display_name: displayName,
|
||||
email: null,
|
||||
status: "unknown",
|
||||
role_code: null,
|
||||
is_system_admin: false,
|
||||
} as AuthUser);
|
||||
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: mergeDirectories(ownerDirs, inferred),
|
||||
dataResources: groupDataResources,
|
||||
});
|
||||
}
|
||||
|
||||
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?.display_name ?? a.user?.user_id ?? "").localeCompare(
|
||||
b.user?.display_name ?? b.user?.user_id ?? "",
|
||||
);
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [filteredScripts, directories, user, dataByOwner, members]);
|
||||
|
||||
return (
|
||||
<aside className="explorer">
|
||||
<div className="explorer__header">
|
||||
<div>
|
||||
<h2>脚本目录</h2>
|
||||
</div>
|
||||
<div className="explorer__actions">
|
||||
<button
|
||||
className="text-button"
|
||||
type="button"
|
||||
disabled={uploading}
|
||||
onClick={onUpload}
|
||||
>
|
||||
<Icon name="upload" size={15} />
|
||||
{uploading ? "上传中…" : "上传"}
|
||||
</button>
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
className="visually-hidden"
|
||||
type="file"
|
||||
accept=".py,.ipynb,.csv,.xlsx,.xls,.tsv,.json,.parquet,.txt"
|
||||
multiple
|
||||
onChange={onHandleUpload}
|
||||
/>
|
||||
<button
|
||||
className="text-button"
|
||||
type="button"
|
||||
onClick={() => onOpenCreateDialog()}
|
||||
>
|
||||
<Icon name="plus" size={16} />
|
||||
新建
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="search-box">
|
||||
<Icon name="search" size={17} />
|
||||
<input
|
||||
aria-label="搜索脚本"
|
||||
placeholder="搜索脚本名称"
|
||||
value={keyword}
|
||||
onChange={(event) => onKeywordChange(event.target.value)}
|
||||
/>
|
||||
<button
|
||||
className={refreshing ? "is-spinning" : ""}
|
||||
type="button"
|
||||
aria-label="刷新脚本"
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<Icon name="refresh" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="tree-scroll">
|
||||
{loading ? (
|
||||
<div className="tree-skeleton">
|
||||
<span /><span /><span /><span />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{memberScriptGroups.map((group) => {
|
||||
const ownerKey = group.user?.user_id ?? "anon";
|
||||
return (
|
||||
<WorkspaceTreeGroup
|
||||
key={ownerKey}
|
||||
groupKey={`__group__${ownerKey}`}
|
||||
ownerUserId={ownerKey}
|
||||
title={`${group.user?.display_name}`}
|
||||
scripts={group.scripts}
|
||||
directories={group.directories}
|
||||
dataResources={group.dataResources}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
onContextMenu={
|
||||
group.user?.user_id === user?.user_id
|
||||
? onContextMenu
|
||||
: undefined
|
||||
}
|
||||
readOnly={group.user?.user_id !== user?.user_id}
|
||||
expandedPaths={expandedPaths}
|
||||
onToggle={onToggle}
|
||||
loadingChildrenPaths={loadingChildrenPaths}
|
||||
onCopyResourcePath={onCopyResourcePath}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{filteredScripts.length === 0 && directories.length === 0 && (
|
||||
<div className="tree-empty">
|
||||
<span className="tree-empty__icon">
|
||||
<Icon name="script" size={24} />
|
||||
</span>
|
||||
<strong>{keyword ? "没有匹配脚本" : "还没有构建脚本"}</strong>
|
||||
<p>
|
||||
{keyword
|
||||
? "换个关键词试试"
|
||||
: "新建 Notebook 或 Python 脚本开始实验"}
|
||||
</p>
|
||||
{!keyword && (
|
||||
<button type="button" onClick={() => onOpenCreateDialog()}>
|
||||
<Icon name="plus" size={15} />
|
||||
新建脚本
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function parentOf(path: string) {
|
||||
const parts = path.split("/");
|
||||
parts.pop();
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
function ownedScriptPath(item: ScriptItem) {
|
||||
return item.relative_path.replaceAll("\\", "/").split("/").slice(2).join("/");
|
||||
}
|
||||
|
||||
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,
|
||||
owner_user_id: ownerUserId,
|
||||
});
|
||||
parentPath = path;
|
||||
}
|
||||
}
|
||||
return [...result.values()];
|
||||
}
|
||||
|
||||
function mergeDirectories(
|
||||
left: WorkspaceDirectory[],
|
||||
right: WorkspaceDirectory[],
|
||||
): WorkspaceDirectory[] {
|
||||
return [...new Map(
|
||||
[...left, ...right].map((item) => [item.path, item]),
|
||||
).values()];
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import { type FormEvent, useEffect, useMemo, useRef } from "react";
|
||||
|
||||
import { useAuth } from "../../context/AuthContext";
|
||||
import { CreateFolderModal } from "../../components/platform/CreateFolderModal";
|
||||
import { CreateScriptModal } from "../../components/platform/CreateScriptModal";
|
||||
import { DataResourceUploadModal } from "../../components/platform/DataResourceUploadModal";
|
||||
import { CreateFolderModal } from "./CreateFolderModal";
|
||||
import { CreateScriptModal } from "./CreateScriptModal";
|
||||
import { DataResourceUploadModal } from "./DataResourceUploadModal";
|
||||
import Icon from "../../components/common/Icon";
|
||||
import { PublishModal } from "../../components/platform/PublishModal";
|
||||
import { ScriptExplorer } from "../../components/platform/ScriptExplorer";
|
||||
import { TreeContextMenu } from "../../components/platform/TreeContextMenu";
|
||||
import { VersionReceiptModal } from "../../components/platform/VersionReceiptModal";
|
||||
import { PublishModal } from "./PublishModal";
|
||||
import { ScriptExplorer } from "./ScriptExplorer";
|
||||
import { TreeContextMenu } from "./TreeContextMenu";
|
||||
import { VersionReceiptModal } from "./VersionReceiptModal";
|
||||
|
||||
import { getSessionCache, useScriptWorkspaceStore } from "./state/scriptWorkspaceStore";
|
||||
import { useUiStore } from "./state/uiStore";
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import Icon from "../../components/common/Icon";
|
||||
import type { ScriptItem, ScriptType } from "../../services/api";
|
||||
|
||||
type ContextMenuState = {
|
||||
x: number;
|
||||
y: number;
|
||||
kind: "root" | "directory" | "file";
|
||||
path: string;
|
||||
script?: ScriptItem;
|
||||
};
|
||||
|
||||
type TreeContextMenuProps = {
|
||||
contextMenu: ContextMenuState | null;
|
||||
onOpenScript: (scriptId: string) => void;
|
||||
onRemoveScript: (script: ScriptItem) => void;
|
||||
onToggleLock: (script: ScriptItem) => void;
|
||||
onOpenCreateDialog: (parentPath: string, scriptType: ScriptType) => void;
|
||||
onOpenFolderDialog: (parentPath: string) => void;
|
||||
onChooseUpload: (parentPath: string) => void;
|
||||
onRemoveDirectory: (path: string) => void;
|
||||
onCopyResourcePath?: (jupyterPath: string) => void;
|
||||
onRemoveResource?: (resourceId: string) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function TreeContextMenu({
|
||||
contextMenu,
|
||||
onOpenScript,
|
||||
onRemoveScript,
|
||||
onToggleLock,
|
||||
onOpenCreateDialog,
|
||||
onOpenFolderDialog,
|
||||
onChooseUpload,
|
||||
onRemoveDirectory,
|
||||
onCopyResourcePath,
|
||||
onRemoveResource,
|
||||
onClose,
|
||||
}: TreeContextMenuProps) {
|
||||
if (!contextMenu) return null;
|
||||
|
||||
const width = 188;
|
||||
const height = contextMenu.kind === "file" ? 124 : 190;
|
||||
const isDataResource = !!contextMenu.script?.script_id.startsWith("data:");
|
||||
|
||||
return (
|
||||
<div
|
||||
className="tree-context-menu"
|
||||
role="menu"
|
||||
style={{
|
||||
left: contextMenu.x,
|
||||
top: contextMenu.y,
|
||||
position: "fixed",
|
||||
zIndex: 1000,
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{contextMenu.kind === "file" && contextMenu.script ? (
|
||||
<>
|
||||
{isDataResource ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
if (onCopyResourcePath) {
|
||||
onCopyResourcePath(contextMenu.script!.relative_path);
|
||||
}
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Icon name="database" size={16} />
|
||||
复制 Jupyter 路径
|
||||
</button>
|
||||
{onRemoveResource && (
|
||||
<button
|
||||
className="is-danger"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
const id = contextMenu.script!.script_id.replace(/^data:/, "");
|
||||
onRemoveResource(id);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Icon name="close" size={16} />
|
||||
删除资源
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
onOpenScript(contextMenu.script!.script_id);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Icon name="script" size={16} />
|
||||
打开文件
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
onToggleLock(contextMenu.script!);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name={contextMenu.script.is_locked ? "unlock" : "lock"}
|
||||
size={16}
|
||||
/>
|
||||
{contextMenu.script.is_locked ? "解锁文件" : "锁定文件"}
|
||||
</button>
|
||||
<button
|
||||
className="is-danger"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => onRemoveScript(contextMenu.script!)}
|
||||
>
|
||||
<Icon name="close" size={16} />
|
||||
删除文件
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => onOpenCreateDialog(contextMenu.path, "notebook")}
|
||||
>
|
||||
<Icon name="notebook" size={16} />
|
||||
新建 Notebook
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => onOpenCreateDialog(contextMenu.path, "python")}
|
||||
>
|
||||
<Icon name="python" size={16} />
|
||||
新建 Python 文件
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => onOpenFolderDialog(contextMenu.path)}
|
||||
>
|
||||
<Icon name="folder" size={16} />
|
||||
新建文件夹
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => onChooseUpload(contextMenu.path)}
|
||||
>
|
||||
<Icon name="upload" size={16} />
|
||||
上传到此处
|
||||
</button>
|
||||
{contextMenu.kind === "directory" && (
|
||||
<>
|
||||
<span className="tree-context-menu__separator" />
|
||||
<button
|
||||
className="is-danger"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => onRemoveDirectory(contextMenu.path)}
|
||||
>
|
||||
<Icon name="close" size={16} />
|
||||
删除文件夹
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import Icon from "../../components/common/Icon";
|
||||
import type { StableVersion } from "../../services/api";
|
||||
import { copyToClipboard } from "../../lib/clipboard";
|
||||
|
||||
type VersionReceiptModalProps = {
|
||||
publishedVersion: StableVersion | null;
|
||||
onClose: () => void;
|
||||
onCopy: (text: string) => void;
|
||||
};
|
||||
|
||||
export function VersionReceiptModal({
|
||||
publishedVersion,
|
||||
onClose,
|
||||
onCopy,
|
||||
}: VersionReceiptModalProps) {
|
||||
if (!publishedVersion) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal version-receipt" role="dialog" aria-modal="true">
|
||||
<div className="version-receipt__check">
|
||||
<Icon name="check" size={28} />
|
||||
</div>
|
||||
<span className="modal__eyebrow">STABLE VERSION</span>
|
||||
<h2>稳定版本发布成功</h2>
|
||||
<p>
|
||||
{publishedVersion.version_label} 已成为不可变制品,
|
||||
后续调度将通过 versions_id 引用它。
|
||||
</p>
|
||||
<div className="version-id-box">
|
||||
<span>versions_id</span>
|
||||
<code>{publishedVersion.versions_id}</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
await copyToClipboard(publishedVersion.versions_id);
|
||||
onCopy("versions_id 已复制");
|
||||
}}
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="primary-button version-receipt__close"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
>
|
||||
完成
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user