fix:锁定文件只读

This commit is contained in:
xiaozhu
2026-08-13 10:26:01 +08:00
parent 7b10f08484
commit a2f515cf87
5 changed files with 389 additions and 52 deletions
@@ -10,10 +10,12 @@ 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";
import { useScriptWorkspaceStore } from "./state/scriptWorkspaceStore";
import { useAuth } from "../../context/AuthContext";
import { getScriptContent } from "../../services/api";
// 模块级变量存储刷新版本号,用于检测只读内容刷新
let _lastRefreshVersion = 0;
type ToastState = {
tone: "success" | "error" | "info";
@@ -73,11 +75,131 @@ function shortHash(value: string) {
return value ? `${value.slice(0, 8)}${value.slice(-6)}` : "—";
}
function confineJupyterFrame(frame: HTMLIFrameElement): void {
// 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") ?? ""} ${
@@ -86,8 +208,15 @@ function confineJupyterFrame(frame: HTMLIFrameElement): void {
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";
@@ -141,6 +270,8 @@ export function ScriptWorkspace({
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;
@@ -154,8 +285,8 @@ export function ScriptWorkspace({
);
const hasEmbeddedContent = sessionCache.size > 0 || pythonTabBuffers.length > 0;
// 只读模式状态
const [readOnlyContent, setReadOnlyContent] = useState<string | null>(null);
// 只读模式状态(用于 Python 文件和 notebook 的 JSON 内容)
const [readOnlyContent, setReadOnlyContent] = useState<string | object | null>(null);
const [readOnlyLoading, setReadOnlyLoading] = useState(false);
const [readOnlyError, setReadOnlyError] = useState<string | null>(null);
@@ -201,13 +332,7 @@ export function ScriptWorkspace({
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);
}
setReadOnlyContent(data.content);
})
.catch((err) => {
setReadOnlyError(err instanceof Error ? err.message : '加载失败');
@@ -215,7 +340,27 @@ export function ScriptWorkspace({
.finally(() => {
setReadOnlyLoading(false);
});
}, [isReadOnlyMode, script.script_id, script.workspace_id, script.script_type]);
}, [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 (
<>
@@ -337,35 +482,41 @@ export function ScriptWorkspace({
<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 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 预览逻辑