feat:脚本只读
This commit is contained in:
@@ -1360,3 +1360,54 @@ async def version_download_url(
|
|||||||
request,
|
request,
|
||||||
)
|
)
|
||||||
return {"request_id": context.request_id, "data": data["data"], "meta": {}}
|
return {"request_id": context.request_id, "data": data["data"], "meta": {}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/scripts/{script_id}/content")
|
||||||
|
async def get_script_content(
|
||||||
|
script_id: str,
|
||||||
|
request: Request,
|
||||||
|
context: RequestContext = Depends(request_context),
|
||||||
|
session: AsyncSession = Depends(database_session),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""获取脚本内容(用于只读预览)。
|
||||||
|
|
||||||
|
适用于被锁定的脚本,非所有者只能查看内容,不能编辑。
|
||||||
|
该接口不检查锁状态,调用方需自行判断权限。
|
||||||
|
"""
|
||||||
|
script = await session.scalar(
|
||||||
|
select(Scripts)
|
||||||
|
.where(
|
||||||
|
Scripts.script_id == script_id,
|
||||||
|
Scripts.workspace_id == context.workspace.workspace_id,
|
||||||
|
Scripts.status == "active",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if script is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "script not found")
|
||||||
|
|
||||||
|
# 获取 Jupyter 路径
|
||||||
|
jupyter_name = _jupyter_path(script.script_type, script.script_id)
|
||||||
|
|
||||||
|
# 从 Jupyter 读取内容
|
||||||
|
runtime_client = request.app.state.runtime_client
|
||||||
|
try:
|
||||||
|
content_data = await runtime_client.get_file(
|
||||||
|
context.workspace.workspace_id,
|
||||||
|
name=jupyter_name,
|
||||||
|
)
|
||||||
|
except RuntimeClientError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
detail=exc.detail,
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return {
|
||||||
|
"request_id": context.request_id,
|
||||||
|
"data": {
|
||||||
|
"script_id": script.script_id,
|
||||||
|
"script_type": script.script_type,
|
||||||
|
"content": content_data.get("content"),
|
||||||
|
"format": content_data.get("format"),
|
||||||
|
},
|
||||||
|
"meta": {},
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import type {
|
|||||||
ScriptType,
|
ScriptType,
|
||||||
} from "../../services/api";
|
} from "../../services/api";
|
||||||
import { scriptIcon } from "./WorkspaceTree";
|
import { scriptIcon } from "./WorkspaceTree";
|
||||||
import type { MouseEvent as ReactMouseEvent, RefObject } from "react";
|
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||||
import Editor from "@monaco-editor/react";
|
import Editor from "@monaco-editor/react";
|
||||||
import { useRef, useLayoutEffect } from "react";
|
import { useRef, useLayoutEffect, useState, useEffect } from "react";
|
||||||
|
|
||||||
import { useScriptWorkspaceStore } from "./state/scriptWorkspaceStore";
|
import { useScriptWorkspaceStore } from "./state/scriptWorkspaceStore";
|
||||||
|
import { useAuth } from "../../context/AuthContext";
|
||||||
|
import { getScriptContent } from "../../services/api";
|
||||||
|
|
||||||
type ToastState = {
|
type ToastState = {
|
||||||
tone: "success" | "error" | "info";
|
tone: "success" | "error" | "info";
|
||||||
@@ -118,12 +120,23 @@ export function ScriptWorkspace({
|
|||||||
onPublish,
|
onPublish,
|
||||||
onInfo,
|
onInfo,
|
||||||
}: ScriptWorkspaceProps) {
|
}: ScriptWorkspaceProps) {
|
||||||
|
const { user } = useAuth();
|
||||||
const tabbarRef = useRef<HTMLDivElement | null>(null);
|
const tabbarRef = useRef<HTMLDivElement | null>(null);
|
||||||
const isNotebook = script.script_type === "notebook";
|
const isNotebook = script.script_type === "notebook";
|
||||||
// 判断当前选中的文件是否正在编辑(需要同时检查 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;
|
||||||
|
|
||||||
|
// 只读模式状态
|
||||||
|
const [readOnlyContent, setReadOnlyContent] = useState<string | 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 scroll = (direction: "left" | "right") => {
|
||||||
const tabbar = tabbarRef.current;
|
const tabbar = tabbarRef.current;
|
||||||
if (!tabbar) return;
|
if (!tabbar) return;
|
||||||
@@ -147,6 +160,36 @@ export function ScriptWorkspace({
|
|||||||
}
|
}
|
||||||
}, [script.script_id]);
|
}, [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) => {
|
||||||
|
if (data.script_type === 'notebook') {
|
||||||
|
// Notebook 转为 JSON 字符串显示
|
||||||
|
setReadOnlyContent(JSON.stringify(data.content, null, 2));
|
||||||
|
} else {
|
||||||
|
// Python 文件直接显示
|
||||||
|
setReadOnlyContent(data.content as string);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setReadOnlyError(err instanceof Error ? err.message : '加载失败');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setReadOnlyLoading(false);
|
||||||
|
});
|
||||||
|
}, [isReadOnlyMode, script.script_id, script.workspace_id, script.script_type]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="tabbar">
|
<div className="tabbar">
|
||||||
@@ -244,165 +287,203 @@ export function ScriptWorkspace({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 多 iframe 共存方案:为每个缓存的 session 渲染独立的 iframe */}
|
{/* 只读模式:显示只读编辑器 */}
|
||||||
<div className={`editor-canvas ${sessionCache.size > 0 ? 'is-embedded' : ''}`} style={sessionCache.size > 0 ? { position: 'relative', minHeight: '0', height: '100%' } : undefined}>
|
{isReadOnlyMode ? (
|
||||||
{/* 渲染所有缓存的 iframe */}
|
<div className="readonly-editor-container">
|
||||||
{Array.from(sessionCache.entries()).map(([scriptId, cached]) => {
|
<div className="readonly-editor-banner">
|
||||||
// 当前选中的脚本就是激活的
|
<Icon name="lock" size={16} />
|
||||||
const isActive = scriptId === script.script_id;
|
<span>此文件已被锁定,您当前处于只读模式</span>
|
||||||
return (
|
</div>
|
||||||
<section
|
{readOnlyLoading ? (
|
||||||
key={scriptId}
|
<div className="readonly-editor-loading">
|
||||||
className="embedded-jupyter"
|
<span className="button-spinner button-spinner--blue" />
|
||||||
style={{
|
<span>加载内容中...</span>
|
||||||
position: isActive ? 'relative' : 'absolute',
|
</div>
|
||||||
visibility: isActive ? 'visible' : 'hidden',
|
) : readOnlyError ? (
|
||||||
pointerEvents: isActive ? 'auto' : 'none',
|
<div className="readonly-editor-error">
|
||||||
width: '100%',
|
<Icon name="info" size={28} />
|
||||||
height: '100%',
|
<strong>加载失败</strong>
|
||||||
overflow: 'hidden'
|
<p>{readOnlyError}</p>
|
||||||
}}
|
</div>
|
||||||
>
|
|
||||||
<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>
|
|
||||||
) : (
|
) : (
|
||||||
<section className="script-overview">
|
<Editor
|
||||||
<div className="script-overview__header">
|
height="calc(100% - 50px)"
|
||||||
<div>
|
language={script.script_type === 'notebook' ? 'json' : 'python'}
|
||||||
<span className="section-kicker">PYTHON SCRIPT</span>
|
value={readOnlyContent ?? ''}
|
||||||
<h2>{script.script_name}</h2>
|
theme="vs"
|
||||||
<p>{script.relative_path}</p>
|
options={{
|
||||||
</div>
|
readOnly: true,
|
||||||
<button
|
domReadOnly: true,
|
||||||
className={`open-editor-button${isEditing ? " is-editing" : ""}`}
|
minimap: { enabled: true },
|
||||||
type="button"
|
lineNumbers: "on",
|
||||||
disabled={editBusy}
|
folding: true,
|
||||||
onClick={onOpenEditor}
|
wordWrap: "on",
|
||||||
|
contextmenu: false,
|
||||||
|
automaticLayout: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// 正常编辑模式:原有的 iframe + Monaco 预览逻辑
|
||||||
|
<div className={`editor-canvas ${sessionCache.size > 0 ? 'is-embedded' : ''}`} style={sessionCache.size > 0 ? { position: 'relative', minHeight: '0', height: '100%' } : undefined}>
|
||||||
|
{/* 渲染所有缓存的 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'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{editBusy
|
<div className="embedded-jupyter__status">
|
||||||
? <span className="button-spinner button-spinner--blue" />
|
<span>
|
||||||
: <Icon name="external" size={17} />}
|
<i />
|
||||||
{editBusy
|
Workspace Jupyter Server
|
||||||
? "正在准备编辑器…"
|
</span>
|
||||||
: isEditing
|
<span>
|
||||||
? "继续编辑"
|
{cached.session.session_status === "active" ? "Notebook Session · Python 3 Kernel" : "Jupyter 文本编辑器"}
|
||||||
: "打开编辑器"}
|
</span>
|
||||||
</button>
|
<code title={cached.session.runtime_id}>
|
||||||
</div>
|
Runtime {cached.session.runtime_id.slice(-8)}
|
||||||
|
</code>
|
||||||
<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>
|
</div>
|
||||||
<span>Python 预览</span>
|
<iframe
|
||||||
<em>{isEditing ? "Jupyter 中可编辑" : "平台只读预览"}</em>
|
src={cached.jupyterUrl}
|
||||||
</div>
|
title={`${scriptId} Jupyter 编辑器`}
|
||||||
<PythonPreview
|
allow="clipboard-read; clipboard-write"
|
||||||
workspaceId={script.workspace_id}
|
sandbox="allow-same-origin allow-scripts allow-forms allow-downloads allow-modals"
|
||||||
filePath={script.jupyter_path}
|
onLoad={(event) => confineJupyterFrame(event.currentTarget)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
<div className="integrity-row">
|
{/* 当前脚本没有缓存时的状态显示 */}
|
||||||
<span>
|
{!sessionCache.has(script.script_id) ? (
|
||||||
<Icon name="check" size={15} />
|
isNotebook ? (
|
||||||
{/*{isEditing ? "Demo 无锁编辑会话已连接" : "工作副本已同步"}*/}
|
<section
|
||||||
</span>
|
className={`editor-opening-state${openError ? " has-error" : ""}`}
|
||||||
<span>SHA-256 {shortHash(script.content_hash)}</span>
|
aria-live="polite"
|
||||||
<span>
|
style={{ display: 'block' }}
|
||||||
稳定版本
|
>
|
||||||
{versionsLoading
|
<div className="editor-opening-state__icon">
|
||||||
? "加载中"
|
{openError
|
||||||
: latestVersion
|
? <Icon name="info" size={28} />
|
||||||
? `${latestVersion.version_label} · ${latestVersion.versions_id}`
|
: <span className="button-spinner button-spinner--blue" />}
|
||||||
: "尚未发布"}
|
</div>
|
||||||
</span>
|
<strong>
|
||||||
</div>
|
{openError ? "Notebook 打开失败" : "正在打开 Notebook"}
|
||||||
</section>
|
</strong>
|
||||||
)) : null}
|
<p>
|
||||||
</div>
|
{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>
|
||||||
|
) : (
|
||||||
|
<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} />
|
||||||
|
</span>
|
||||||
|
<span>SHA-256 {shortHash(script.content_hash)}</span>
|
||||||
|
<span>
|
||||||
|
稳定版本
|
||||||
|
{versionsLoading
|
||||||
|
? "加载中"
|
||||||
|
: latestVersion
|
||||||
|
? `${latestVersion.version_label} · ${latestVersion.versions_id}`
|
||||||
|
: "尚未发布"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -413,6 +413,22 @@ export async function updateScript(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getScriptContent(
|
||||||
|
workspaceId: string,
|
||||||
|
scriptId: string,
|
||||||
|
): Promise<{
|
||||||
|
script_id: string;
|
||||||
|
script_type: ScriptType;
|
||||||
|
content: string | object;
|
||||||
|
format: string;
|
||||||
|
}> {
|
||||||
|
return apiRequest(
|
||||||
|
`/api/v1/scripts/${scriptId}/content`,
|
||||||
|
{},
|
||||||
|
workspaceId,
|
||||||
|
).then((envelope) => (envelope as Record<string, any>).data);
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteScript(
|
export async function deleteScript(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
scriptId: string,
|
scriptId: string,
|
||||||
|
|||||||
@@ -1989,3 +1989,62 @@ button {
|
|||||||
width: calc(100% - 30px);
|
width: calc(100% - 30px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============ 只读编辑器样式 ============ */
|
||||||
|
.readonly-editor-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readonly-editor-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: #fff3cd;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
color: #856404;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readonly-editor-banner svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readonly-editor-loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
gap: 16px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readonly-editor-error {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
gap: 12px;
|
||||||
|
color: #dc3545;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readonly-editor-error strong {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readonly-editor-error p {
|
||||||
|
margin: 0;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user