fix:锁定文件只读
This commit is contained in:
@@ -10,10 +10,12 @@ import type { MouseEvent as ReactMouseEvent } from "react";
|
|||||||
import Editor from "@monaco-editor/react";
|
import Editor from "@monaco-editor/react";
|
||||||
import { useRef, useLayoutEffect, useState, useEffect } from "react";
|
import { useRef, useLayoutEffect, useState, useEffect } from "react";
|
||||||
|
|
||||||
import { useScriptWorkspaceStore, type PythonEditorBuffer } from "./state/scriptWorkspaceStore";
|
import { useScriptWorkspaceStore } from "./state/scriptWorkspaceStore";
|
||||||
import { PythonEditor } from "./PythonEditor";
|
import { useAuth } from "../../context/AuthContext";
|
||||||
import { useAuth } from "~/context/AuthContext";
|
import { getScriptContent } from "../../services/api";
|
||||||
import { getScriptContent } from "~/services/api";
|
|
||||||
|
// 模块级变量存储刷新版本号,用于检测只读内容刷新
|
||||||
|
let _lastRefreshVersion = 0;
|
||||||
|
|
||||||
type ToastState = {
|
type ToastState = {
|
||||||
tone: "success" | "error" | "info";
|
tone: "success" | "error" | "info";
|
||||||
@@ -73,11 +75,131 @@ function shortHash(value: string) {
|
|||||||
return value ? `${value.slice(0, 8)}…${value.slice(-6)}` : "—";
|
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 {
|
try {
|
||||||
const document = frame.contentDocument;
|
const document = frame.contentDocument;
|
||||||
if (!document?.documentElement) return;
|
if (!document?.documentElement) return;
|
||||||
const keepInside = (): void => {
|
const keepInside = (): void => {
|
||||||
|
// 隐藏 "Open in..." 按钮
|
||||||
document.querySelectorAll<HTMLElement>("button,[role='button']").forEach(
|
document.querySelectorAll<HTMLElement>("button,[role='button']").forEach(
|
||||||
(element) => {
|
(element) => {
|
||||||
const label = `${element.getAttribute("aria-label") ?? ""} ${
|
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)) {
|
if (/^open in\b/i.test(label) || /\bopen in\.\.\./i.test(label)) {
|
||||||
element.style.setProperty("display", "none", "important");
|
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) => {
|
document.querySelectorAll<HTMLAnchorElement>("a[target]").forEach((link) => {
|
||||||
if (["_blank", "_top", "_parent"].includes(link.target)) {
|
if (["_blank", "_top", "_parent"].includes(link.target)) {
|
||||||
link.target = "_self";
|
link.target = "_self";
|
||||||
@@ -141,6 +270,8 @@ export function ScriptWorkspace({
|
|||||||
const tabbarRef = useRef<HTMLDivElement | null>(null);
|
const tabbarRef = useRef<HTMLDivElement | null>(null);
|
||||||
const isNotebook = script.script_type === "notebook";
|
const isNotebook = script.script_type === "notebook";
|
||||||
const isPython = script.script_type === "python";
|
const isPython = script.script_type === "python";
|
||||||
|
// 订阅 store 的刷新版本号,用于触发只读内容刷新
|
||||||
|
const readOnlyRefreshVersion = useScriptWorkspaceStore((s) => s.readOnlyRefreshVersion);
|
||||||
// 判断当前选中的文件是否正在编辑(需要同时检查 session_status 和 script_id)
|
// 判断当前选中的文件是否正在编辑(需要同时检查 session_status 和 script_id)
|
||||||
const isEditing = editSession?.session_status === "active"
|
const isEditing = editSession?.session_status === "active"
|
||||||
&& editSession?.script_id === script.script_id;
|
&& editSession?.script_id === script.script_id;
|
||||||
@@ -154,8 +285,8 @@ export function ScriptWorkspace({
|
|||||||
);
|
);
|
||||||
const hasEmbeddedContent = sessionCache.size > 0 || pythonTabBuffers.length > 0;
|
const hasEmbeddedContent = sessionCache.size > 0 || pythonTabBuffers.length > 0;
|
||||||
|
|
||||||
// 只读模式状态
|
// 只读模式状态(用于 Python 文件和 notebook 的 JSON 内容)
|
||||||
const [readOnlyContent, setReadOnlyContent] = useState<string | null>(null);
|
const [readOnlyContent, setReadOnlyContent] = useState<string | object | null>(null);
|
||||||
const [readOnlyLoading, setReadOnlyLoading] = useState(false);
|
const [readOnlyLoading, setReadOnlyLoading] = useState(false);
|
||||||
const [readOnlyError, setReadOnlyError] = useState<string | null>(null);
|
const [readOnlyError, setReadOnlyError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -201,13 +332,7 @@ export function ScriptWorkspace({
|
|||||||
|
|
||||||
getScriptContent(script.workspace_id, script.script_id)
|
getScriptContent(script.workspace_id, script.script_id)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (data.script_type === 'notebook') {
|
setReadOnlyContent(data.content);
|
||||||
// Notebook 转为 JSON 字符串显示
|
|
||||||
setReadOnlyContent(JSON.stringify(data.content, null, 2));
|
|
||||||
} else {
|
|
||||||
// Python 文件直接显示
|
|
||||||
setReadOnlyContent(data.content as string);
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
setReadOnlyError(err instanceof Error ? err.message : '加载失败');
|
setReadOnlyError(err instanceof Error ? err.message : '加载失败');
|
||||||
@@ -215,7 +340,27 @@ export function ScriptWorkspace({
|
|||||||
.finally(() => {
|
.finally(() => {
|
||||||
setReadOnlyLoading(false);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -337,35 +482,41 @@ export function ScriptWorkspace({
|
|||||||
<Icon name="lock" size={16} />
|
<Icon name="lock" size={16} />
|
||||||
<span>此文件已被锁定,您当前处于只读模式</span>
|
<span>此文件已被锁定,您当前处于只读模式</span>
|
||||||
</div>
|
</div>
|
||||||
{readOnlyLoading ? (
|
<div className="readonly-editor-content">
|
||||||
<div className="readonly-editor-loading">
|
{readOnlyLoading ? (
|
||||||
<span className="button-spinner button-spinner--blue" />
|
<div className="readonly-editor-loading">
|
||||||
<span>加载内容中...</span>
|
<span className="button-spinner button-spinner--blue" />
|
||||||
</div>
|
<span>加载内容中...</span>
|
||||||
) : readOnlyError ? (
|
</div>
|
||||||
<div className="readonly-editor-error">
|
) : readOnlyError ? (
|
||||||
<Icon name="info" size={28} />
|
<div className="readonly-editor-error">
|
||||||
<strong>加载失败</strong>
|
<Icon name="info" size={28} />
|
||||||
<p>{readOnlyError}</p>
|
<strong>加载失败</strong>
|
||||||
</div>
|
<p>{readOnlyError}</p>
|
||||||
) : (
|
</div>
|
||||||
<Editor
|
) : script.script_type === 'notebook' && readOnlyContent && typeof readOnlyContent === 'object' ? (
|
||||||
height="calc(100% - 50px)"
|
// Notebook 使用单元格渲染
|
||||||
language={script.script_type === 'notebook' ? 'json' : 'python'}
|
<NotebookViewer content={readOnlyContent} />
|
||||||
value={readOnlyContent ?? ''}
|
) : (
|
||||||
theme="vs"
|
// Python 文件使用 Monaco Editor 显示
|
||||||
options={{
|
<Editor
|
||||||
readOnly: true,
|
height="100%"
|
||||||
domReadOnly: true,
|
language="python"
|
||||||
minimap: { enabled: true },
|
value={typeof readOnlyContent === 'string' ? readOnlyContent : ''}
|
||||||
lineNumbers: "on",
|
theme="vs"
|
||||||
folding: true,
|
options={{
|
||||||
wordWrap: "on",
|
readOnly: true,
|
||||||
contextmenu: false,
|
domReadOnly: true,
|
||||||
automaticLayout: true,
|
minimap: { enabled: true },
|
||||||
}}
|
lineNumbers: "on",
|
||||||
/>
|
folding: true,
|
||||||
)}
|
wordWrap: "on",
|
||||||
|
contextmenu: false,
|
||||||
|
automaticLayout: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// 正常编辑模式:原有的 iframe + Monaco 预览逻辑
|
// 正常编辑模式:原有的 iframe + Monaco 预览逻辑
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export default function ScriptsPage() {
|
|||||||
const toggleScriptLock = useScriptWorkspaceStore((s) => s.toggleScriptLock);
|
const toggleScriptLock = useScriptWorkspaceStore((s) => s.toggleScriptLock);
|
||||||
const openPublishDialog = useScriptWorkspaceStore((s) => s.openPublishDialog);
|
const openPublishDialog = useScriptWorkspaceStore((s) => s.openPublishDialog);
|
||||||
const submitPublish = useScriptWorkspaceStore((s) => s.submitPublish);
|
const submitPublish = useScriptWorkspaceStore((s) => s.submitPublish);
|
||||||
|
const refreshReadOnlyContent = useScriptWorkspaceStore((s) => s.refreshReadOnlyContent);
|
||||||
|
|
||||||
// ui store
|
// ui store
|
||||||
const pushToast = useUiStore((s) => s.pushToast);
|
const pushToast = useUiStore((s) => s.pushToast);
|
||||||
@@ -88,8 +89,10 @@ export default function ScriptsPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reset();
|
reset();
|
||||||
|
// 刷新前递增版本号,触发已打开标签页的只读内容刷新
|
||||||
|
refreshReadOnlyContent();
|
||||||
void load();
|
void load();
|
||||||
}, [reset, load, workspaceId]);
|
}, [reset, load, workspaceId, refreshReadOnlyContent]);
|
||||||
|
|
||||||
// 2) 选中文件变更时加载最新版本
|
// 2) 选中文件变更时加载最新版本
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ type State = {
|
|||||||
loadingChildrenPaths: Set<string>;
|
loadingChildrenPaths: Set<string>;
|
||||||
loadedChildPaths: Set<string>;
|
loadedChildPaths: Set<string>;
|
||||||
|
|
||||||
|
// 只读内容刷新版本号(用于触发已打开标签页的内容刷新)
|
||||||
|
readOnlyRefreshVersion: number;
|
||||||
|
|
||||||
// actions
|
// actions
|
||||||
setApiOnline: (online: boolean) => void;
|
setApiOnline: (online: boolean) => void;
|
||||||
setKeyword: (keyword: string) => void;
|
setKeyword: (keyword: string) => void;
|
||||||
@@ -111,6 +114,7 @@ type State = {
|
|||||||
toggleScriptLock: (script: ScriptItem) => Promise<void>;
|
toggleScriptLock: (script: ScriptItem) => Promise<void>;
|
||||||
openPublishDialog: (script: ScriptItem) => void;
|
openPublishDialog: (script: ScriptItem) => void;
|
||||||
submitPublish: (releaseNote: string, visibility: Visibility) => Promise<void>;
|
submitPublish: (releaseNote: string, visibility: Visibility) => Promise<void>;
|
||||||
|
refreshReadOnlyContent: () => void; // 刷新只读内容
|
||||||
|
|
||||||
// 生命周期 tick(由 layout 层 useEditSessionLifecycle 周期性调用)
|
// 生命周期 tick(由 layout 层 useEditSessionLifecycle 周期性调用)
|
||||||
tickHeartbeats: () => Promise<void>;
|
tickHeartbeats: () => Promise<void>;
|
||||||
@@ -175,8 +179,11 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
|
|||||||
loadingChildrenPaths: new Set<string>(),
|
loadingChildrenPaths: new Set<string>(),
|
||||||
loadedChildPaths: new Set<string>(),
|
loadedChildPaths: new Set<string>(),
|
||||||
|
|
||||||
|
readOnlyRefreshVersion: 0,
|
||||||
|
|
||||||
setApiOnline: (online) => set({ apiOnline: online }),
|
setApiOnline: (online) => set({ apiOnline: online }),
|
||||||
setKeyword: (keyword) => set({ keyword }),
|
setKeyword: (keyword) => set({ keyword }),
|
||||||
|
refreshReadOnlyContent: () => set((state) => ({ readOnlyRefreshVersion: state.readOnlyRefreshVersion + 1 })),
|
||||||
|
|
||||||
reset: () => {
|
reset: () => {
|
||||||
if (_previewController) {
|
if (_previewController) {
|
||||||
|
|||||||
@@ -424,11 +424,41 @@ export async function getScriptContent(
|
|||||||
content: string | object;
|
content: string | object;
|
||||||
format: string;
|
format: string;
|
||||||
}> {
|
}> {
|
||||||
return apiRequest(
|
const response = await fetch(
|
||||||
`/api/v1/scripts/${scriptId}/content`,
|
`/api/v1/scripts/${scriptId}/content?workspace_id=${encodeURIComponent(workspaceId)}`,
|
||||||
{},
|
{
|
||||||
workspaceId,
|
credentials: "same-origin",
|
||||||
).then((envelope) => (envelope as Record<string, any>).data);
|
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(
|
export async function deleteScript(
|
||||||
|
|||||||
@@ -2072,6 +2072,7 @@ button {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.readonly-editor-banner {
|
.readonly-editor-banner {
|
||||||
@@ -2085,6 +2086,13 @@ button {
|
|||||||
color: #856404;
|
color: #856404;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readonly-editor-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.readonly-editor-banner svg {
|
.readonly-editor-banner svg {
|
||||||
@@ -2096,7 +2104,7 @@ button {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex: 1;
|
min-height: 200px;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
color: #666;
|
color: #666;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -2107,11 +2115,12 @@ button {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex: 1;
|
min-height: 200px;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
color: #dc3545;
|
color: #dc3545;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.readonly-editor-error strong {
|
.readonly-editor-error strong {
|
||||||
@@ -2122,3 +2131,140 @@ button {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
color: #666;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user