chore:platform文件拆分
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import Icon from "./Icon";
|
||||
|
||||
const navigation = [
|
||||
{ label: "工作台", icon: "home" as const, page: "home" as const },
|
||||
{ label: "构建脚本", icon: "script" as const, page: "scripts" as const },
|
||||
{ label: "调度配置", icon: "schedule" as const, page: "schedules" as const },
|
||||
{ label: "系统管理", icon: "settings" as const, page: "system" as const },
|
||||
];
|
||||
|
||||
type ActivePage = "home" | "scripts" | "schedules" | "system";
|
||||
|
||||
type SidebarProps = {
|
||||
activePage: ActivePage;
|
||||
collapsed: boolean;
|
||||
onNavigate: (page: ActivePage) => void;
|
||||
onToggleCollapse: () => void;
|
||||
onEndEditing?: () => void;
|
||||
onSelectScript?: (scriptId: null) => void;
|
||||
editSessionRef?: React.RefObject<unknown | null>;
|
||||
};
|
||||
|
||||
export function Sidebar({
|
||||
activePage,
|
||||
collapsed,
|
||||
onNavigate,
|
||||
onToggleCollapse,
|
||||
onEndEditing,
|
||||
onSelectScript,
|
||||
editSessionRef,
|
||||
}: SidebarProps) {
|
||||
const handleNavigationClick = (page: ActivePage) => {
|
||||
if (page !== "scripts") {
|
||||
if (editSessionRef?.current) {
|
||||
onEndEditing?.();
|
||||
} else {
|
||||
onSelectScript?.(null);
|
||||
}
|
||||
}
|
||||
onNavigate(page);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className={`sidebar${collapsed ? " is-collapsed" : ""}`}>
|
||||
<div className="brand">
|
||||
<span className="brand__mark"><Icon name="brand" size={31} /></span>
|
||||
{!collapsed && <span className="brand__name">模型实验开发平台</span>}
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<nav className="navigation" aria-label="主导航">
|
||||
{navigation.map((item) => (
|
||||
<button
|
||||
className={`nav-item${
|
||||
item.page === activePage ? " nav-item--active" : ""
|
||||
}`}
|
||||
key={item.label}
|
||||
type="button"
|
||||
onClick={() => handleNavigationClick(item.page)}
|
||||
>
|
||||
<Icon name={item.icon} size={19} />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<button className="sidebar-footer" type="button" onClick={onToggleCollapse}>
|
||||
<Icon name="menu" size={19} />
|
||||
<span>{collapsed ? "展开菜单" : "收起菜单"}</span>
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import Icon from "./Icon";
|
||||
|
||||
type ToastState = {
|
||||
tone: "success" | "error" | "info";
|
||||
message: string;
|
||||
};
|
||||
|
||||
type ToastProps = {
|
||||
toast: ToastState | null;
|
||||
};
|
||||
|
||||
export function Toast({ toast }: ToastProps) {
|
||||
if (!toast) return null;
|
||||
|
||||
return (
|
||||
<div className={`toast toast--${toast.tone}`} role="status">
|
||||
<span>
|
||||
<Icon name={toast.tone === "success" ? "check" : "info"} size={17} />
|
||||
</span>
|
||||
{toast.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import Icon from "./Icon";
|
||||
import type { AuthUser, AuthWorkspace } from "../../context/AuthContext";
|
||||
|
||||
type TopbarProps = {
|
||||
activePage: string;
|
||||
apiOnline: boolean;
|
||||
user: AuthUser | null;
|
||||
currentWorkspace: AuthWorkspace;
|
||||
workspaces: AuthWorkspace[];
|
||||
workspaceMenuOpen: boolean;
|
||||
onSetWorkspaceMenuOpen: (open: boolean) => void;
|
||||
onSetCurrentWorkspace: (workspaceId: string) => void;
|
||||
onLogout: () => void;
|
||||
};
|
||||
|
||||
export function Topbar({
|
||||
activePage,
|
||||
apiOnline,
|
||||
user,
|
||||
currentWorkspace,
|
||||
workspaces,
|
||||
workspaceMenuOpen,
|
||||
onSetWorkspaceMenuOpen,
|
||||
onSetCurrentWorkspace,
|
||||
onLogout,
|
||||
}: TopbarProps) {
|
||||
const pageTitles: Record<string, string> = {
|
||||
home: "工作台",
|
||||
scripts: "构建脚本",
|
||||
schedules: "调度配置",
|
||||
system: "系统管理",
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="topbar">
|
||||
<div className="page-title">
|
||||
<button className="icon-button icon-button--back" type="button">
|
||||
<Icon name="chevron" size={19} />
|
||||
</button>
|
||||
<div>
|
||||
<span className="page-title__eyebrow">开发工作区</span>
|
||||
<h1>{pageTitles[activePage] || "工作台"}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="topbar__actions">
|
||||
<div className="api-state">
|
||||
<span className={`api-state__dot${apiOnline ? " is-online" : ""}`} />
|
||||
{apiOnline ? "服务已连接" : "服务未连接"}
|
||||
</div>
|
||||
<div className="topbar-menu-wrap">
|
||||
<button
|
||||
className="workspace-switcher"
|
||||
type="button"
|
||||
onClick={() => onSetWorkspaceMenuOpen(!workspaceMenuOpen)}
|
||||
>
|
||||
<span className="workspace-switcher__icon">
|
||||
<Icon name="workspace" size={18} />
|
||||
</span>
|
||||
<span>
|
||||
<small>当前 Workspace</small>
|
||||
<strong>{currentWorkspace.workspace_name}</strong>
|
||||
</span>
|
||||
<Icon name="chevron" size={15} />
|
||||
</button>
|
||||
{workspaceMenuOpen && (
|
||||
<div className="topbar-dropdown">
|
||||
{workspaces.map((workspace) => (
|
||||
<button
|
||||
className={
|
||||
workspace.workspace_id === currentWorkspace.workspace_id
|
||||
? "is-selected"
|
||||
: ""
|
||||
}
|
||||
type="button"
|
||||
key={workspace.workspace_id}
|
||||
onClick={() => {
|
||||
onSetCurrentWorkspace(workspace.workspace_id);
|
||||
onSetWorkspaceMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<Icon name="workspace" size={15} />
|
||||
<span>
|
||||
<strong>{workspace.workspace_name}</strong>
|
||||
<small>
|
||||
{workspace.workspace_id === currentWorkspace.workspace_id
|
||||
? "当前使用"
|
||||
: "点击切换"}
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="topbar-menu-wrap">
|
||||
<button className="user-menu" type="button">
|
||||
<span className="avatar">
|
||||
{user?.display_name?.slice(0, 1) ?? "?"}
|
||||
</span>
|
||||
<span className="user-menu__copy">
|
||||
<strong>{user?.display_name ?? "未知用户"}</strong>
|
||||
<small>
|
||||
{user?.role_code === "admin" ? "管理员" : "开发人员"}
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className="text-button"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onLogout();
|
||||
window.location.assign("/login");
|
||||
}}
|
||||
>
|
||||
登出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import Icon from "./Icon";
|
||||
|
||||
type WelcomePanelProps = {
|
||||
onCreateScript: () => void;
|
||||
};
|
||||
|
||||
export function WelcomePanel({ onCreateScript }: WelcomePanelProps) {
|
||||
return (
|
||||
<div className="welcome-panel">
|
||||
<div className="welcome-panel__visual">
|
||||
<Icon name="script" size={42} />
|
||||
</div>
|
||||
<span className="welcome-panel__label">构建脚本工作台</span>
|
||||
<h2>创建你的第一个模型脚本</h2>
|
||||
<p>
|
||||
通过 Notebook 完成数据探索,或使用 Python 脚本构建可调度的处理任务。
|
||||
</p>
|
||||
<button className="primary-button" onClick={onCreateScript}>
|
||||
<Icon name="plus" size={17} />
|
||||
新建构建脚本
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { type FormEvent } from "react";
|
||||
import Icon from "../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,165 @@
|
||||
import { type FormEvent } from "react";
|
||||
import Icon from "../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 }>;
|
||||
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) =>
|
||||
script.script_type === form.scriptType
|
||||
&& script.script_name.toLocaleLowerCase() === normalizedName.toLocaleLowerCase()
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { type FormEvent } from "react";
|
||||
import Icon from "../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,199 @@
|
||||
import { type ChangeEvent, type MouseEvent as ReactMouseEvent, type RefObject } from "react";
|
||||
import Icon from "../common/Icon";
|
||||
import { WorkspaceTreeGroup } from "../../features/platform/WorkspaceTree";
|
||||
import type { ScriptItem, WorkspaceDirectory } from "../../services/api";
|
||||
import type { AuthUser } from "../../context/AuthContext";
|
||||
|
||||
type ScriptExplorerProps = {
|
||||
scripts: ScriptItem[];
|
||||
filteredScripts: ScriptItem[];
|
||||
directories: WorkspaceDirectory[];
|
||||
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;
|
||||
uploadInputRef: RefObject<HTMLInputElement | null>;
|
||||
onHandleUpload: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
};
|
||||
|
||||
export function ScriptExplorer({
|
||||
scripts,
|
||||
filteredScripts,
|
||||
directories,
|
||||
user,
|
||||
selectedId,
|
||||
loading,
|
||||
refreshing,
|
||||
uploading,
|
||||
keyword,
|
||||
onKeywordChange,
|
||||
onRefresh,
|
||||
onUpload,
|
||||
onOpenCreateDialog,
|
||||
onOpenFolderDialog,
|
||||
onChooseUpload,
|
||||
onContextMenu,
|
||||
onSelect,
|
||||
uploadInputRef,
|
||||
onHandleUpload,
|
||||
}: ScriptExplorerProps) {
|
||||
const memberScriptGroups = (() => {
|
||||
const currentUserScripts = filteredScripts.filter(
|
||||
(item) => item.owner_user_id === user?.user_id,
|
||||
);
|
||||
const inferred = inferredDirectories(currentUserScripts);
|
||||
return [{
|
||||
user: user,
|
||||
scripts: currentUserScripts,
|
||||
directories: mergeDirectories(directories, inferred),
|
||||
}];
|
||||
})();
|
||||
|
||||
return (
|
||||
<aside className="explorer">
|
||||
<div className="explorer__header">
|
||||
<div>
|
||||
<h2>脚本目录</h2>
|
||||
<span>{scripts.length} 个工作副本</span>
|
||||
</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"
|
||||
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) => (
|
||||
<WorkspaceTreeGroup
|
||||
key={group.user?.user_id ?? "anon"}
|
||||
title={`${group.user?.display_name}的文件`}
|
||||
scripts={group.scripts}
|
||||
directories={group.directories}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
onContextMenu={
|
||||
group.user?.user_id === user?.user_id
|
||||
? onContextMenu
|
||||
: undefined
|
||||
}
|
||||
readOnly={group.user?.user_id !== user?.user_id}
|
||||
/>
|
||||
))}
|
||||
{filteredScripts.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[]): 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 });
|
||||
parentPath = path;
|
||||
}
|
||||
}
|
||||
return [...result.values()];
|
||||
}
|
||||
|
||||
function mergeDirectories(
|
||||
left: WorkspaceDirectory[],
|
||||
right: WorkspaceDirectory[],
|
||||
): WorkspaceDirectory[] {
|
||||
return [...new Map(
|
||||
[...left, ...right].map((item) => [item.path, item]),
|
||||
).values()];
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import Icon from "../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;
|
||||
onOpenCreateDialog: (parentPath: string, scriptType: ScriptType) => void;
|
||||
onOpenFolderDialog: (parentPath: string) => void;
|
||||
onChooseUpload: (parentPath: string) => void;
|
||||
onRemoveDirectory: (path: string) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function TreeContextMenu({
|
||||
contextMenu,
|
||||
onOpenScript,
|
||||
onRemoveScript,
|
||||
onOpenCreateDialog,
|
||||
onOpenFolderDialog,
|
||||
onChooseUpload,
|
||||
onRemoveDirectory,
|
||||
onClose,
|
||||
}: TreeContextMenuProps) {
|
||||
if (!contextMenu) return null;
|
||||
|
||||
const width = 188;
|
||||
const height = contextMenu.kind === "file" ? 92 : 190;
|
||||
|
||||
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 ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
onOpenScript(contextMenu.script!.script_id);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Icon name="script" size={16} />
|
||||
打开文件
|
||||
</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,54 @@
|
||||
import Icon from "../common/Icon";
|
||||
import type { StableVersion } from "../../services/api";
|
||||
|
||||
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={() => {
|
||||
void navigator.clipboard.writeText(
|
||||
publishedVersion.versions_id,
|
||||
);
|
||||
onCopy("versions_id 已复制");
|
||||
}}
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="primary-button version-receipt__close"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
>
|
||||
完成
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type Employee,
|
||||
} from "../../services/api";
|
||||
import { useApi, useAuth } from "../../context/AuthContext";
|
||||
import Icon from "../../components/Icon";
|
||||
import Icon from "../../components/common/Icon";
|
||||
import "../../styles/admin.css";
|
||||
import "../../styles/dashboard.css";
|
||||
import "../../styles/platform.css";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import Icon from "../../components/Icon";
|
||||
import Icon from "../../components/common/Icon";
|
||||
import type {
|
||||
ActiveEditSession,
|
||||
LatestVersion,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type MouseEvent as ReactMouseEvent, useState } from "react";
|
||||
|
||||
import Icon from "../../components/Icon";
|
||||
import Icon from "../../components/common/Icon";
|
||||
import type {
|
||||
ScriptItem,
|
||||
WorkspaceDirectory,
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from "../../services/api";
|
||||
|
||||
import { useApi } from "../../context/AuthContext";
|
||||
import Icon from "../../components/Icon";
|
||||
import Icon from "../../components/common/Icon";
|
||||
import "../../styles/schedule.css";
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user