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 预览逻辑
@@ -57,6 +57,7 @@ export default function ScriptsPage() {
const toggleScriptLock = useScriptWorkspaceStore((s) => s.toggleScriptLock);
const openPublishDialog = useScriptWorkspaceStore((s) => s.openPublishDialog);
const submitPublish = useScriptWorkspaceStore((s) => s.submitPublish);
const refreshReadOnlyContent = useScriptWorkspaceStore((s) => s.refreshReadOnlyContent);
// ui store
const pushToast = useUiStore((s) => s.pushToast);
@@ -88,8 +89,10 @@ export default function ScriptsPage() {
useEffect(() => {
reset();
// 刷新前递增版本号,触发已打开标签页的只读内容刷新
refreshReadOnlyContent();
void load();
}, [reset, load, workspaceId]);
}, [reset, load, workspaceId, refreshReadOnlyContent]);
// 2) 选中文件变更时加载最新版本
useEffect(() => {
@@ -83,6 +83,9 @@ type State = {
loadingChildrenPaths: Set<string>;
loadedChildPaths: Set<string>;
// 只读内容刷新版本号(用于触发已打开标签页的内容刷新)
readOnlyRefreshVersion: number;
// actions
setApiOnline: (online: boolean) => void;
setKeyword: (keyword: string) => void;
@@ -111,6 +114,7 @@ type State = {
toggleScriptLock: (script: ScriptItem) => Promise<void>;
openPublishDialog: (script: ScriptItem) => void;
submitPublish: (releaseNote: string, visibility: Visibility) => Promise<void>;
refreshReadOnlyContent: () => void; // 刷新只读内容
// 生命周期 tick(由 layout 层 useEditSessionLifecycle 周期性调用)
tickHeartbeats: () => Promise<void>;
@@ -175,8 +179,11 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
loadingChildrenPaths: new Set<string>(),
loadedChildPaths: new Set<string>(),
readOnlyRefreshVersion: 0,
setApiOnline: (online) => set({ apiOnline: online }),
setKeyword: (keyword) => set({ keyword }),
refreshReadOnlyContent: () => set((state) => ({ readOnlyRefreshVersion: state.readOnlyRefreshVersion + 1 })),
reset: () => {
if (_previewController) {
+35 -5
View File
@@ -424,11 +424,41 @@ export async function getScriptContent(
content: string | object;
format: string;
}> {
return apiRequest(
`/api/v1/scripts/${scriptId}/content`,
{},
workspaceId,
).then((envelope) => (envelope as Record<string, any>).data);
const response = await fetch(
`/api/v1/scripts/${scriptId}/content?workspace_id=${encodeURIComponent(workspaceId)}`,
{
credentials: "same-origin",
headers: {
"X-Request-ID": createUuid().replaceAll("-", ""),
},
},
);
if (response.status === 401 && typeof window !== "undefined") {
const here = window.location.pathname;
if (here !== "/login") {
window.location.assign("/login");
}
throw new ApiRequestError("未登录或登录已过期", 401);
}
const payload = await response.json();
if (!response.ok) {
const error = payload as ApiErrorEnvelope;
const detailMessage = typeof error.detail === "string"
? error.detail
: error.detail?.message;
throw new ApiRequestError(
detailMessage ?? `请求失败(HTTP ${response.status}`,
response.status,
);
}
const data = (payload as { data: { script_id: string; script_type: ScriptType; content: string | object; format: string } }).data;
if (!data || !data.script_type) {
throw new ApiRequestError("响应数据格式错误", response.status);
}
return data;
}
export async function deleteScript(
+148 -2
View File
@@ -2072,6 +2072,7 @@ button {
flex-direction: column;
height: 100%;
background: #f8f9fa;
overflow: hidden;
}
.readonly-editor-banner {
@@ -2085,6 +2086,13 @@ button {
color: #856404;
font-size: 13px;
font-weight: 500;
flex-shrink: 0;
}
.readonly-editor-content {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.readonly-editor-banner svg {
@@ -2096,7 +2104,7 @@ button {
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
min-height: 200px;
gap: 16px;
color: #666;
font-size: 14px;
@@ -2107,11 +2115,12 @@ button {
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
min-height: 200px;
gap: 12px;
color: #dc3545;
font-size: 14px;
text-align: center;
padding: 20px;
}
.readonly-editor-error strong {
@@ -2122,3 +2131,140 @@ button {
margin: 0;
color: #666;
}
/* ============ Notebook 只读视图样式 ============ */
.notebook-read-only {
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
background: #fff;
padding: 0;
}
.notebook-cell {
display: flex;
width: 100%;
min-height: 44px; /* 保证空单元格的最小高度 */
padding: 8px 0;
flex-shrink: 0; /* 防止单元格被压缩 */
}
.notebook-cell--markdown {
padding: 14px 24px;
}
.notebook-cell--code {
padding-left: 0;
padding-right: 0;
}
.notebook-cell__prompt {
flex: 0 0 auto;
min-width: 90px;
padding-right: 16px;
padding-left: 16px;
color: #8b949e;
font-family: "Consolas", "SFMono-Regular", monospace;
font-size: 11px;
text-align: right;
user-select: none;
line-height: 1.6;
}
.notebook-cell__content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.notebook-cell__input {
background: #f6f8fa;
border-radius: 1px;
padding: 6px;
margin-right: 14px;
border: #ccc 1px solid;
min-height: 24px; /* 保证输入区域的最小高度 */
}
.notebook-cell__input--empty {
min-height: 24px;
padding: 0 14px;
display: flex;
align-items: center;
}
.notebook-cell__input pre,
.notebook-cell__input code {
margin: 0;
color: #24292f;
font-family: "Consolas", "SFMono-Regular", monospace;
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
background: transparent;
}
.notebook-cell__outputs {
margin-top: 8px;
}
.notebook-output {
padding-left: 6px;
color: #24292f;
font-size: 13px;
line-height: 1.5;
}
.notebook-output pre {
margin: 0;
font-family: "Consolas", "SFMono-Regular", monospace;
font-size: 13px;
white-space: pre-wrap;
word-break: break-word;
}
.notebook-output--stdout pre {
color: #24292f;
}
.notebook-cell__content h1,
.notebook-cell__content h2,
.notebook-cell__content h3,
.notebook-cell__content p,
.notebook-cell__content li {
margin: 6px 0;
color: #24292f;
font-size: 14px;
line-height: 1.6;
}
.notebook-cell__content h1 {
font-size: 24px;
color: #1f6feb;
font-weight: 600;
}
.notebook-cell__content h2 {
font-size: 20px;
color: #1f6feb;
font-weight: 600;
}
.notebook-cell__content h3 {
font-size: 16px;
color: #24292f;
font-weight: 600;
}
.notebook-cell__content ul,
.notebook-cell__content ol {
padding-left: 24px;
}
.notebook-cell__content li {
margin-left: 0;
list-style-position: outside;
}