merge: integrate feat/auth into develop

This commit is contained in:
Winnie
2026-08-03 17:44:00 +08:00
38 changed files with 2590 additions and 796 deletions
+19 -13
View File
@@ -2,13 +2,9 @@ import { type FormEvent, useEffect, useState } from "react";
import {
ApiRequestError,
createEmployee,
deleteEmployee,
demoContext,
listEmployees,
updateEmployee,
type Employee,
} from "../../services/api";
import { useApi, useAuth } from "../../context/AuthContext";
import Icon from "../../components/Icon";
import "../../styles/admin.css";
import "../../styles/dashboard.css";
@@ -28,13 +24,17 @@ export function DashboardPage({
online: boolean;
onNavigate: (page: "scripts" | "schedules" | "system") => void;
}) {
const { user, currentWorkspace } = useAuth();
return (
<section className="dashboard-page">
<div className="dashboard-hero">
<div>
<span>MODEL DEVELOPMENT PLATFORM</span>
<h2>{demoContext.userName}</h2>
<p> {demoContext.workspaceName}</p>
<h2>{user?.display_name ?? "用户"}</h2>
<p>
{currentWorkspace?.workspace_name ?? "(未选择 Workspace"}
</p>
</div>
<span className="dashboard-hero__badge">{online ? "服务正常" : "服务连接中"}</span>
</div>
@@ -112,18 +112,20 @@ export function SystemAdminPage({
onNotify: (notice: Notice) => void;
onConnectionChange: (online: boolean) => void;
}) {
const api = useApi();
const { user, currentWorkspace } = useAuth();
const [employees, setEmployees] = useState<Employee[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [editing, setEditing] = useState<Employee | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState(EMPTY_FORM);
const canManage = demoContext.roleCode === "admin";
const canManage = user?.role_code === "admin";
const load = async (): Promise<void> => {
setLoading(true);
try {
setEmployees(await listEmployees());
setEmployees(await api.listEmployees());
onConnectionChange(true);
} catch (error) {
onConnectionChange(false);
@@ -164,7 +166,7 @@ export function SystemAdminPage({
setSaving(true);
try {
if (editing) {
const updated = await updateEmployee(editing.user_id, {
const updated = await api.updateEmployee(editing.user_id, {
display_name: form.display_name.trim(),
email: form.email.trim() || null,
role_code: form.role_code,
@@ -174,7 +176,7 @@ export function SystemAdminPage({
(item) => item.user_id === updated.user_id ? updated : item,
));
} else {
const created = await createEmployee({
const created = await api.createEmployee({
username: form.username.trim(),
display_name: form.display_name.trim(),
email: form.email.trim() || null,
@@ -197,7 +199,7 @@ export function SystemAdminPage({
const remove = async (employee: Employee): Promise<void> => {
if (!window.confirm(`确定从当前 Workspace 删除员工“${employee.display_name}”吗?`)) return;
try {
await deleteEmployee(employee.user_id);
await api.deleteEmployee(employee.user_id);
setEmployees((current) => current.filter((item) => item.user_id !== employee.user_id));
onNotify({ tone: "success", message: "员工已删除" });
} catch (error) {
@@ -211,7 +213,11 @@ export function SystemAdminPage({
return (
<section className="admin-page">
<header className="admin-page__header">
<div><span></span><h2></h2><p>{demoContext.workspaceName} · {employees.length} </p></div>
<div>
<span></span>
<h2></h2>
<p>{currentWorkspace?.workspace_name ?? "(未选择 Workspace"} · {employees.length} </p>
</div>
<button className="primary-button" type="button" disabled={!canManage} onClick={openCreate}>
<Icon name="plus" size={15} />
</button>
@@ -10,24 +10,6 @@ import {
import { useLocation, useNavigate } from "react-router";
import {
acquireFileLock,
createWorkspaceDirectory,
createScript,
createJupyterAccessTicket,
deleteScript,
deleteWorkspaceDirectory,
demoContext,
demoUsers,
demoWorkspaces,
heartbeatFileLock,
listScripts,
listScriptVersions,
listWorkspaceDirectories,
publishScriptVersion,
releaseFileLock,
releaseFileLockOnUnload,
setDemoContext,
uploadScript,
type ActiveEditSession,
type ScriptItem,
type ScriptType,
@@ -35,6 +17,7 @@ import {
type Visibility,
type WorkspaceDirectory,
} from "../../services/api";
import { useApi, useAuth } from "~/context/AuthContext";
import Icon from "../../components/Icon";
import SchedulePage from "../schedules/SchedulePage";
import { DashboardPage, SystemAdminPage } from "../admin/AdminPages";
@@ -123,9 +106,27 @@ function mergeDirectories(
).values()];
}
export default function ModelPlatformApp() {
const { currentWorkspace } = useAuth();
if (!currentWorkspace) {
return (
<div className="app-shell" style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>
<div style={{ textAlign: "center" }}>
<span style={{ fontSize: 18 }}></span>
</div>
</div>
);
}
return <AuthenticatedModelPlatformApp />;
}
function AuthenticatedModelPlatformApp() {
const location = useLocation();
const navigate = useNavigate();
const activePage = pageFromPath(location.pathname);
const auth = useAuth();
const { user, workspaces, setCurrentWorkspace, logout } = auth;
const currentWorkspace = auth.currentWorkspace!;
const api = useApi();
const [scripts, setScripts] = useState<ScriptItem[]>([]);
const [directories, setDirectories] = useState<WorkspaceDirectory[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
@@ -144,7 +145,6 @@ export default function ModelPlatformApp() {
}>({ open: false, parentPath: "", name: "", busy: false });
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const [workspaceMenuOpen, setWorkspaceMenuOpen] = useState(false);
const [userMenuOpen, setUserMenuOpen] = useState(false);
const [uploadParentPath, setUploadParentPath] = useState("");
const [uploading, setUploading] = useState(false);
const uploadInputRef = useRef<HTMLInputElement | null>(null);
@@ -176,8 +176,8 @@ export default function ModelPlatformApp() {
setRefreshing(silent);
try {
const [items, folderItems] = await Promise.all([
listScripts(),
listWorkspaceDirectories(),
api.listScripts(),
api.listWorkspaceDirectories(),
]);
setScripts(items);
setDirectories(folderItems);
@@ -248,7 +248,7 @@ export default function ModelPlatformApp() {
}
let ignore = false;
setVersionsLoading(true);
void listScriptVersions(selectedId)
void api.listScriptVersions(selectedId)
.then((items) => {
if (!ignore) setVersions(items);
})
@@ -282,7 +282,7 @@ export default function ModelPlatformApp() {
return;
}
heartbeatRunning = true;
void heartbeatFileLock(current)
void api.heartbeatFileLock(current)
.then((updated) => {
setEditSession((active) => active
&& active.edit_session_id === updated.edit_session_id
@@ -319,7 +319,7 @@ export default function ModelPlatformApp() {
if (!current || current.edit_session_id !== editSession.edit_session_id) {
return;
}
void createJupyterAccessTicket(current)
void api.createJupyterAccessTicket(current)
.then((ticket) => {
setEditSession((active) => active
&& active.edit_session_id === ticket.edit_session_id
@@ -342,7 +342,7 @@ export default function ModelPlatformApp() {
if (!editSession) return;
const handleUnload = () => {
const current = editSessionRef.current;
if (current) releaseFileLockOnUnload(current);
if (current) api.releaseFileLockOnUnload(current);
};
window.addEventListener("beforeunload", handleUnload);
return () => window.removeEventListener("beforeunload", handleUnload);
@@ -356,24 +356,17 @@ export default function ModelPlatformApp() {
);
}, [keyword, scripts]);
const memberScriptGroups = [...demoUsers]
.sort((left, right) => (
Number(right.userId === demoContext.userId)
- Number(left.userId === demoContext.userId)
))
.map((user) => {
const memberScripts = filteredScripts.filter(
(item) => item.owner_user_id === user.userId,
const memberScriptGroups = (() => {
const currentUserScripts = filteredScripts.filter(
(item) => item.owner_user_id === user?.user_id,
);
const inferred = inferredDirectories(memberScripts);
return {
user,
scripts: memberScripts,
directories: user.userId === demoContext.userId
? mergeDirectories(directories, inferred)
: inferred,
};
});
const inferred = inferredDirectories(currentUserScripts);
return [{
user: user,
scripts: currentUserScripts,
directories: mergeDirectories(directories, inferred),
}];
})();
const selected = scripts.find((item) => item.script_id === selectedId) ?? null;
const selectScript = (scriptId: string | null) => {
@@ -413,7 +406,7 @@ export default function ModelPlatformApp() {
let newlyAcquired = false;
try {
if (active && active.script_id !== script.script_id) {
await releaseFileLock(active);
await api.releaseFileLock(active);
setEmbeddedJupyterUrl(null);
setEditSession(null);
editSessionRef.current = null;
@@ -422,20 +415,20 @@ export default function ModelPlatformApp() {
if (!requestIsCurrent()) return;
if (!active) {
active = await acquireFileLock(script);
active = await api.acquireFileLock(script);
newlyAcquired = true;
}
if (!requestIsCurrent()) {
if (active) {
await releaseFileLock(active);
await api.releaseFileLock(active);
clearSessionIfActive(active);
}
return;
}
const ticket = await createJupyterAccessTicket(active);
const ticket = await api.createJupyterAccessTicket(active);
if (!requestIsCurrent()) {
await releaseFileLock(active);
await api.releaseFileLock(active);
clearSessionIfActive(active);
return;
}
@@ -456,7 +449,7 @@ export default function ModelPlatformApp() {
} catch (error) {
if (newlyAcquired && active) {
try {
await releaseFileLock(active);
await api.releaseFileLock(active);
} catch {
// The database lease is the final safety net if compensation cannot reach Runtime.
}
@@ -513,7 +506,7 @@ export default function ModelPlatformApp() {
}
setEditBusy(true);
try {
await releaseFileLock(active);
await api.releaseFileLock(active);
setEmbeddedJupyterUrl(null);
setEditSession(null);
editSessionRef.current = null;
@@ -572,7 +565,7 @@ export default function ModelPlatformApp() {
if (!publishTarget) return;
setPublishing(true);
try {
const version = await publishScriptVersion({
const version = await api.publishScriptVersion({
script: publishTarget,
releaseNote,
visibility: publishVisibility,
@@ -602,7 +595,7 @@ export default function ModelPlatformApp() {
if (!form.name.trim()) return;
setCreating(true);
try {
const created = await createScript(form);
const created = await api.createScript(form);
setScripts((items) => [created, ...items]);
selectScript(created.script_id);
setCreateOpen(false);
@@ -658,7 +651,7 @@ export default function ModelPlatformApp() {
let lastCreated: ScriptItem | null = null;
try {
for (const file of files) {
lastCreated = await uploadScript(file, uploadParentPath);
lastCreated = await api.uploadScript(file, uploadParentPath);
}
await load(true);
if (lastCreated) selectScript(lastCreated.script_id);
@@ -684,7 +677,7 @@ export default function ModelPlatformApp() {
if (!folderDialog.name.trim()) return;
setFolderDialog((current) => ({ ...current, busy: true }));
try {
await createWorkspaceDirectory(
await api.createWorkspaceDirectory(
folderDialog.name.trim(),
folderDialog.parentPath,
);
@@ -718,7 +711,7 @@ export default function ModelPlatformApp() {
if (editSessionRef.current?.script_id === script.script_id) return;
}
try {
await deleteScript(script.script_id);
await api.deleteScript(script.script_id);
if (selectedIdRef.current === script.script_id) selectScript(null);
await load(true);
setToast({
@@ -752,7 +745,7 @@ export default function ModelPlatformApp() {
if (editSessionRef.current?.script_id === activeScript.script_id) return;
}
try {
const result = await deleteWorkspaceDirectory(path);
const result = await api.deleteWorkspaceDirectory(path);
const selectedScript = scripts.find(
(item) => item.script_id === selectedIdRef.current,
);
@@ -851,36 +844,29 @@ export default function ModelPlatformApp() {
{apiOnline ? "服务已连接" : "服务未连接"}
</div>
<div className="topbar-menu-wrap">
<button className="workspace-switcher" type="button" onClick={() => { setWorkspaceMenuOpen((value) => !value); setUserMenuOpen(false); }}>
<button className="workspace-switcher" type="button" onClick={() => { setWorkspaceMenuOpen((value) => !value); }}>
<span className="workspace-switcher__icon"><Icon name="workspace" size={18} /></span>
<span><small> Workspace</small><strong>{demoContext.workspaceName}</strong></span>
<span><small> Workspace</small><strong>{currentWorkspace.workspace_name}</strong></span>
<Icon name="chevron" size={15} />
</button>
{workspaceMenuOpen && (
<div className="topbar-dropdown">
{demoWorkspaces.map((workspace) => (
<button className={workspace.workspaceId === demoContext.workspaceId ? "is-selected" : ""} type="button" key={workspace.workspaceId} onClick={() => { setDemoContext({ workspace }); window.location.reload(); }}>
<Icon name="workspace" size={15} /><span><strong>{workspace.workspaceName}</strong><small>{workspace.workspaceId === demoContext.workspaceId ? "当前使用" : "点击切换"}</small></span>
{workspaces.map((workspace) => (
<button className={workspace.workspace_id === currentWorkspace.workspace_id ? "is-selected" : ""} type="button" key={workspace.workspace_id} onClick={() => { setCurrentWorkspace(workspace.workspace_id); setWorkspaceMenuOpen(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" onClick={() => { setUserMenuOpen((value) => !value); setWorkspaceMenuOpen(false); }}>
<span className="avatar">{demoContext.userName.slice(0, 1)}</span>
<span className="user-menu__copy"><strong>{demoContext.userName}</strong><small>{demoContext.roleName}</small></span>
<Icon name="chevron" size={15} />
<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={() => { logout(); window.location.assign("/login"); }}>
</button>
{userMenuOpen && (
<div className="topbar-dropdown topbar-dropdown--users">
{demoUsers.map((user) => (
<button className={user.userId === demoContext.userId ? "is-selected" : ""} type="button" key={user.userId} onClick={() => { setDemoContext({ user }); window.location.reload(); }}>
<span className="avatar">{user.userName.slice(0, 1)}</span><span><strong>{user.userName}</strong><small>{user.roleName} · {user.username}</small></span>
</button>
))}
</div>
)}
</div>
</div>
</header>
@@ -949,18 +935,18 @@ export default function ModelPlatformApp() {
<>
{memberScriptGroups.map((group) => (
<WorkspaceTreeGroup
key={group.user.userId}
title={`${group.user.userName}的文件`}
key={group.user?.user_id ?? "anon"}
title={`${group.user?.display_name}的文件`}
scripts={group.scripts}
directories={group.directories}
selectedId={selectedId}
onSelect={selectScript}
onContextMenu={
group.user.userId === demoContext.userId
group.user?.user_id === user?.user_id
? showContextMenu
: undefined
}
readOnly={group.user.userId !== demoContext.userId}
readOnly={group.user?.user_id !== user?.user_id}
/>
))}
{filteredScripts.length === 0 && (
@@ -1045,13 +1031,13 @@ export default function ModelPlatformApp() {
</section>
) : activePage === "schedules" ? (
<SchedulePage
key={`${demoContext.userId}-${demoContext.workspaceId}`}
key={`${user?.user_id ?? "anon"}-${currentWorkspace?.workspace_id ?? "none"}`}
onNotify={setToast}
onConnectionChange={setApiOnline}
/>
) : activePage === "system" ? (
<SystemAdminPage
key={`${demoContext.userId}-${demoContext.workspaceId}`}
key={`${user?.user_id ?? "anon"}-${currentWorkspace?.workspace_id ?? "none"}`}
onNotify={setToast}
onConnectionChange={setApiOnline}
/>
@@ -1458,4 +1444,4 @@ export default function ModelPlatformApp() {
)}
</div>
);
}
}
@@ -0,0 +1,327 @@
import Icon from "../../components/Icon";
import type {
ActiveEditSession,
ScriptItem,
StableVersion,
} from "../../services/api";
import { scriptIcon } from "./WorkspaceTree";
type ToastState = {
tone: "success" | "error" | "info";
message: string;
};
type ScriptWorkspaceProps = {
script: ScriptItem;
editSession: ActiveEditSession | null;
jupyterUrl: string | null;
editBusy: boolean;
openError: string | null;
latestVersion: StableVersion | null;
versionsLoading: boolean;
onOpenEditor: () => void;
onEndEditing: () => void;
onClose: () => void;
onPublish: () => void;
onInfo: (toast: ToastState) => void;
};
function formatTime(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(new Date(value));
}
function formatBytes(value: number) {
if (value < 1024) return `${value} B`;
return `${(value / 1024).toFixed(1)} KB`;
}
function shortHash(value: string) {
return value ? `${value.slice(0, 8)}${value.slice(-6)}` : "—";
}
function confineJupyterFrame(frame: HTMLIFrameElement): void {
try {
const document = frame.contentDocument;
if (!document?.documentElement) return;
const keepInside = (): void => {
document.querySelectorAll<HTMLElement>("button,[role='button']").forEach(
(element) => {
const label = `${element.getAttribute("aria-label") ?? ""} ${
element.getAttribute("title") ?? ""
} ${element.textContent ?? ""}`.trim();
if (/^open in\b/i.test(label) || /\bopen in\.\.\./i.test(label)) {
element.style.setProperty("display", "none", "important");
}
},
);
document.querySelectorAll<HTMLAnchorElement>("a[target]").forEach((link) => {
if (["_blank", "_top", "_parent"].includes(link.target)) {
link.target = "_self";
}
});
};
keepInside();
new MutationObserver(keepInside).observe(document.documentElement, {
childList: true,
subtree: true,
});
document.addEventListener("click", (event) => {
const target = event.target as HTMLElement | null;
const link = target?.closest?.("a") as HTMLAnchorElement | null;
if (link && ["_blank", "_top", "_parent"].includes(link.target)) {
link.target = "_self";
}
}, true);
} catch {
// The iframe remains sandboxed even if its document is not yet accessible.
}
}
export function ScriptWorkspace({
script,
editSession,
jupyterUrl,
editBusy,
openError,
latestVersion,
versionsLoading,
onOpenEditor,
onEndEditing,
onClose,
onPublish,
onInfo,
}: ScriptWorkspaceProps) {
const isNotebook = script.script_type === "notebook";
const isEditing = editSession?.session_status === "active";
return (
<>
<div className="tabbar">
<div className="editor-tab editor-tab--active">
<span className={`file-icon file-icon--${script.script_type}`}>
<Icon name={scriptIcon(script)} size={16} />
</span>
<span>{script.script_name}</span>
<button type="button" aria-label="关闭标签" onClick={onClose}>
<Icon name="close" size={14} />
</button>
</div>
<button
className="new-tab"
type="button"
onClick={() => onInfo({ tone: "info", message: "请从左侧选择或新建脚本" })}
>
<Icon name="plus" size={17} />
</button>
</div>
<div className="editor-toolbar">
<div className="editor-toolbar__path">
<span className={`file-icon file-icon--${script.script_type}`}>
<Icon name={scriptIcon(script)} size={17} />
</span>
<span></span>
<Icon name="chevron" size={13} />
<strong>{script.script_name}</strong>
</div>
<div className="editor-toolbar__actions">
{isEditing ? (
<button
className="end-edit-button"
type="button"
disabled={editBusy}
onClick={onEndEditing}
>
{editBusy ? "正在释放…" : "结束编辑"}
</button>
) : (
<button
type="button"
onClick={() => onInfo({
tone: "info",
message: "Jupyter 中保存后会直接写入 Workspace 工作副本",
})}
>
</button>
)}
<button
className="release-button"
type="button"
onClick={onPublish}
>
</button>
<span className={`stage-badge${isEditing ? " is-editing" : ""}`}>
<span />
{isEditing
? isNotebook
? "Demo 无锁模式 · Kernel 已连接"
: "Demo 无锁模式 · 编辑中"
: latestVersion
? `最新 ${latestVersion.version_label}`
: "工作副本已就绪"}
</span>
</div>
</div>
<div className={`editor-canvas${isEditing && jupyterUrl ? " is-embedded" : ""}`}>
{isEditing && jupyterUrl ? (
<section className="embedded-jupyter">
<div className="embedded-jupyter__status">
<span>
<i />
Workspace Jupyter Server
</span>
<span>
{isNotebook ? "Notebook Session · Python 3 Kernel" : "Jupyter 文本编辑器"}
</span>
<code title={editSession.runtime_id}>
Runtime {editSession.runtime_id.slice(-8)}
</code>
</div>
<iframe
key={`${editSession.edit_session_id}:${jupyterUrl}`}
src={jupyterUrl}
title={`${script.script_name} Jupyter 编辑器`}
allow="clipboard-read; clipboard-write"
sandbox="allow-same-origin allow-scripts allow-forms allow-downloads allow-modals"
onLoad={(event) => confineJupyterFrame(event.currentTarget)}
/>
</section>
) : isNotebook ? (
<section
className={`editor-opening-state${openError ? " has-error" : ""}`}
aria-live="polite"
>
<div className="editor-opening-state__icon">
{openError
? <Icon name="info" size={28} />
: <span className="button-spinner button-spinner--blue" />}
</div>
<strong>
{openError ? "Notebook 打开失败" : "正在打开 Notebook"}
</strong>
<p>
{openError
? openError
: "正在获取编辑锁并连接 Workspace Jupyter Server…"}
</p>
{openError && (
<button
className="open-editor-button"
type="button"
disabled={editBusy}
onClick={onOpenEditor}
>
{editBusy
? <span className="button-spinner button-spinner--blue" />
: <Icon name="refresh" size={16} />}
{editBusy ? "正在重试…" : "重试打开"}
</button>
)}
</section>
) : (
<section className="script-overview">
<div className="script-overview__header">
<div>
<span className="section-kicker">PYTHON SCRIPT</span>
<h2>{script.script_name}</h2>
<p>{script.relative_path}</p>
</div>
<button
className={`open-editor-button${isEditing ? " is-editing" : ""}`}
type="button"
disabled={editBusy}
onClick={onOpenEditor}
>
{editBusy
? <span className="button-spinner button-spinner--blue" />
: <Icon name="external" size={17} />}
{editBusy
? "正在准备编辑器…"
: isEditing
? "继续编辑"
: "打开编辑器"}
</button>
</div>
<div className="metadata-grid">
<div>
<span></span>
<strong>Python</strong>
</div>
<div>
<span></span>
<strong>
{script.visibility === "workspace"
? "Workspace"
: script.visibility === "public" ? "公开" : "私有"}
</strong>
</div>
<div>
<span></span>
<strong>{formatBytes(script.size_bytes)}</strong>
</div>
<div>
<span></span>
<strong>{formatTime(script.updated_at)}</strong>
</div>
</div>
<div className="preview-card">
<div className="preview-card__bar">
<div>
<span className="window-dot window-dot--red" />
<span className="window-dot window-dot--yellow" />
<span className="window-dot window-dot--green" />
</div>
<span>Python </span>
<em>{isEditing ? "Jupyter 中可编辑" : "平台只读预览"}</em>
</div>
<PythonPreview />
</div>
<div className="integrity-row">
<span>
<Icon name="check" size={15} />
{isEditing ? "Demo 无锁编辑会话已连接" : "工作副本已同步"}
</span>
<span>SHA-256&nbsp; {shortHash(script.content_hash)}</span>
<span>
&nbsp;
{versionsLoading
? "加载中"
: latestVersion
? `${latestVersion.version_label} · ${latestVersion.versions_id}`
: "尚未发布"}
</span>
</div>
</section>
)}
</div>
</>
);
}
function PythonPreview() {
return (
<div className="python-preview">
<div className="line-numbers">
1<br />2<br />3<br />4<br />5<br />6<br />7<br />8<br />9
</div>
<pre>
<span className="code-comment">&quot;&quot;&quot;&quot;&quot;&quot;</span>
{"\n\n"}<b>def</b> <span className="code-function">main</span>() -&gt; <b>None</b>:
{"\n"} print(<i>&quot;Hello, Model Platform!&quot;</i>)
{"\n\n\n"}<b>if</b> __name__ == <i>&quot;__main__&quot;</i>:
{"\n"} main()
</pre>
</div>
);
}
@@ -0,0 +1,212 @@
import { type MouseEvent as ReactMouseEvent, useState } from "react";
import Icon from "../../components/Icon";
import type {
ScriptItem,
WorkspaceDirectory,
} from "../../services/api";
export type WorkspaceTreeTarget = {
kind: "root" | "directory" | "file";
path: string;
script?: ScriptItem;
};
type WorkspaceTreeProps = {
title: string;
scripts: ScriptItem[];
directories: WorkspaceDirectory[];
selectedId: string | null;
onSelect: (id: string) => void;
onContextMenu?: (
event: ReactMouseEvent,
target: WorkspaceTreeTarget,
) => void;
readOnly?: boolean;
};
type WorkspaceTreeItemsProps = Omit<WorkspaceTreeProps, "title" | "readOnly"> & {
path: string;
depth: number;
};
function formatTime(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(new Date(value));
}
export function scriptIcon(item: ScriptItem) {
return item.script_type === "notebook" ? "notebook" : "python";
}
function ownedScriptPath(item: ScriptItem) {
return item.relative_path.replaceAll("\\", "/").split("/").slice(2).join("/");
}
function parentOf(path: string) {
const parts = path.split("/");
parts.pop();
return parts.join("/");
}
export function WorkspaceTreeGroup({
title,
scripts,
directories,
selectedId,
onSelect,
onContextMenu,
readOnly = false,
}: WorkspaceTreeProps) {
const [open, setOpen] = useState(true);
return (
<div className="tree-group">
<button
className={`tree-group__title${open ? " is-open" : ""}`}
type="button"
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
onContextMenu={onContextMenu
? (event) => onContextMenu(event, { kind: "root", path: "" })
: undefined}
>
<Icon name="chevron" size={14} />
<Icon name="folder" size={17} />
<span>{title}</span>
<em>{scripts.length}</em>
</button>
{open && (
<div className="tree-group__items">
<WorkspaceTreeItems
path=""
depth={0}
scripts={scripts}
directories={directories}
selectedId={selectedId}
onSelect={onSelect}
onContextMenu={onContextMenu}
/>
{scripts.length === 0 && directories.length === 0 && (
<p className="tree-group__empty">
{readOnly ? "暂无文件" : "右键此目录即可新建或上传"}
</p>
)}
</div>
)}
</div>
);
}
function WorkspaceTreeItems({
path,
depth,
scripts,
directories,
selectedId,
onSelect,
onContextMenu,
}: WorkspaceTreeItemsProps) {
const childDirectories = directories.filter(
(item) => item.parent_path === path,
);
const childScripts = scripts.filter(
(item) => parentOf(ownedScriptPath(item)) === path,
);
return (
<>
{childDirectories.map((directory) => (
<DirectoryBranch
key={directory.path}
directory={directory}
depth={depth}
scripts={scripts}
directories={directories}
selectedId={selectedId}
onSelect={onSelect}
onContextMenu={onContextMenu}
/>
))}
{childScripts.map((item) => (
<button
className={`script-row${
selectedId === item.script_id ? " script-row--active" : ""
}`}
style={{ paddingLeft: 20 + depth * 16 }}
key={item.script_id}
type="button"
onClick={() => onSelect(item.script_id)}
onContextMenu={onContextMenu
? (event) => onContextMenu(event, {
kind: "file",
path: ownedScriptPath(item),
script: item,
})
: undefined}
>
<span className={`file-icon file-icon--${item.script_type}`}>
<Icon name={scriptIcon(item)} size={17} />
</span>
<span className="script-row__copy">
<strong title={item.script_name}>{item.script_name}</strong>
<small>{formatTime(item.updated_at)}</small>
</span>
{item.visibility !== "private" && (
<span className="visibility-dot" title="Workspace 可见" />
)}
</button>
))}
</>
);
}
function DirectoryBranch({
directory,
depth,
scripts,
directories,
selectedId,
onSelect,
onContextMenu,
}: Omit<WorkspaceTreeItemsProps, "path"> & {
directory: WorkspaceDirectory;
}) {
const [open, setOpen] = useState(true);
return (
<div className="directory-branch">
<button
className="directory-row"
style={{ paddingLeft: 10 + depth * 16 }}
type="button"
onClick={() => setOpen((current) => !current)}
onContextMenu={onContextMenu
? (event) => onContextMenu(event, {
kind: "directory",
path: directory.path,
})
: undefined}
>
<span className={`directory-row__chevron${open ? " is-open" : ""}`}>
<Icon name="chevron" size={13} />
</span>
<Icon name="folder" size={17} />
<strong title={directory.path}>{directory.name}</strong>
</button>
{open && (
<WorkspaceTreeItems
path={directory.path}
depth={depth + 1}
scripts={scripts}
directories={directories}
selectedId={selectedId}
onSelect={onSelect}
onContextMenu={onContextMenu}
/>
)}
</div>
);
}
@@ -11,22 +11,6 @@ import {
import {
ApiRequestError,
createSchedule,
createScheduleEdge,
createScheduleNode,
deleteSchedule,
deleteScheduleEdge,
deleteScheduleNode,
hideScheduleArtifact,
getSchedule,
listScheduleArtifacts,
listScheduleRuns,
listSchedules,
previewCron,
runScheduleNow,
updateSchedule,
updateScheduleNode,
validateSchedule,
type CronPreview,
type Schedule,
type ScheduleArtifact,
@@ -34,6 +18,8 @@ import {
type ScheduleNode,
type ScheduleRunSummary,
} from "../../services/api";
import { useApi } from "~/context/AuthContext";
import Icon from "../../components/Icon";
import "../../styles/schedule.css";
@@ -250,6 +236,7 @@ export default function SchedulePage({
const dragRef = useRef<DragState | null>(null);
const positionDraftsRef = useRef<Record<string, NodePositionDraft>>({});
const [positionDraftCount, setPositionDraftCount] = useState(0);
const api = useApi();
const selectedNode = schedule?.nodes.find(
(item) => item.node_id === selectedNodeId,
@@ -328,7 +315,7 @@ export default function SchedulePage({
): Promise<void> => {
if (showLoading) setRunsLoading(true);
try {
const items = await listScheduleRuns({
const items = await api.listScheduleRuns({
scheduleId,
limit: 20,
});
@@ -349,8 +336,8 @@ export default function SchedulePage({
preferredScheduleId?: string | null,
): Promise<void> => {
const [scheduleItems, artifactItems] = await Promise.all([
listSchedules(),
listScheduleArtifacts(),
api.listSchedules(),
api.listScheduleArtifacts(),
]);
setSchedules(scheduleItems);
setArtifacts(artifactItems);
@@ -362,20 +349,20 @@ export default function SchedulePage({
setSchedule(null);
return;
}
const detail = await getSchedule(targetId);
const detail = await api.getSchedule(targetId);
setSchedule(applyPositionDrafts(detail));
};
useEffect(() => {
let cancelled = false;
setLoading(true);
Promise.all([listSchedules(), listScheduleArtifacts()])
Promise.all([api.listSchedules(), api.listScheduleArtifacts()])
.then(async ([scheduleItems, artifactItems]) => {
if (cancelled) return;
setSchedules(scheduleItems);
setArtifacts(artifactItems);
if (scheduleItems[0]) {
const detail = await getSchedule(scheduleItems[0].schedule_id);
const detail = await api.getSchedule(scheduleItems[0].schedule_id);
if (!cancelled) setSchedule(applyPositionDrafts(detail));
}
onConnectionChange(true);
@@ -404,7 +391,7 @@ export default function SchedulePage({
}
let cancelled = false;
setRunsLoading(true);
listScheduleRuns({ scheduleId, limit: 20 })
api.listScheduleRuns({ scheduleId, limit: 20 })
.then((items) => {
if (!cancelled) setRuns(items);
})
@@ -507,7 +494,7 @@ export default function SchedulePage({
setLinkSourceId(null);
setCronResult(null);
try {
setSchedule(await getSchedule(scheduleId));
setSchedule(await api.getSchedule(scheduleId));
onConnectionChange(true);
} catch (error) {
await handleError(error, "调度详情加载失败");
@@ -529,7 +516,7 @@ export default function SchedulePage({
if (busy || !scheduleName) return;
setBusy("create-schedule");
try {
const created = await createSchedule({
const created = await api.createSchedule({
schedule_name: scheduleName,
description: "在画布中拖入稳定版本并配置执行顺序",
trigger_type: "manual",
@@ -557,7 +544,7 @@ export default function SchedulePage({
if (!window.confirm(`确定删除调度“${selectedSchedule.schedule_name}”吗?`)) return;
setBusy("delete-schedule");
try {
await deleteSchedule(
await api.deleteSchedule(
selectedSchedule.schedule_id,
selectedSchedule.workflow_version,
);
@@ -571,7 +558,7 @@ export default function SchedulePage({
setSelectedNodeId(null);
setSelectedEdgeId(null);
if (remaining[0]) {
setSchedule(await getSchedule(remaining[0].schedule_id));
setSchedule(await api.getSchedule(remaining[0].schedule_id));
}
}
onNotify({ tone: "success", message: "调度方案已删除" });
@@ -589,7 +576,7 @@ export default function SchedulePage({
if (!scheduleName || scheduleName === target.schedule_name) return;
setBusy("rename-schedule");
try {
const updated = await updateSchedule(target.schedule_id, {
const updated = await api.updateSchedule(target.schedule_id, {
workflow_version: target.workflow_version,
schedule_name: scheduleName,
});
@@ -620,7 +607,7 @@ export default function SchedulePage({
) return;
setBusy("delete-artifact");
try {
await hideScheduleArtifact(artifact.versions_id);
await api.hideScheduleArtifact(artifact.versions_id);
setArtifacts((current) => current.filter(
(item) => item.versions_id !== artifact.versions_id,
));
@@ -652,13 +639,13 @@ export default function SchedulePage({
for (const [nodeId, position] of Object.entries(
positionDraftsRef.current,
)) {
updated = await updateScheduleNode(updated.schedule_id, nodeId, {
updated = await api.updateScheduleNode(updated.schedule_id, nodeId, {
workflow_version: updated.workflow_version,
position_x: position.position_x,
position_y: position.position_y,
});
}
updated = await updateSchedule(updated.schedule_id, {
updated = await api.updateSchedule(updated.schedule_id, {
workflow_version: updated.workflow_version,
schedule_name: scheduleForm.scheduleName.trim(),
description: scheduleForm.description.trim() || null,
@@ -694,7 +681,7 @@ export default function SchedulePage({
if (busy) return;
setBusy("cron-preview");
try {
const result = await previewCron({
const result = await api.previewCron({
cron_expression: scheduleForm.cronExpression.trim(),
timezone: scheduleForm.timezone.trim(),
count: 5,
@@ -727,7 +714,7 @@ export default function SchedulePage({
}
setBusy("run-now");
try {
const created = await runScheduleNow(schedule.schedule_id);
const created = await api.runScheduleNow(schedule.schedule_id);
setRuns((current) => [
created,
...current.filter((item) => item.run_id !== created.run_id),
@@ -763,7 +750,7 @@ export default function SchedulePage({
const nodeKey = artifactNodeKey(artifact, schedule);
const updated = await withMutation(
"add-node",
() => createScheduleNode(schedule.schedule_id, {
() => api.createScheduleNode(schedule.schedule_id, {
workflow_version: schedule.workflow_version,
node_key: nodeKey,
node_name: artifact.script_name,
@@ -898,7 +885,7 @@ export default function SchedulePage({
setLinkSourceId(null);
await withMutation(
"create-edge",
() => createScheduleEdge(schedule.schedule_id, {
() => api.createScheduleEdge(schedule.schedule_id, {
workflow_version: schedule.workflow_version,
source_node_id: sourceId,
target_node_id: targetNodeId,
@@ -935,7 +922,7 @@ export default function SchedulePage({
);
await withMutation(
"save-node",
() => updateScheduleNode(schedule.schedule_id, selectedNode.node_id, {
() => api.updateScheduleNode(schedule.schedule_id, selectedNode.node_id, {
workflow_version: schedule.workflow_version,
node_name: nodeForm.nodeName.trim(),
timeout_seconds: timeoutSeconds,
@@ -958,7 +945,7 @@ export default function SchedulePage({
if (!window.confirm(`确定删除节点“${node.node_name}”吗?`)) return;
const updated = await withMutation(
"delete-node",
() => deleteScheduleNode(
() => api.deleteScheduleNode(
schedule.schedule_id,
node.node_id,
schedule.workflow_version,
@@ -974,7 +961,7 @@ export default function SchedulePage({
setContextMenu(null);
const updated = await withMutation(
"delete-edge",
() => deleteScheduleEdge(
() => api.deleteScheduleEdge(
schedule.schedule_id,
edge.edge_id,
schedule.workflow_version,
@@ -988,7 +975,7 @@ export default function SchedulePage({
if (!schedule || busy) return;
setBusy("validate");
try {
const result = await validateSchedule(schedule.schedule_id);
const result = await api.validateSchedule(schedule.schedule_id);
setSchedule((current) => current
? { ...current, dag_validation: result }
: current);