Files
model-platform/frontend/app/features/platform/ScriptWorkspace.tsx
T
2026-08-12 16:58:00 +08:00

623 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Icon from "../../components/common/Icon";
import type {
ActiveEditSession,
LatestVersion,
ScriptItem,
ScriptType,
} from "~/services/api";
import { scriptIcon } from "./WorkspaceTree";
import type { MouseEvent as ReactMouseEvent } from "react";
import Editor from "@monaco-editor/react";
import { useRef, useLayoutEffect, useState, useEffect } from "react";
import { useScriptWorkspaceStore, type PythonEditorBuffer } from "./state/scriptWorkspaceStore";
import { PythonEditor } from "./PythonEditor";
import { useAuth } from "~/context/AuthContext";
import { getScriptContent } from "~/services/api";
type ToastState = {
tone: "success" | "error" | "info";
message: string;
};
// 缓存的会话类型(多 iframe 共存方案)
interface CachedSession {
session: ActiveEditSession;
jupyterUrl: string;
lastActiveTime: number;
}
type ScriptWorkspaceProps = {
script: ScriptItem;
scripts: ScriptItem[];
sessionCache: Map<string, CachedSession>;
editSession: ActiveEditSession | null;
jupyterUrl: string | null;
editBusy: boolean;
openError: string | null;
latestVersion: LatestVersion | null;
versionsLoading: boolean;
openTabs: Array<{ scriptId: string; scriptName: string; scriptType: ScriptType }>;
onOpenEditor: () => void;
onEndEditing: () => void;
onClose: (scriptId: string, event?: ReactMouseEvent) => void;
onSwitchTab: (scriptId: string) => void;
onNewTab: () => void;
onPublish: () => void;
onInfo: (toast: ToastState) => void;
pythonEditorBuffers: Record<string, PythonEditorBuffer>;
onOpenPythonEditor: () => void;
onSetPythonEditorContent: (scriptId: string, v: string) => void;
onSavePythonEditor: (scriptId: string) => void;
onExitPythonEditor: (scriptId: string) => void;
onClosePythonTab: (scriptId: string) => 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,
scripts,
sessionCache,
editSession,
jupyterUrl,
editBusy,
openError,
latestVersion,
versionsLoading,
openTabs,
onOpenEditor,
onEndEditing,
onClose,
onSwitchTab,
onNewTab,
onPublish,
onInfo,
pythonEditorBuffers,
onOpenPythonEditor,
onSetPythonEditorContent,
onSavePythonEditor,
onExitPythonEditor,
onClosePythonTab,
}: ScriptWorkspaceProps) {
const { user } = useAuth();
const tabbarRef = useRef<HTMLDivElement | null>(null);
const isNotebook = script.script_type === "notebook";
const isPython = script.script_type === "python";
// 判断当前选中的文件是否正在编辑(需要同时检查 session_status 和 script_id
const isEditing = editSession?.session_status === "active"
&& editSession?.script_id === script.script_id;
const activePythonBuf = isPython ? pythonEditorBuffers[script.script_id] : null;
const isPythonEditing = isPython && !!activePythonBuf;
const showSaveButton = isPythonEditing && activePythonBuf &&
(activePythonBuf.dirty || activePythonBuf.saving || activePythonBuf.initial);
const saveDisabled = !activePythonBuf?.dirty || activePythonBuf?.saving;
const pythonTabBuffers = openTabs.filter(
(t) => t.scriptType === "python" && pythonEditorBuffers[t.scriptId],
);
const hasEmbeddedContent = sessionCache.size > 0 || pythonTabBuffers.length > 0;
// 只读模式状态
const [readOnlyContent, setReadOnlyContent] = useState<string | null>(null);
const [readOnlyLoading, setReadOnlyLoading] = useState(false);
const [readOnlyError, setReadOnlyError] = useState<string | null>(null);
// 判断是否启用只读模式:文件已锁定 + 非所有者 + 非管理员
const isReadOnlyMode = script.is_locked
&& user?.user_id !== script.owner_user_id
&& user?.role_code !== "admin";
const scroll = (direction: "left" | "right") => {
const tabbar = tabbarRef.current;
if (!tabbar) return;
const scrollAmount = 200;
tabbar.scrollBy({
left: direction === "left" ? -scrollAmount : scrollAmount,
behavior: "smooth",
});
};
useLayoutEffect(() => {
const tabbar = tabbarRef.current;
if (!tabbar) return;
const activeTab = tabbar.querySelector(".editor-tab--active") as HTMLElement | null;
if (activeTab) {
const tabbarRect = tabbar.getBoundingClientRect();
const tabRect = activeTab.getBoundingClientRect();
if (tabRect.right > tabbarRect.right || tabRect.left < tabbarRect.left) {
activeTab.scrollIntoView({ behavior: "smooth", inline: "center" });
}
}
}, [script.script_id]);
// 加载只读内容(当处于只读模式时)
useEffect(() => {
if (!isReadOnlyMode) {
setReadOnlyContent(null);
setReadOnlyLoading(false);
setReadOnlyError(null);
return;
}
setReadOnlyLoading(true);
setReadOnlyError(null);
getScriptContent(script.workspace_id, script.script_id)
.then((data) => {
if (data.script_type === 'notebook') {
// Notebook 转为 JSON 字符串显示
setReadOnlyContent(JSON.stringify(data.content, null, 2));
} else {
// Python 文件直接显示
setReadOnlyContent(data.content as string);
}
})
.catch((err) => {
setReadOnlyError(err instanceof Error ? err.message : '加载失败');
})
.finally(() => {
setReadOnlyLoading(false);
});
}, [isReadOnlyMode, script.script_id, script.workspace_id, script.script_type]);
return (
<>
<div className="tabbar">
<button
className="tabbar-scroll-btn tabbar-scroll-btn--left"
type="button"
aria-label="向左滚动"
onClick={() => scroll("left")}
>
<Icon name="chevron" size={16} />
</button>
<div className="tabbar-scroll-content" ref={tabbarRef}>
{openTabs.map((tab) => (
<div
key={tab.scriptId}
className={`editor-tab${tab.scriptId === script.script_id ? " editor-tab--active" : ""}`}
onClick={() => onSwitchTab(tab.scriptId)}
>
<span className={`file-icon file-icon--${tab.scriptType}`}>
<Icon name={tab.scriptType === "notebook" ? "notebook" : "python"} size={16} />
</span>
<span>{tab.scriptName}</span>
<button
type="button"
aria-label="关闭标签"
onClick={(event) => onClose(tab.scriptId, event)}
>
<Icon name="close" size={14} />
</button>
</div>
))}
<button
className="new-tab"
type="button"
onClick={onNewTab}
>
<Icon name="plus" size={17} />
</button>
</div>
<button
className="tabbar-scroll-btn tabbar-scroll-btn--right"
type="button"
aria-label="向右滚动"
onClick={() => scroll("right")}
>
<Icon name="chevron" size={16} />
</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">
{isPythonEditing ? (
<>
{showSaveButton && (
<button
type="button"
className={`editor-save-button${activePythonBuf?.saving ? " is-saving" : ""}`}
disabled={saveDisabled}
onClick={() => onSavePythonEditor(script.script_id)}
>
{activePythonBuf?.saving
? <><span className="button-spinner button-spinner--blue" /> 保存中</>
: "保存"}
</button>
)}
<button type="button" className="end-edit-button" onClick={() => onExitPythonEditor(script.script_id)}>结束编辑</button>
</>
) : 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 />
{
latestVersion
? `最新 ${latestVersion.version_label}`
: "工作副本已就绪"
}
</span>
</div>
</div>
{/* 只读模式:显示只读编辑器 */}
{isReadOnlyMode ? (
<div className="readonly-editor-container">
<div className="readonly-editor-banner">
<Icon name="lock" size={16} />
<span>此文件已被锁定,您当前处于只读模式</span>
</div>
{readOnlyLoading ? (
<div className="readonly-editor-loading">
<span className="button-spinner button-spinner--blue" />
<span>加载内容中...</span>
</div>
) : readOnlyError ? (
<div className="readonly-editor-error">
<Icon name="info" size={28} />
<strong>加载失败</strong>
<p>{readOnlyError}</p>
</div>
) : (
<Editor
height="calc(100% - 50px)"
language={script.script_type === 'notebook' ? 'json' : 'python'}
value={readOnlyContent ?? ''}
theme="vs"
options={{
readOnly: true,
domReadOnly: true,
minimap: { enabled: true },
lineNumbers: "on",
folding: true,
wordWrap: "on",
contextmenu: false,
automaticLayout: true,
}}
/>
)}
</div>
) : (
// 正常编辑模式:原有的 iframe + Monaco 预览逻辑
<div className={`editor-canvas ${sessionCache.size > 0 ? 'is-embedded' : ''}`} style={sessionCache.size > 0 ? { position: 'relative', minHeight: '0', height: '100%' } : undefined}>
{/* 多 PythonEditor 实例:每个有 buffer 的 python tab 都挂载,仅 active 可见 */}
{pythonTabBuffers.map((tab) => {
const tabScript = scripts.find((s) => s.script_id === tab.scriptId);
if (!tabScript) return null;
const buf = pythonEditorBuffers[tab.scriptId];
const isActive = tab.scriptId === script.script_id;
return (
<section
key={tab.scriptId}
className="python-editor-mount"
style={{
position: isActive ? "relative" : "absolute",
visibility: isActive ? "visible" : "hidden",
pointerEvents: isActive ? "auto" : "none",
width: "100%",
height: "100%",
}}
>
<PythonEditor
scriptId={tab.scriptId}
script={tabScript}
initialContent={buf.initialContent ?? ""}
loading={buf.initialContent === null && !buf.loadError}
loadError={buf.loadError}
saving={buf.saving}
dirty={buf.dirty}
onChange={onSetPythonEditorContent}
onSave={onSavePythonEditor}
onEndEditing={onExitPythonEditor}
onCloseTab={onClosePythonTab}
/>
</section>
);
})}
{/* 渲染所有缓存的 iframe */}
{Array.from(sessionCache.entries()).map(([scriptId, cached]) => {
const isActive = scriptId === script.script_id;
return (
<section
key={scriptId}
className="embedded-jupyter"
style={{
position: isActive ? 'relative' : 'absolute',
visibility: isActive ? 'visible' : 'hidden',
pointerEvents: isActive ? 'auto' : 'none',
width: '100%',
height: '100%',
overflow: 'hidden'
}}
>
<div className="embedded-jupyter__status">
<span>
<i />
Workspace Jupyter Server
</span>
<span>
{cached.session.session_status === "active" ? "Notebook Session · Python 3 Kernel" : "Jupyter 文本编辑器"}
</span>
<code title={cached.session.runtime_id}>
Runtime {cached.session.runtime_id.slice(-8)}
</code>
</div>
<iframe
src={cached.jupyterUrl}
title={`${scriptId} Jupyter 编辑器`}
allow="clipboard-read; clipboard-write"
sandbox="allow-same-origin allow-scripts allow-forms allow-downloads allow-modals"
onLoad={(event) => confineJupyterFrame(event.currentTarget)}
/>
</section>
);
})}
{/* 当前脚本没有缓存时的状态显示 */}
{!sessionCache.has(script.script_id) ? (
isNotebook ? (
<section
className={`editor-opening-state${openError ? " has-error" : ""}`}
aria-live="polite"
style={{ display: 'block' }}
>
<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>
) : isPython && !activePythonBuf ? (
<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
workspaceId={script.workspace_id}
filePath={script.jupyter_path}
/>
</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>
) : null):null}
</div>
)}
</>
);
}
interface PythonPreviewProps {
workspaceId: string;
filePath: string;
}
export function PythonPreview({
workspaceId,
filePath,
}: PythonPreviewProps) {
const previewKey = `${workspaceId}::${filePath}`;
const storeKey = useScriptWorkspaceStore((s) => s.previewKey);
const previewCode = useScriptWorkspaceStore((s) => s.previewCode);
const previewCodeSize = useScriptWorkspaceStore((s) => s.previewCodeSize);
const previewLoading = useScriptWorkspaceStore((s) => s.previewLoading);
const previewError = useScriptWorkspaceStore((s) => s.previewError);
const loadPreview = useScriptWorkspaceStore((s) => s.loadPreview);
useLayoutEffect(() => {
void loadPreview(workspaceId, filePath);
}, [previewKey]);
if (storeKey !== previewKey) {
return <div>Loading...</div>;
}
if (previewLoading) {
return <div>Loading...</div>;
}
if (previewError) {
return <div>Failed to load: {previewError}</div>;
}
return (
<Editor
height="550px"
language="python"
value={previewCode ?? ""}
theme="vs"
options={{
readOnly: true,
domReadOnly: true,
minimap: {
enabled: previewCodeSize !== null && previewCodeSize > 1000,
},
lineNumbers: "off",
folding: true,
wordWrap: "on",
contextmenu: false,
dragAndDrop: false,
automaticLayout: true,
renderLineHighlight: "none",
}}
/>
);
}