Develop #16

Merged
tao.chen merged 273 commits from develop into main 2026-08-21 10:42:09 +08:00
4 changed files with 364 additions and 157 deletions
Showing only changes of commit fe65034aa8 - Show all commits
+51
View File
@@ -1360,3 +1360,54 @@ async def version_download_url(
request,
)
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,
} from "../../services/api";
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 { useRef, useLayoutEffect } from "react";
import { useRef, useLayoutEffect, useState, useEffect } from "react";
import { useScriptWorkspaceStore } from "./state/scriptWorkspaceStore";
import { useAuth } from "../../context/AuthContext";
import { getScriptContent } from "../../services/api";
type ToastState = {
tone: "success" | "error" | "info";
@@ -118,12 +120,23 @@ export function ScriptWorkspace({
onPublish,
onInfo,
}: ScriptWorkspaceProps) {
const { user } = useAuth();
const tabbarRef = useRef<HTMLDivElement | null>(null);
const isNotebook = script.script_type === "notebook";
// 判断当前选中的文件是否正在编辑(需要同时检查 session_status 和 script_id
const isEditing = editSession?.session_status === "active"
&& 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 tabbar = tabbarRef.current;
if (!tabbar) return;
@@ -147,6 +160,36 @@ export function ScriptWorkspace({
}
}, [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 (
<>
<div className="tabbar">
@@ -244,11 +287,48 @@ export function ScriptWorkspace({
</div>
</div>
{/* 多 iframe 共存方案:为每个缓存的 session 渲染独立的 iframe */}
{/* 只读模式:显示只读编辑器 */}
{isReadOnlyMode ? (
<div className="readonly-editor-container">
<div className="readonly-editor-banner">
<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>
) : (
// 正常编辑模式:原有的 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
@@ -388,7 +468,6 @@ export function ScriptWorkspace({
<div className="integrity-row">
<span>
<Icon name="check" size={15} />
{/*{isEditing ? "Demo 无锁编辑会话已连接" : "工作副本已同步"}*/}
</span>
<span>SHA-256&nbsp; {shortHash(script.content_hash)}</span>
<span>
@@ -401,8 +480,10 @@ export function ScriptWorkspace({
</span>
</div>
</section>
)) : null}
)
) : null}
</div>
)}
</>
);
}
+16
View File
@@ -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(
workspaceId: string,
scriptId: string,
+59
View File
@@ -1989,3 +1989,62 @@ button {
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;
}
}
}