Files
model-platform/frontend/app/features/platform/ScriptWorkspace.tsx
T
2026-08-27 15:43:29 +08:00

909 lines
35 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 {
BookOpen,
Check,
ChevronRight,
ExternalLink,
FileCode,
Info,
Lock,
Plus,
RefreshCw,
X,
} from "lucide-react";
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 {type PythonEditorBuffer, useScriptWorkspaceStore} from "./state/scriptWorkspaceStore";
import { useAuth } from "../../context/AuthContext";
import { getScriptContent } from "../../services/api";
import {PythonEditor} from "~/features/platform/PythonEditor";
function fileIconTone(type: ScriptType | "data") {
if (type === "notebook") return "text-[#e15e50] bg-[#fff0ed]";
if (type === "data") return "text-[#5a8f6a] bg-[#eef8f1]";
return "text-[#2e73c6] bg-[#eaf3ff]";
}
const toolbarBtnClass =
"h-[31px] cursor-pointer rounded-[5px] border border-[#d9e3ec] bg-white px-[11px] text-[11px] text-[#52708d] hover:border-[#9dbbd8] hover:bg-[#f7fbff] disabled:cursor-wait disabled:opacity-70";
const endEditBtnClass =
"h-[31px] cursor-pointer rounded-[5px] border border-[#e1b8b8] bg-[#fff8f8] px-[11px] text-[11px] text-[#a34c4c] hover:border-[#9dbbd8] hover:bg-[#f7fbff] disabled:cursor-wait disabled:opacity-70";
const releaseBtnClass =
"h-[31px] cursor-pointer rounded-[5px] border border-[#e7c689] bg-[#fffbf3] px-[11px] text-[11px] text-[#9b6a18] hover:border-[#9dbbd8] hover:bg-[#f7fbff]";
const openEditorBtnClass =
"inline-flex h-[37px] cursor-pointer items-center justify-center gap-[7px] rounded-md border border-[#b9d4ef] bg-[#f4f9ff] px-3.5 text-[11px] font-[650] text-[#176cc0] hover:border-[#7eafe0] hover:bg-[#eaf4ff] disabled:cursor-wait disabled:opacity-70";
const openEditorEditingBtnClass =
"inline-flex h-[37px] cursor-pointer items-center justify-center gap-[7px] rounded-md border border-[#69b79a] bg-[#effaf5] px-3.5 text-[11px] font-[650] text-[#137a55] hover:border-[#7eafe0] hover:bg-[#eaf4ff] disabled:cursor-wait disabled:opacity-70";
const saveBtnClass =
"inline-flex h-[31px] cursor-pointer items-center justify-center gap-1.5 rounded-[5px] border border-[#b8dec1] bg-[#f1faf3] px-[11px] text-[11px] font-semibold text-[#1f6d3a] disabled:cursor-not-allowed disabled:opacity-50";
// 模块级变量存储刷新版本号,用于检测只读内容刷新
let _lastRefreshVersion = 0;
type Notice = {
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: Notice) => 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)}` : "—";
}
// Notebook 单元格类型
type NotebookCell = {
cell_type: "code" | "markdown" | "raw";
source: string[] | string;
execution_count?: number | null;
outputs?: Array<{
name?: "stdout" | "stderr" | string;
output_type: "stream" | "execute_result" | "display_data" | "error";
text?: string[] | string;
data?: Record<string, unknown>;
}>;
};
// Notebook Viewer 组件
function NotebookViewer({ content }: { content: object }) {
const notebook = content as {
cells?: NotebookCell[];
metadata?: Record<string, unknown>;
nbformat?: number;
nbformat_minor?: number;
};
const cells = notebook.cells ?? [];
return (
<div className="flex h-full flex-col overflow-y-auto bg-white">
{cells.map((cell, index) => {
const sourceText = Array.isArray(cell.source)
? cell.source.join("")
: (cell.source ?? "");
if (cell.cell_type === "markdown") {
return (
<div key={index} className="flex w-full shrink-0 px-6 py-1">
<div className="notebook-md flex min-w-0 flex-1 flex-col">
{renderMarkdown(sourceText)}
</div>
</div>
);
}
if (cell.cell_type === "code") {
const hasSource = sourceText.trim().length > 0;
return (
<div key={index} className="flex w-full min-h-11 shrink-0 py-2">
<div className="w-[90px] shrink-0 select-none px-4 text-right font-mono text-[11px] leading-[1.6] text-[#8b949e]">
In [{cell.execution_count ?? " "}]:
</div>
<div className="flex min-w-0 flex-1 flex-col">
<div
className={`mr-3.5 min-h-6 rounded-[1px] border border-[#ccc] bg-[#f6f8fa] ${
hasSource ? "p-1.5" : "flex items-center px-3.5"
}`}
>
<pre className="m-0 bg-transparent font-mono text-[13px] leading-normal break-words whitespace-pre-wrap text-[#24292f]">
<code className="bg-transparent font-mono text-[13px]">{sourceText}</code>
</pre>
</div>
{cell.outputs && cell.outputs.length > 0 && (
<div className="mt-2">
{cell.outputs.map((output, outputIndex) => {
if (output.output_type === "stream" && output.text) {
const text = Array.isArray(output.text)
? output.text.join("")
: output.text;
return (
<div
key={outputIndex}
className="px-1.5 text-[13px] leading-normal text-[#24292f]"
>
<pre className="m-0 font-mono text-[13px] break-words whitespace-pre-wrap">
{text}
</pre>
</div>
);
}
if (output.output_type === "execute_result" || output.output_type === "display_data") {
const text = output.data?.["text/plain"];
if (text) {
const textStr = Array.isArray(text) ? text.join("") : String(text);
return (
<div
key={outputIndex}
className="px-1.5 text-[13px] leading-normal text-[#24292f]"
>
<pre className="m-0 font-mono text-[13px] break-words whitespace-pre-wrap">
{textStr}
</pre>
</div>
);
}
}
return null;
})}
</div>
)}
</div>
</div>
);
}
return null;
})}
</div>
);
}
// 简单的 markdown 渲染函数
function renderMarkdown(text: string) {
const lines = text.split("\n");
return lines.map((line, i) => {
if (line.startsWith("# ")) {
return <h1 key={i}>{line.slice(2)}</h1>;
}
if (line.startsWith("## ")) {
return <h2 key={i}>{line.slice(3)}</h2>;
}
if (line.startsWith("### ")) {
return <h3 key={i}>{line.slice(4)}</h3>;
}
if (line.startsWith("- ") || line.startsWith("* ")) {
return <li key={i}>{line.slice(2)}</li>;
}
if (line.match(/^\d+\. /)) {
return <li key={i}>{line.replace(/^\d+\. /, "")}</li>;
}
if (line.trim() === "") {
return <br key={i} />;
}
return <p key={i}>{line}</p>;
});
}
function confineJupyterFrame(frame: HTMLIFrameElement, readOnly: boolean = false): void {
try {
const document = frame.contentDocument;
if (!document?.documentElement) return;
const keepInside = (): void => {
// 隐藏 "Open in..." 按钮
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");
}
// 只读模式下隐藏保存/编辑相关的按钮
if (readOnly) {
if (/\bsave\b|\bedit\b|\brun\b/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";
// 订阅 store 的刷新版本号,用于触发只读内容刷新
const readOnlyRefreshVersion = useScriptWorkspaceStore((s) => s.readOnlyRefreshVersion);
// 判断当前选中的文件是否正在编辑(需要同时检查 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;
// 只读模式状态(用于 Python 文件和 notebook 的 JSON 内容)
const [readOnlyContent, setReadOnlyContent] = useState<string | object | 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('[data-active="true"]') 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) => {
setReadOnlyContent(data.content);
})
.catch((err) => {
setReadOnlyError(err instanceof Error ? err.message : '加载失败');
})
.finally(() => {
setReadOnlyLoading(false);
});
}, [isReadOnlyMode, script.script_id, script.workspace_id]);
// 监听只读内容刷新
useEffect(() => {
if (readOnlyRefreshVersion > _lastRefreshVersion && isReadOnlyMode) {
_lastRefreshVersion = readOnlyRefreshVersion;
// 重新加载只读内容
setReadOnlyLoading(true);
setReadOnlyError(null);
getScriptContent(script.workspace_id, script.script_id)
.then((data) => {
setReadOnlyContent(data.content);
})
.catch((err) => {
setReadOnlyError(err instanceof Error ? err.message : '加载失败');
})
.finally(() => {
setReadOnlyLoading(false);
});
}
}, [script.script_id, script.workspace_id, isReadOnlyMode, readOnlyRefreshVersion]);
const ScriptTypeIcon = scriptIcon(script);
return (
<>
<div className="relative flex h-[43px] min-h-[43px] items-stretch overflow-hidden border-b border-[#e5ebf1] bg-[#f7f9fb]">
<button
className="z-[1] grid w-7 shrink-0 rotate-180 cursor-pointer place-items-center border-0 border-r border-[#e4eaf0] bg-[#f7f9fb] text-[#6b7c8f] transition-colors duration-150 hover:bg-[#edf1f5] hover:text-[#3d4c5c]"
type="button"
aria-label="向左滚动"
onClick={() => scroll("left")}
>
<ChevronRight size={16} />
</button>
<div
className="tabbar-scroll flex flex-1 overflow-x-auto overflow-y-hidden scroll-smooth"
ref={tabbarRef}
>
{openTabs.map((tab) => {
const isActive = tab.scriptId === script.script_id;
return (
<div
key={tab.scriptId}
data-active={isActive ? "true" : undefined}
className={`relative flex max-w-[290px] min-w-[100px] shrink-0 cursor-pointer items-center gap-1.5 border-r border-[#e4eaf0] px-1.5 text-xs text-[#4b5c70] ${
isActive
? "bg-white before:absolute before:inset-x-0 before:top-0 before:h-0.5 before:bg-[#2381e5] before:content-['']"
: "bg-white hover:bg-[#f0f4f8]"
}`}
onClick={() => onSwitchTab(tab.scriptId)}
>
<span
className={`grid size-[25px] shrink-0 place-items-center rounded-[5px] ${fileIconTone(tab.scriptType)}`}
>
{tab.scriptType === "notebook"
? <BookOpen size={16} />
: <FileCode size={16} />}
</span>
<span className="min-w-0 flex-1 truncate">{tab.scriptName}</span>
<button
type="button"
className="grid size-[25px] place-items-center border-0 bg-transparent text-[#8e9baa] hover:bg-[#edf1f5] hover:text-[#43566c]"
aria-label="关闭标签"
onClick={(event) => onClose(tab.scriptId, event)}
>
<X size={14} />
</button>
</div>
);
})}
<button
className="ml-[5px] grid size-[25px] shrink-0 cursor-pointer place-items-center self-center rounded border-0 bg-transparent text-[#8e9baa] hover:bg-[#edf1f5] hover:text-[#43566c]"
type="button"
onClick={onNewTab}
>
<Plus size={17} />
</button>
</div>
<button
className="z-[1] grid w-7 shrink-0 cursor-pointer place-items-center border-0 border-l border-[#e4eaf0] bg-[#f7f9fb] text-[#6b7c8f] transition-colors duration-150 hover:bg-[#edf1f5] hover:text-[#3d4c5c]"
type="button"
aria-label="向右滚动"
onClick={() => scroll("right")}
>
<ChevronRight size={16} />
</button>
</div>
<div className="flex min-h-[53px] items-center justify-between border-b border-[#e6ebf0] bg-white px-3.5">
<div className="flex min-w-0 items-center gap-[7px] text-[11px] text-[#8794a3]">
<span
className={`grid size-[25px] shrink-0 place-items-center rounded-[5px] ${fileIconTone(script.script_type)}`}
>
<ScriptTypeIcon size={17} />
</span>
<span>工作副本</span>
<ChevronRight size={13} />
<strong className="max-w-[300px] truncate text-[#45576b]">
{script.script_name}
</strong>
</div>
{!isReadOnlyMode && (
<div className="flex items-center gap-[7px]">
{isPythonEditing ? (
<>
{showSaveButton && (
<button
type="button"
className={`${saveBtnClass}${activePythonBuf?.saving ? " opacity-85" : ""}`}
disabled={saveDisabled}
onClick={() => onSavePythonEditor(script.script_id)}
>
{activePythonBuf?.saving
? <><span className="button-spinner button-spinner--blue size-3" /> 保存中</>
: "保存"}
</button>
)}
<button
type="button"
className={endEditBtnClass}
onClick={() => onExitPythonEditor(script.script_id)}
>
结束编辑
</button>
</>
) : isEditing ? (
<button
className={endEditBtnClass}
type="button"
disabled={editBusy}
onClick={onEndEditing}
>
{editBusy ? "正在释放…" : "结束编辑"}
</button>
) : (
<button
type="button"
className={toolbarBtnClass}
onClick={() => onInfo({
tone: "info",
message: "Jupyter 中保存后会直接写入 Workspace 工作副本",
})}
>
保存说明
</button>
)}
<button
className={releaseBtnClass}
type="button"
onClick={onPublish}
>
发布稳定版
</button>
<span
className={`inline-flex h-[29px] items-center gap-1.5 rounded-[15px] px-2.5 text-[10px] ${
isEditing
? "bg-[#e9f5fd] text-[#126b9d]"
: "bg-[#eaf9f3] text-[#16845c]"
}`}
>
<span
className={`size-1.5 rounded-full ${
isEditing
? "stage-dot-editing bg-[#2495d3]"
: "bg-[#20bc7f]"
}`}
/>
{
latestVersion
? `最新 ${latestVersion.version_label}`
: "工作副本已就绪"
}
</span>
</div>
)}
</div>
{/* 本地编辑锁提示——只在非 Python 编辑时显示,
因为 Python 编辑走 pythonEditorBuffers,不走文件锁。
这个锁只在本浏览器当前 tab 内有效,不能阻止隐身模式 / 其它浏览器同时编辑。 */}
{isEditing && (
<div className="flex shrink-0 items-center justify-center gap-2 border-b border-[#c9def9] bg-[#e7f1ff] px-4 py-1.5 text-xs font-medium text-[#1f4e8a]">
<Info size={14} />
<span>
本地编辑锁——关闭标签页、刷新页面或换浏览器后失效,不会阻止他人同时编辑。
</span>
</div>
)}
{/* 编辑器画布区域 - 始终渲染,保证 iframe 不重新加载 */}
<div className="relative h-full w-full overflow-hidden">
{/* 只读模式内容 - 用 CSS 控制显示/隐藏 */}
<div style={{ display: isReadOnlyMode ? 'block' : 'none', height: '100%' }}>
<div className="flex h-full flex-col overflow-hidden bg-[#f8f9fa]">
<div className="mb-1.5 flex shrink-0 items-center justify-center gap-2 border-b border-[#e0e0e0] bg-[#fff3cd] px-4 py-2 text-[13px] font-medium text-[#856404]">
<Lock size={16} />
<span>此文件已被锁定,您当前处于只读模式</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
{readOnlyLoading ? (
<div className="flex min-h-[200px] flex-col items-center justify-center gap-4 text-sm text-[#666]">
<span className="button-spinner button-spinner--blue" />
<span>加载内容中...</span>
</div>
) : readOnlyError ? (
<div className="flex min-h-[200px] flex-col items-center justify-center gap-3 p-5 text-center text-sm text-[#dc3545]">
<Info size={28} />
<strong className="text-base">加载失败</strong>
<p className="m-0 text-[#666]">{readOnlyError}</p>
</div>
) : script.script_type === 'notebook' && readOnlyContent && typeof readOnlyContent === 'object' ? (
<NotebookViewer content={readOnlyContent} />
) : (
<Editor
height="100%"
language="python"
value={typeof readOnlyContent === 'string' ? readOnlyContent : ''}
theme="vs"
options={{
readOnly: true,
domReadOnly: true,
minimap: { enabled: true },
lineNumbers: "on",
folding: true,
wordWrap: "on",
contextmenu: false,
automaticLayout: true,
}}
/>
)}
</div>
</div>
</div>
{/* 正常编辑模式 - 始终渲染,用 CSS 控制显示/隐藏 */}
<div
className={`relative min-h-0 flex-1 ${
sessionCache.size > 0
? "overflow-hidden bg-white"
: "editor-canvas-shell overflow-auto"
}`}
style={{ display: isReadOnlyMode ? 'none' : 'block', height: '100%' }}
>
{/* 多 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="flex h-full min-h-0 w-full flex-col bg-white"
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="flex h-full min-h-0 w-full flex-col overflow-hidden bg-white"
style={{
position: isActive ? 'relative' : 'absolute',
visibility: isActive ? 'visible' : 'hidden',
pointerEvents: isActive ? 'auto' : 'none',
}}
>
<div className="flex min-h-[34px] items-center gap-[18px] border-b border-[#dfe6ec] bg-[#f8fafc] px-3.5 text-[9px] text-[#718195]">
<span className="inline-flex items-center gap-1.5 font-[650] text-[#197b59]">
<i className="size-[7px] rounded-full bg-[#20b77d] shadow-[0_0_0_3px_rgb(32_183_125/12%)]" />
Workspace Jupyter Server
</span>
<span className="inline-flex items-center gap-1.5">
{cached.session.session_status === "active" ? "Notebook Session · Python 3 Kernel" : "Jupyter 文本编辑器"}
</span>
<code className="ml-auto text-[9px] text-[#72859a]" title={cached.session.runtime_id}>
Runtime {cached.session.runtime_id.slice(-8)}
</code>
</div>
<iframe
className="min-h-0 w-full flex-1 border-0 bg-white"
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={`mx-auto my-16 flex w-[min(520px,calc(100%-48px))] min-h-[260px] flex-col items-center justify-center rounded-[10px] border p-9 text-center text-[#66788d] shadow-[0_12px_34px_rgb(31_64_98/7%)] ${
openError
? "border-[#f0cfcc] bg-white/95"
: "border-[#dce5ed] bg-white/95"
}`}
aria-live="polite"
>
<div
className={`mb-4 grid size-[54px] place-items-center rounded-full ${
openError
? "bg-[#fff1f0] text-[#c84f49]"
: "bg-[#edf6ff] text-[#247bc5]"
}`}
>
{openError
? <Info size={28} />
: <span className="button-spinner button-spinner--blue size-6 border-[3px]" />}
</div>
<strong className="text-base text-[#1d344d]">
{openError ? "Notebook 打开失败" : "正在打开 Notebook"}
</strong>
<p className="mt-[9px] max-w-[430px] text-xs leading-[1.7] text-[#7a8a9b] [overflow-wrap:anywhere]">
{openError
? openError
: "正在获取编辑锁并连接 Workspace Jupyter Server…"}
</p>
{openError && (
<button
className={`${openEditorBtnClass} mt-[18px]`}
type="button"
disabled={editBusy}
onClick={onOpenEditor}
>
{editBusy
? <span className="button-spinner button-spinner--blue" />
: <RefreshCw size={16} />}
{editBusy ? "正在重试…" : "重试打开"}
</button>
)}
</section>
) : isPython && !activePythonBuf ? (
<section className="mx-auto mb-[38px] mt-6 w-[min(1020px,calc(100%-48px))] max-xl:w-[calc(100%-30px)]">
<div className="flex items-start justify-between gap-5 rounded-t-[9px] border border-[#dce5ed] bg-white px-[26px] py-[23px]">
<div>
<span className="text-[9px] font-extrabold tracking-[0.13em] text-[#2b7fca]">
PYTHON SCRIPT
</span>
<h2 className="mb-1 mt-1.5 text-[22px] text-[#1b2d43]">
{script.script_name}
</h2>
<p className="m-0 font-mono text-[10px] text-[#8997a6]">
{script.relative_path}
</p>
</div>
<button
className={
isEditing
? openEditorEditingBtnClass
: openEditorBtnClass
}
type="button"
disabled={editBusy}
onClick={onOpenEditor}
>
{editBusy
? <span className="button-spinner button-spinner--blue" />
: <ExternalLink size={17} />}
{editBusy
? "正在准备编辑器…"
: isEditing
? "继续编辑"
: "打开编辑器"}
</button>
</div>
<div className="grid grid-cols-4 border-x border-b border-[#dce5ed] bg-[#f9fbfd]">
{[
["脚本类型", "Python"],
[
"可见范围",
script.visibility === "workspace"
? "Workspace"
: script.visibility === "public"
? "公开"
: "私有",
],
["文件大小", formatBytes(script.size_bytes)],
["最近更新", formatTime(script.updated_at)],
].map(([label, value], index) => (
<div
key={label}
className={`flex min-h-16 flex-col justify-center px-[21px] ${
index < 3 ? "border-r border-[#e4eaf0]" : ""
}`}
>
<span className="text-[10px] text-[#8a9aaa]">{label}</span>
<strong className="text-[13px] text-[#2a3f55]">{value}</strong>
</div>
))}
</div>
<div className="overflow-hidden rounded-b-[9px] border border-t-0 border-[#dce5ed] bg-white">
<div className="flex items-center justify-between border-b border-[#e8edf3] bg-[#f7f9fb] px-3.5 py-2 text-[11px] text-[#6b7c8f]">
<div className="flex items-center gap-1.5">
<span className="size-2.5 rounded-full bg-[#ef6b62]" />
<span className="size-2.5 rounded-full bg-[#e9b949]" />
<span className="size-2.5 rounded-full bg-[#49b97b]" />
</div>
<span>Python 预览</span>
<em className="text-[10px] not-italic text-[#97a4b3]">
{isEditing ? "Jupyter 中可编辑" : "平台只读预览"}
</em>
</div>
<PythonPreview
workspaceId={script.workspace_id}
filePath={script.jupyter_path}
/>
</div>
<div className="mt-3 flex flex-wrap items-center gap-x-5 gap-y-2 px-1 text-[11px] text-[#7a8b9c]">
<span className="inline-flex items-center gap-[5px] text-[#25875e]">
<Check size={15} />
</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>
</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",
}}
/>
);
}