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,
|
type Employee,
|
||||||
} from "../../services/api";
|
} from "../../services/api";
|
||||||
import { useApi, useAuth } from "../../context/AuthContext";
|
import { useApi, useAuth } from "../../context/AuthContext";
|
||||||
import Icon from "../../components/Icon";
|
import Icon from "../../components/common/Icon";
|
||||||
import "../../styles/admin.css";
|
import "../../styles/admin.css";
|
||||||
import "../../styles/dashboard.css";
|
import "../../styles/dashboard.css";
|
||||||
import "../../styles/platform.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 {
|
import type {
|
||||||
ActiveEditSession,
|
ActiveEditSession,
|
||||||
LatestVersion,
|
LatestVersion,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type MouseEvent as ReactMouseEvent, useState } from "react";
|
import { type MouseEvent as ReactMouseEvent, useState } from "react";
|
||||||
|
|
||||||
import Icon from "../../components/Icon";
|
import Icon from "../../components/common/Icon";
|
||||||
import type {
|
import type {
|
||||||
ScriptItem,
|
ScriptItem,
|
||||||
WorkspaceDirectory,
|
WorkspaceDirectory,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
} from "../../services/api";
|
} from "../../services/api";
|
||||||
|
|
||||||
import { useApi } from "../../context/AuthContext";
|
import { useApi } from "../../context/AuthContext";
|
||||||
import Icon from "../../components/Icon";
|
import Icon from "../../components/common/Icon";
|
||||||
import "../../styles/schedule.css";
|
import "../../styles/schedule.css";
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user