Merge remote-tracking branch 'aliyun/develop' into develop

This commit is contained in:
tao.chen
2026-08-04 18:48:24 +08:00
17 changed files with 2701 additions and 793 deletions
@@ -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>
);
}
+23
View File
@@ -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>
);
}
+121
View File
@@ -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>
);
}
+1 -1
View File
@@ -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";