Files
model-platform/frontend/app/features/platform/ScriptWorkspace.tsx
T
2026-08-04 13:35:16 +08:00

328 lines
11 KiB
TypeScript

import Icon from "../../components/Icon";
import type {
ActiveEditSession,
LatestVersion,
ScriptItem,
} 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: LatestVersion | 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>
);
}