diff --git a/frontend/app/features/platform/ScriptWorkspace.tsx b/frontend/app/features/platform/ScriptWorkspace.tsx index a16200d..4e41adf 100644 --- a/frontend/app/features/platform/ScriptWorkspace.tsx +++ b/frontend/app/features/platform/ScriptWorkspace.tsx @@ -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; + }>; +}; + +// Notebook Viewer 组件 +function NotebookViewer({ content }: { content: object }) { + const notebook = content as { + cells?: NotebookCell[]; + metadata?: Record; + nbformat?: number; + nbformat_minor?: number; + }; + + const cells = notebook.cells ?? []; + + return ( +
+ {cells.map((cell, index) => { + const sourceText = Array.isArray(cell.source) + ? cell.source.join("") + : (cell.source ?? ""); + + if (cell.cell_type === "markdown") { + return ( +
+
+ {renderMarkdown(sourceText)} +
+
+ ); + } + + if (cell.cell_type === "code") { + const hasSource = sourceText.trim().length > 0; + return ( +
+
+ In [{cell.execution_count ?? " "}]: +
+
+
+
{sourceText}
+
+ {cell.outputs && cell.outputs.length > 0 && ( +
+ {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 ( +
+
{text}
+
+ ); + } + // 处理 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 ( +
+
{textStr}
+
+ ); + } + } + return null; + })} +
+ )} +
+
+ ); + } + + return null; + })} +
+ ); +} + +// 简单的 markdown 渲染函数 +function renderMarkdown(text: string) { + const lines = text.split("\n"); + return lines.map((line, i) => { + if (line.startsWith("# ")) { + return

{line.slice(2)}

; + } + if (line.startsWith("## ")) { + return

{line.slice(3)}

; + } + if (line.startsWith("### ")) { + return

{line.slice(4)}

; + } + if (line.startsWith("- ") || line.startsWith("* ")) { + return
  • {line.slice(2)}
  • ; + } + if (line.match(/^\d+\. /)) { + return
  • {line.replace(/^\d+\. /, "")}
  • ; + } + if (line.trim() === "") { + return
    ; + } + return

    {line}

    ; + }); +} + +function confineJupyterFrame(frame: HTMLIFrameElement, readOnly: boolean = false): void { try { const document = frame.contentDocument; if (!document?.documentElement) return; const keepInside = (): void => { + // 隐藏 "Open in..." 按钮 document.querySelectorAll("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("a[target]").forEach((link) => { if (["_blank", "_top", "_parent"].includes(link.target)) { link.target = "_self"; @@ -141,6 +270,8 @@ export function ScriptWorkspace({ const tabbarRef = useRef(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(null); + // 只读模式状态(用于 Python 文件和 notebook 的 JSON 内容) + const [readOnlyContent, setReadOnlyContent] = useState(null); const [readOnlyLoading, setReadOnlyLoading] = useState(false); const [readOnlyError, setReadOnlyError] = useState(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({ 此文件已被锁定,您当前处于只读模式 - {readOnlyLoading ? ( -
    - - 加载内容中... -
    - ) : readOnlyError ? ( -
    - - 加载失败 -

    {readOnlyError}

    -
    - ) : ( - - )} +
    + {readOnlyLoading ? ( +
    + + 加载内容中... +
    + ) : readOnlyError ? ( +
    + + 加载失败 +

    {readOnlyError}

    +
    + ) : script.script_type === 'notebook' && readOnlyContent && typeof readOnlyContent === 'object' ? ( + // Notebook 使用单元格渲染 + + ) : ( + // Python 文件使用 Monaco Editor 显示 + + )} +
    ) : ( // 正常编辑模式:原有的 iframe + Monaco 预览逻辑 diff --git a/frontend/app/features/platform/ScriptsPage.tsx b/frontend/app/features/platform/ScriptsPage.tsx index 152b727..405229f 100644 --- a/frontend/app/features/platform/ScriptsPage.tsx +++ b/frontend/app/features/platform/ScriptsPage.tsx @@ -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(() => { diff --git a/frontend/app/features/platform/state/scriptWorkspaceStore.ts b/frontend/app/features/platform/state/scriptWorkspaceStore.ts index 6520189..7a62e70 100644 --- a/frontend/app/features/platform/state/scriptWorkspaceStore.ts +++ b/frontend/app/features/platform/state/scriptWorkspaceStore.ts @@ -83,6 +83,9 @@ type State = { loadingChildrenPaths: Set; loadedChildPaths: Set; + // 只读内容刷新版本号(用于触发已打开标签页的内容刷新) + readOnlyRefreshVersion: number; + // actions setApiOnline: (online: boolean) => void; setKeyword: (keyword: string) => void; @@ -111,6 +114,7 @@ type State = { toggleScriptLock: (script: ScriptItem) => Promise; openPublishDialog: (script: ScriptItem) => void; submitPublish: (releaseNote: string, visibility: Visibility) => Promise; + refreshReadOnlyContent: () => void; // 刷新只读内容 // 生命周期 tick(由 layout 层 useEditSessionLifecycle 周期性调用) tickHeartbeats: () => Promise; @@ -175,8 +179,11 @@ export const useScriptWorkspaceStore = create((set, get) => { loadingChildrenPaths: new Set(), loadedChildPaths: new Set(), + readOnlyRefreshVersion: 0, + setApiOnline: (online) => set({ apiOnline: online }), setKeyword: (keyword) => set({ keyword }), + refreshReadOnlyContent: () => set((state) => ({ readOnlyRefreshVersion: state.readOnlyRefreshVersion + 1 })), reset: () => { if (_previewController) { diff --git a/frontend/app/services/api.ts b/frontend/app/services/api.ts index 3032c89..1e71ffd 100644 --- a/frontend/app/services/api.ts +++ b/frontend/app/services/api.ts @@ -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).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( diff --git a/frontend/app/styles/platform.css b/frontend/app/styles/platform.css index f3eae1f..f70bca0 100644 --- a/frontend/app/styles/platform.css +++ b/frontend/app/styles/platform.css @@ -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; +}