775 lines
27 KiB
TypeScript
775 lines
27 KiB
TypeScript
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 {type PythonEditorBuffer, useScriptWorkspaceStore} from "./state/scriptWorkspaceStore";
|
||
import { useAuth } from "../../context/AuthContext";
|
||
import { getScriptContent } from "../../services/api";
|
||
import {PythonEditor} from "~/features/platform/PythonEditor";
|
||
|
||
// 模块级变量存储刷新版本号,用于检测只读内容刷新
|
||
let _lastRefreshVersion = 0;
|
||
|
||
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)}` : "—";
|
||
}
|
||
|
||
// 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="notebook-read-only">
|
||
{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="notebook-cell notebook-cell--markdown">
|
||
<div className="notebook-cell__content">
|
||
{renderMarkdown(sourceText)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (cell.cell_type === "code") {
|
||
const hasSource = sourceText.trim().length > 0;
|
||
return (
|
||
<div key={index} className="notebook-cell notebook-cell--code">
|
||
<div className="notebook-cell__prompt">
|
||
In [{cell.execution_count ?? " "}]:
|
||
</div>
|
||
<div className="notebook-cell__content">
|
||
<div className={`notebook-cell__input${!hasSource ? ' notebook-cell__input--empty' : ''}`}>
|
||
<pre><code>{sourceText}</code></pre>
|
||
</div>
|
||
{cell.outputs && cell.outputs.length > 0 && (
|
||
<div className="notebook-cell__outputs">
|
||
{cell.outputs.map((output, outputIndex) => {
|
||
// 处理 stream 类型的输出
|
||
if (output.output_type === "stream" && output.text) {
|
||
const text = Array.isArray(output.text)
|
||
? output.text.join("")
|
||
: output.text;
|
||
return (
|
||
<div key={outputIndex} className={`notebook-output notebook-output--${output.name}`}>
|
||
<pre>{text}</pre>
|
||
</div>
|
||
);
|
||
}
|
||
// 处理 execute_result / display_data 类型
|
||
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="notebook-output">
|
||
<pre>{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(".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) => {
|
||
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]);
|
||
|
||
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>
|
||
<div className="readonly-editor-content">
|
||
{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>
|
||
) : script.script_type === 'notebook' && readOnlyContent && typeof readOnlyContent === 'object' ? (
|
||
// Notebook 使用单元格渲染
|
||
<NotebookViewer content={readOnlyContent} />
|
||
) : (
|
||
// Python 文件使用 Monaco Editor 显示
|
||
<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>
|
||
) : (
|
||
// 正常编辑模式:原有的 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 {shortHash(script.content_hash)}</span>
|
||
<span>
|
||
稳定版本
|
||
{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",
|
||
}}
|
||
/>
|
||
);
|
||
}
|