From 2fcdf51cfcbbe4f662da6630523fe7dc11a2e8e0 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:49:21 +0800 Subject: [PATCH 01/13] update: PythonEditor.tsx --- backend/src/backend/scripts.py | 195 +++++++++++++++++++-------------- 1 file changed, 112 insertions(+), 83 deletions(-) diff --git a/backend/src/backend/scripts.py b/backend/src/backend/scripts.py index 1064caf..a15b1ff 100644 --- a/backend/src/backend/scripts.py +++ b/backend/src/backend/scripts.py @@ -123,11 +123,36 @@ def _jupyter_path(script_type: str, script_id: str) -> str: (``/jupyter//notebooks/.ipynb``) is jupyter's URL route for the editor view, not a filesystem path — jupyter routes that URL to the file at the workspace root. + + NOTE: This function returns the flat basename only (``{script_id}.{ext}``). + It must NOT be used directly as a Jupyter path for scripts that live + inside sub-directories. Callers must combine it with the parent + directory ULID (for newly-created files) or derive the real path from + ``StorageObjects.object_key`` (for existing files). """ ext = ".ipynb" if script_type == "notebook" else ".py" return f"{script_id}{ext}" +def _derive_jupyter_path( + storage_object: StorageObjects | None, + workspace_id: str, + script_type: str, + script_id: str, +) -> str: + """Return the real Jupyter path for an existing script. + + For normal workspace files the path is taken from + ``StorageObjects.object_key`` with the workspace prefix removed. + For jupyter-only scripts that have no StorageObjects row, fall back + to the flat ``_jupyter_path()`` basename so existing behavior is + preserved. + """ + if storage_object is None or not storage_object.object_key: + return _jupyter_path(script_type, script_id) + return storage_object.object_key.removeprefix(f"{workspace_id}/") + + def validate_script_content(content: str, script_type: str) -> bytes: encoded = content.encode("utf-8") if len(encoded) > 10 * 1024 * 1024: @@ -156,9 +181,14 @@ def validate_script_content(content: str, script_type: str) -> bytes: def script_payload( script: Scripts, - storage_object: StorageObjects | dict[str, Any], + storage_object: StorageObjects | dict[str, Any] | None, ) -> dict[str, Any]: - if isinstance(storage_object, dict): + if storage_object is None: + relative_path = None + object_key = None + content_hash = None + size_bytes = 0 + elif isinstance(storage_object, dict): relative_path = storage_object.get("relative_path") object_key = storage_object.get("object_key") content_hash = storage_object.get("content_hash") @@ -169,11 +199,14 @@ def script_payload( content_hash = storage_object.content_hash size_bytes = storage_object.size_bytes workspace_prefix = f"{script.workspace_id}/" - jupyter_path = ( - object_key[len(workspace_prefix) :] - if object_key and object_key.startswith(workspace_prefix) - else object_key - ) + if object_key: + jupyter_path = ( + object_key[len(workspace_prefix) :] + if object_key.startswith(workspace_prefix) + else object_key + ) + else: + jupyter_path = _jupyter_path(script.script_type, script.script_id) return { "script_id": script.script_id, "workspace_id": script.workspace_id, @@ -219,7 +252,8 @@ async def get_script_row( session: AsyncSession, *, for_update: bool = False, -) -> tuple[Scripts, StorageObjects]: + allow_missing_storage_object: bool = False, +) -> tuple[Scripts, StorageObjects | None]: if for_update: script = await session.scalar( select(Scripts) @@ -239,25 +273,39 @@ async def get_script_row( StorageObjects, script.current_object_id, ) - if storage_object is None: + if storage_object is None and not allow_missing_storage_object: raise HTTPException( status.HTTP_409_CONFLICT, "script working-copy metadata is missing", ) row = (script, storage_object) else: - statement = ( - select(Scripts, StorageObjects) - .join( - StorageObjects, - StorageObjects.storage_object_id == Scripts.current_object_id, + if allow_missing_storage_object: + statement = ( + select(Scripts, StorageObjects) + .outerjoin( + StorageObjects, + StorageObjects.storage_object_id == Scripts.current_object_id, + ) + .where( + Scripts.script_id == script_id, + Scripts.workspace_id == context.workspace.workspace_id, + Scripts.status == "active", + ) ) - .where( - Scripts.script_id == script_id, - Scripts.workspace_id == context.workspace.workspace_id, - Scripts.status == "active", + else: + statement = ( + select(Scripts, StorageObjects) + .join( + StorageObjects, + StorageObjects.storage_object_id == Scripts.current_object_id, + ) + .where( + Scripts.script_id == script_id, + Scripts.workspace_id == context.workspace.workspace_id, + Scripts.status == "active", + ) ) - ) row = (await session.execute(statement)).one_or_none() if row is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "script not found") @@ -971,24 +1019,16 @@ async def update_script( context: RequestContext = Depends(request_context), session: AsyncSession = Depends(database_session), ) -> dict[str, Any]: - # Look up the Scripts row on its own: jupyter-only scripts do not - # have a StorageObjects row to JOIN against, and update is an - # in-place overwrite of the same jupyter path, so we do not need - # any object-store metadata. - script = await session.scalar( - select(Scripts) - .where( - Scripts.script_id == script_id, - Scripts.workspace_id == context.workspace.workspace_id, - Scripts.status == "active", - ) - .with_for_update() + # Load the working-copy StorageObject as well as the Scripts row. + # Jupyter-only scripts may not have a StorageObjects row; allow that + # case and fall back to the flat _jupyter_path() name. + script, storage_object = await get_script_row( + script_id, + context, + session, + for_update=True, + allow_missing_storage_object=True, ) - if script is None: - raise HTTPException( - status.HTTP_404_NOT_FOUND, - "script not found", - ) require_script_modify_access( script, user_id=context.user.user_id, @@ -997,21 +1037,23 @@ async def update_script( content = validate_script_content(payload.content, script.script_type) runtime_client = request.app.state.runtime_client workspace_id = context.workspace.workspace_id - jupyter_name = _jupyter_path(script.script_type, script.script_id) + jupyter_path = _derive_jupyter_path( + storage_object, workspace_id, script.script_type, script.script_id + ) try: if script.script_type == "notebook": notebook = json.loads(content.decode("utf-8")) jupyter_resp = await runtime_client.create_notebook( workspace_id, - name=jupyter_name, + name=jupyter_path, cells=notebook.get("cells"), ) else: jupyter_resp = await runtime_client.upload_file( workspace_id, - name=jupyter_name, + name=jupyter_path, content=content.decode("utf-8"), - content_type=(mimetypes.guess_type(jupyter_name)[0] or "text/plain"), + content_type=(mimetypes.guess_type(jupyter_path)[0] or "text/plain"), ) except RuntimeClientError as exc: raise HTTPException( @@ -1022,8 +1064,8 @@ async def update_script( script.updated_at = datetime.now(UTC).replace(tzinfo=None) storage_data = { "storage_object_id": script.current_object_id, - "relative_path": jupyter_name, - "object_key": f"{workspace_id}/{jupyter_name}", + "relative_path": jupyter_path, + "object_key": f"{workspace_id}/{jupyter_path}", "content_hash": hashlib.sha256(content).hexdigest(), "size_bytes": len(content), } @@ -1083,23 +1125,16 @@ async def delete_script( context: RequestContext = Depends(request_context), session: AsyncSession = Depends(database_session), ) -> dict[str, Any]: - # Scripts created via the jupyter path do not have a corresponding - # StorageObjects row, so we look up the Scripts row on its own and - # forward the delete to the workspace's live Jupyter instance. - script = await session.scalar( - select(Scripts) - .where( - Scripts.script_id == script_id, - Scripts.workspace_id == context.workspace.workspace_id, - Scripts.status == "active", - ) - .with_for_update() + # Load the working-copy StorageObject if it exists. Jupyter-only + # scripts may not have a StorageObjects row; fall back to the flat + # _jupyter_path() name in that case. + script, storage_object = await get_script_row( + script_id, + context, + session, + for_update=True, + allow_missing_storage_object=True, ) - if script is None: - raise HTTPException( - status.HTTP_404_NOT_FOUND, - "script not found", - ) require_script_modify_access( script, user_id=context.user.user_id, @@ -1107,11 +1142,12 @@ async def delete_script( ) runtime_client = request.app.state.runtime_client + workspace_id = context.workspace.workspace_id + jupyter_path = _derive_jupyter_path( + storage_object, workspace_id, script.script_type, script.script_id + ) try: - await runtime_client.delete_file( - context.workspace.workspace_id, - name=_jupyter_path(script.script_type, script.script_id), - ) + await runtime_client.delete_file(workspace_id, name=jupyter_path) except RuntimeClientError as exc: raise HTTPException( status_code=exc.status_code, @@ -1142,25 +1178,16 @@ async def publish_version( context: RequestContext = Depends(request_context), session: AsyncSession = Depends(database_session), ) -> dict[str, Any]: - # Jupyter-only scripts do not have a StorageObjects row, so the - # legacy get_script_row helper raises 409 before we even get here. - # Look up the Scripts row on its own — version publication is a - # metadata operation, we do not need the working-copy object - # metadata. - script = await session.scalar( - select(Scripts) - .where( - Scripts.script_id == script_id, - Scripts.workspace_id == context.workspace.workspace_id, - Scripts.status == "active", - ) - .with_for_update() + # Load the working-copy StorageObject if it exists. Jupyter-only + # scripts may not have a StorageObjects row; fall back to the flat + # _jupyter_path() name when reading from Jupyter. + script, storage_object = await get_script_row( + script_id, + context, + session, + for_update=True, + allow_missing_storage_object=True, ) - if script is None: - raise HTTPException( - status.HTTP_404_NOT_FOUND, - "script not found", - ) require_script_modify_access( script, user_id=context.user.user_id, @@ -1180,9 +1207,11 @@ async def publish_version( # files come back as a UTF-8 string. runtime_client = request.app.state.runtime_client workspace_id = context.workspace.workspace_id - jupyter_name = _jupyter_path(script.script_type, script.script_id) + jupyter_path = _derive_jupyter_path( + storage_object, workspace_id, script.script_type, script.script_id + ) try: - contents = await runtime_client.get_file(workspace_id, name=jupyter_name) + contents = await runtime_client.get_file(workspace_id, name=jupyter_path) except RuntimeClientError as exc: raise HTTPException( status_code=exc.status_code, @@ -1241,7 +1270,7 @@ async def publish_version( artifact_object_id=artifact_data["storage_object_id"], version_no=version_no, version_label=f"v{version_no}.0", - source_path=jupyter_name, + source_path=jupyter_path, artifact_path=artifact_data["storage_uri"], content_hash=content_hash, file_size_bytes=len(content), From a86432b102d42d3b2407bc85b3fbf629c03f1401 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:58:00 +0800 Subject: [PATCH 02/13] update: PythonEditor.tsx --- .../app/features/platform/PythonEditor.tsx | 129 ++++++++++ .../app/features/platform/ScriptWorkspace.tsx | 242 ++++++++++++------ .../app/features/platform/ScriptsPage.tsx | 43 +++- .../platform/state/scriptWorkspaceStore.ts | 171 +++++++++++++ frontend/app/styles/platform.css | 76 ++++++ 5 files changed, 576 insertions(+), 85 deletions(-) create mode 100644 frontend/app/features/platform/PythonEditor.tsx diff --git a/frontend/app/features/platform/PythonEditor.tsx b/frontend/app/features/platform/PythonEditor.tsx new file mode 100644 index 0000000..7abe918 --- /dev/null +++ b/frontend/app/features/platform/PythonEditor.tsx @@ -0,0 +1,129 @@ +import { useEffect, useRef, useState } from "react"; +import Editor from "@monaco-editor/react"; + +import Icon from "../../components/common/Icon"; + +import type { ScriptItem } from "../../services/api"; + +interface PythonEditorProps { + script: ScriptItem; + scriptId: string; + initialContent: string; + saving: boolean; + dirty: boolean; + loading: boolean; + loadError?: string | null; + onChange: (scriptId: string, value: string) => void; + onSave: (scriptId: string) => void; + onEndEditing: (scriptId: string) => void; + onCloseTab: (scriptId: string) => void; +} + +export function PythonEditor({ + script, + scriptId, + initialContent, + saving, + dirty, + loading, + loadError, + onChange, + onSave, + onEndEditing, + onCloseTab, +}: PythonEditorProps) { + const containerRef = useRef(null); + const [value, setValue] = useState(initialContent); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const mod = e.metaKey || e.ctrlKey; + if (!mod) return; + if (e.key.toLowerCase() === "s") { + e.preventDefault(); + if (dirty && !saving) onSave(scriptId); + } else if (e.key.toLowerCase() === "w") { + e.preventDefault(); + onCloseTab(scriptId); + } + }; + containerRef.current?.addEventListener("keydown", onKey); + return () => containerRef.current?.removeEventListener("keydown", onKey); + }, [dirty, saving, onSave, onCloseTab, scriptId]); + + useEffect(() => { + if (!dirty || saving) return; + const t = window.setTimeout(() => onSave(scriptId), 30_000); + return () => window.clearTimeout(t); + }, [value, dirty, saving, onSave, scriptId]); + + const hasSyncedRef = useRef(false); + useEffect(() => { + if (!loading && initialContent !== null && !hasSyncedRef.current) { + setValue(initialContent); + hasSyncedRef.current = true; + } + }, [loading, initialContent]); + + if (loading) { + return ( +
+
+
+ +
+ 正在打开 Python 编辑器 +

正在加载文件内容…

+
+
+ ); + } + + if (loadError) { + return ( +
+
+
+ +
+ Python 编辑器打开失败 +

{loadError}

+
+
+ ); + } + + return ( +
+ {dirty &&
未保存修改
} +
+ + Workspace Python Editor + + {script.relative_path} +
+
+ { + setValue(v ?? ""); + onChange(scriptId, v ?? ""); + }} + options={{ + minimap: { enabled: initialContent.length > 5000 }, + wordWrap: "on", + fontSize: 13, + automaticLayout: true, + renderLineHighlight: "gutter", + contextmenu: false, + dragAndDrop: false, + scrollBeyondLastLine: false, + }} + /> +
+
+ ); +} diff --git a/frontend/app/features/platform/ScriptWorkspace.tsx b/frontend/app/features/platform/ScriptWorkspace.tsx index e566a9a..a16200d 100644 --- a/frontend/app/features/platform/ScriptWorkspace.tsx +++ b/frontend/app/features/platform/ScriptWorkspace.tsx @@ -4,15 +4,16 @@ import type { LatestVersion, ScriptItem, ScriptType, -} from "../../services/api"; +} from "~/services/api"; import { scriptIcon } from "./WorkspaceTree"; import type { MouseEvent as ReactMouseEvent } from "react"; import Editor from "@monaco-editor/react"; import { useRef, useLayoutEffect, useState, useEffect } from "react"; -import { useScriptWorkspaceStore } from "./state/scriptWorkspaceStore"; -import { useAuth } from "../../context/AuthContext"; -import { getScriptContent } from "../../services/api"; +import { useScriptWorkspaceStore, type PythonEditorBuffer } from "./state/scriptWorkspaceStore"; +import { PythonEditor } from "./PythonEditor"; +import { useAuth } from "~/context/AuthContext"; +import { getScriptContent } from "~/services/api"; type ToastState = { tone: "success" | "error" | "info"; @@ -28,6 +29,7 @@ interface CachedSession { type ScriptWorkspaceProps = { script: ScriptItem; + scripts: ScriptItem[]; sessionCache: Map; editSession: ActiveEditSession | null; jupyterUrl: string | null; @@ -43,6 +45,13 @@ type ScriptWorkspaceProps = { onNewTab: () => void; onPublish: () => void; onInfo: (toast: ToastState) => void; + + pythonEditorBuffers: Record; + onOpenPythonEditor: () => void; + onSetPythonEditorContent: (scriptId: string, v: string) => void; + onSavePythonEditor: (scriptId: string) => void; + onExitPythonEditor: (scriptId: string) => void; + onClosePythonTab: (scriptId: string) => void; }; function formatTime(value: string) { @@ -104,6 +113,7 @@ function confineJupyterFrame(frame: HTMLIFrameElement): void { export function ScriptWorkspace({ script, + scripts, sessionCache, editSession, jupyterUrl, @@ -119,13 +129,30 @@ export function ScriptWorkspace({ onNewTab, onPublish, onInfo, + + pythonEditorBuffers, + onOpenPythonEditor, + onSetPythonEditorContent, + onSavePythonEditor, + onExitPythonEditor, + onClosePythonTab, }: ScriptWorkspaceProps) { const { user } = useAuth(); const tabbarRef = useRef(null); const isNotebook = script.script_type === "notebook"; + const isPython = script.script_type === "python"; // 判断当前选中的文件是否正在编辑(需要同时检查 session_status 和 script_id) const isEditing = editSession?.session_status === "active" && editSession?.script_id === script.script_id; + const activePythonBuf = isPython ? pythonEditorBuffers[script.script_id] : null; + const isPythonEditing = isPython && !!activePythonBuf; + const showSaveButton = isPythonEditing && activePythonBuf && + (activePythonBuf.dirty || activePythonBuf.saving || activePythonBuf.initial); + const saveDisabled = !activePythonBuf?.dirty || activePythonBuf?.saving; + const pythonTabBuffers = openTabs.filter( + (t) => t.scriptType === "python" && pythonEditorBuffers[t.scriptId], + ); + const hasEmbeddedContent = sessionCache.size > 0 || pythonTabBuffers.length > 0; // 只读模式状态 const [readOnlyContent, setReadOnlyContent] = useState(null); @@ -249,7 +276,23 @@ export function ScriptWorkspace({ {script.script_name}
- {isEditing ? ( + {isPythonEditing ? ( + <> + {showSaveButton && ( + + )} + + + ) : isEditing ? ( + )} + + ) : isPython && !activePythonBuf ? ( +
+
+
+ PYTHON SCRIPT +

{script.script_name}

+

{script.relative_path}

+
+ - )} -
- ) : ( -
-
-
- PYTHON SCRIPT -

{script.script_name}

-

{script.relative_path}

-
- -
+ {editBusy + ? + : } + {editBusy + ? "正在准备编辑器…" + : isEditing + ? "继续编辑" + : "打开编辑器"} + +
-
+
脚本类型 Python @@ -448,8 +525,7 @@ export function ScriptWorkspace({ {formatTime(script.updated_at)}
- -
+
@@ -465,23 +541,23 @@ export function ScriptWorkspace({ />
-
- - - - SHA-256  {shortHash(script.content_hash)} - - 稳定版本  - {versionsLoading - ? "加载中" - : latestVersion - ? `${latestVersion.version_label} · ${latestVersion.versions_id}` - : "尚未发布"} - -
- - ) - ) : null} +
+ + + {/*{isEditing ? "Demo 无锁编辑会话已连接" : "工作副本已同步"}*/} + + SHA-256  {shortHash(script.content_hash)} + + 稳定版本  + {versionsLoading + ? "加载中" + : latestVersion + ? `${latestVersion.version_label} · ${latestVersion.versions_id}` + : "尚未发布"} + +
+ + ) : null):null}
)} diff --git a/frontend/app/features/platform/ScriptsPage.tsx b/frontend/app/features/platform/ScriptsPage.tsx index 14a69a0..152b727 100644 --- a/frontend/app/features/platform/ScriptsPage.tsx +++ b/frontend/app/features/platform/ScriptsPage.tsx @@ -32,6 +32,8 @@ export default function ScriptsPage() { const latestVersion = useScriptWorkspaceStore((s) => s.latestVersion); const latestVersionLoading = useScriptWorkspaceStore((s) => s.latestVersionLoading); + const pythonEditorBuffers = useScriptWorkspaceStore((s) => s.pythonEditorBuffers); + // store actions const setKeyword = useScriptWorkspaceStore((s) => s.setKeyword); const load = useScriptWorkspaceStore((s) => s.load); @@ -42,6 +44,10 @@ export default function ScriptsPage() { const switchTab = useScriptWorkspaceStore((s) => s.switchTab); const openScriptEditor = useScriptWorkspaceStore((s) => s.openScriptEditor); const endEditing = useScriptWorkspaceStore((s) => s.endEditing); + const openPythonEditor = useScriptWorkspaceStore((s) => s.openPythonEditor); + const setPythonEditorContent = useScriptWorkspaceStore((s) => s.setPythonEditorContent); + const savePythonEditor = useScriptWorkspaceStore((s) => s.savePythonEditor); + const exitPythonEditor = useScriptWorkspaceStore((s) => s.exitPythonEditor); const loadLatestVersion = useScriptWorkspaceStore((s) => s.loadLatestVersion); const createScript = useScriptWorkspaceStore((s) => s.createScript); const uploadScripts = useScriptWorkspaceStore((s) => s.uploadScripts); @@ -166,6 +172,20 @@ export default function ScriptsPage() { }; }, [contextMenu, closeContextMenu]); + // python editor handlers with scriptId forwarding + const handleSetPythonEditorContent = (scriptId: string, value: string) => { + setPythonEditorContent(scriptId, value); + }; + const handleSavePythonEditor = (scriptId: string) => { + void savePythonEditor(scriptId); + }; + const handleExitPythonEditor = (scriptId: string) => { + exitPythonEditor(scriptId); + }; + const handleClosePythonTab = (scriptId: string) => { + void closeTab(scriptId); + }; + // 5) handlers const handleUpload = (event: React.ChangeEvent) => { const files = Array.from(event.target.files ?? []); @@ -242,12 +262,31 @@ export default function ScriptsPage() { scriptType: s?.script_type ?? "notebook", }; })} - onOpenEditor={() => void openScriptEditor(selected)} - onEndEditing={() => void endEditing()} + onOpenEditor={() => { + if (selected.script_type === "python") { + void openPythonEditor(selected); + } else { + void openScriptEditor(selected); + } + }} + onEndEditing={() => { + if (selected && pythonEditorBuffers[selected.script_id]) { + exitPythonEditor(selected.script_id); + } else { + void endEditing(); + } + }} onClose={(scriptId, event) => void closeTab(scriptId, event)} onSwitchTab={switchTab} onNewTab={() => openCreateDialog("")} onPublish={() => openPublishDialog(selected)} + scripts={scripts} + pythonEditorBuffers={pythonEditorBuffers} + onOpenPythonEditor={() => void openPythonEditor(selected)} + onSetPythonEditorContent={handleSetPythonEditorContent} + onSavePythonEditor={handleSavePythonEditor} + onExitPythonEditor={handleExitPythonEditor} + onClosePythonTab={handleClosePythonTab} onInfo={(t) => pushToast(t)} /> ) : ( diff --git a/frontend/app/features/platform/state/scriptWorkspaceStore.ts b/frontend/app/features/platform/state/scriptWorkspaceStore.ts index 39076a3..d7077c6 100644 --- a/frontend/app/features/platform/state/scriptWorkspaceStore.ts +++ b/frontend/app/features/platform/state/scriptWorkspaceStore.ts @@ -20,6 +20,15 @@ type CachedSession = { lastActiveTime: number; }; +export type PythonEditorBuffer = { + initialContent: string | null; + content: string | null; + dirty: boolean; + saving: boolean; + initial: boolean; + loadError: string | null; +}; + // 模块级可变 holder(非响应式,避免 React 重渲) const sessionCache = new Map(); let _selectedId: string | null = null; @@ -29,6 +38,7 @@ let _editorOpenRequest = 0; let _api: WorkspaceBoundApi | null = null; let _previewController: AbortController | null = null; let _previewRequest = 0; +let _pythonEditorOpeningIds = new Set(); export const bindScriptWorkspaceApi = (api: WorkspaceBoundApi | null) => { _api = api; @@ -68,6 +78,8 @@ type State = { previewLoading: boolean; previewError: string | null; + pythonEditorBuffers: Record; + // actions setApiOnline: (online: boolean) => void; setKeyword: (keyword: string) => void; @@ -79,6 +91,11 @@ type State = { switchTab: (id: string) => void; openScriptEditor: (script: ScriptItem, showToast?: boolean) => Promise; endEditing: (closeTabFlag?: boolean, showToast?: boolean) => Promise; + openPythonEditor: (script: ScriptItem, showToast?: boolean) => Promise; + setPythonEditorContent: (scriptId: string, value: string) => void; + savePythonEditor: (scriptId: string) => Promise; + exitPythonEditor: (scriptId: string) => void; + exitAllPythonEditors: () => void; loadLatestVersion: (scriptId: string) => Promise; loadPreview: (workspaceId: string, filePath: string) => Promise; createScript: (form: NewScriptForm) => Promise; @@ -148,6 +165,8 @@ export const useScriptWorkspaceStore = create((set, get) => { previewLoading: false, previewError: null, + pythonEditorBuffers: {}, + setApiOnline: (online) => set({ apiOnline: online }), setKeyword: (keyword) => set({ keyword }), @@ -161,6 +180,7 @@ export const useScriptWorkspaceStore = create((set, get) => { _editSession = null; editSessionHandle.current = null; sessionCache.clear(); + _pythonEditorOpeningIds.clear(); set({ scripts: [], directories: [], @@ -176,6 +196,7 @@ export const useScriptWorkspaceStore = create((set, get) => { previewCodeSize: null, previewLoading: false, previewError: null, + pythonEditorBuffers: {}, }); }, @@ -236,6 +257,15 @@ export const useScriptWorkspaceStore = create((set, get) => { closeTab: async (id, event) => { event?.stopPropagation(); + const buffer = get().pythonEditorBuffers[id]; + if (buffer?.dirty && !buffer.saving) { + const name = get().scripts.find((s) => s.script_id === id)?.script_name ?? "该脚本"; + const ok = window.confirm(`当前脚本有未保存修改,确定关闭 "${name}" 吗?`); + if (!ok) return; + } + if (buffer) { + get().exitPythonEditor(id); + } if (_editSession?.script_id === id) { await get().endEditing(false, false); } @@ -255,6 +285,13 @@ export const useScriptWorkspaceStore = create((set, get) => { }, switchTab: (id) => { + const current = get().selectedId; + if (current && current !== id) { + const curBuffer = get().pythonEditorBuffers[current]; + if (curBuffer?.dirty && !curBuffer.saving) { + void get().savePythonEditor(current); + } + } if (_selectedId !== id) { _editorOpenRequest += 1; set({ editorOpenError: null }); @@ -268,6 +305,140 @@ export const useScriptWorkspaceStore = create((set, get) => { } }, + openPythonEditor: async (script, showToast = true) => { + if (!script) return; + if (_pythonEditorOpeningIds.has(script.script_id)) return; + _pythonEditorOpeningIds.add(script.script_id); + try { + const workspaceId = script.workspace_id; + const url = + `/jupyter/${workspaceId}/api/contents/${script.jupyter_path}` + + `?type=file&content=1&hash=1&format=text`; + const response = await fetch(url, { + credentials: "include", + cache: "no-store", + }); + if (!response.ok) { + throw new Error(`加载文件失败: ${response.status}`); + } + const data = await response.json(); + const content = typeof data.content === "string" ? data.content : ""; + set((state) => ({ + pythonEditorBuffers: { + ...state.pythonEditorBuffers, + [script.script_id]: { + initialContent: content, + content, + dirty: false, + saving: false, + initial: true, + loadError: null, + }, + }, + })); + } catch (error) { + const message = error instanceof Error ? error.message : "打开 Python 编辑器失败"; + set((state) => ({ + pythonEditorBuffers: { + ...state.pythonEditorBuffers, + [script.script_id]: { + initialContent: null, + content: null, + dirty: false, + saving: false, + initial: false, + loadError: message, + }, + }, + })); + pushToast("error", message); + } finally { + _pythonEditorOpeningIds.delete(script.script_id); + } + }, + + setPythonEditorContent: (scriptId, value) => { + set((state) => { + const buffer = state.pythonEditorBuffers[scriptId]; + if (!buffer) return state; + return { + pythonEditorBuffers: { + ...state.pythonEditorBuffers, + [scriptId]: { + ...buffer, + content: value, + dirty: value !== buffer.initialContent, + }, + }, + }; + }); + }, + + savePythonEditor: async (scriptId) => { + const buffer = get().pythonEditorBuffers[scriptId]; + if (!buffer || buffer.content === null || !buffer.dirty || buffer.saving) { + return; + } + const script = get().scripts.find((s) => s.script_id === scriptId); + if (!script) return; + const savedContent = buffer.content; + set((state) => ({ + pythonEditorBuffers: { + ...state.pythonEditorBuffers, + [scriptId]: { ...buffer, saving: true }, + }, + })); + try { + const api = requireApi(); + await api.updateScript(scriptId, { content: savedContent }); + set((state) => { + const current = state.pythonEditorBuffers[scriptId]; + if (!current) return state; + return { + pythonEditorBuffers: { + ...state.pythonEditorBuffers, + [scriptId]: { + ...current, + initialContent: savedContent, + dirty: current.content !== savedContent, + saving: false, + initial: false, + }, + }, + }; + }); + pushToast("success", "已保存"); + } catch (error) { + set((state) => { + const current = state.pythonEditorBuffers[scriptId]; + if (!current) return state; + return { + pythonEditorBuffers: { + ...state.pythonEditorBuffers, + [scriptId]: { ...current, saving: false }, + }, + }; + }); + pushToast( + "error", + error instanceof Error ? error.message : "保存失败", + ); + } + }, + + exitPythonEditor: (scriptId) => { + set((state) => { + const next = { ...state.pythonEditorBuffers }; + delete next[scriptId]; + return { pythonEditorBuffers: next }; + }); + }, + + exitAllPythonEditors: () => { + _pythonEditorOpeningIds.clear(); + set({ pythonEditorBuffers: {} }); + }, + openScriptEditor: async (script, showToast = true) => { if (!script) return; if (_editorOpening) return; diff --git a/frontend/app/styles/platform.css b/frontend/app/styles/platform.css index df64474..f3eae1f 100644 --- a/frontend/app/styles/platform.css +++ b/frontend/app/styles/platform.css @@ -1048,6 +1048,7 @@ button { } .editor-canvas { + position: relative; min-height: 0; flex: 1; overflow: auto; @@ -1990,6 +1991,81 @@ button { } } + +/* Save button — green primary tone for python editor */ +.editor-toolbar__actions .editor-save-button { + color: #1f6d3a; + background: #f1faf3; + border-color: #b8dec1; + font-weight: 600; +} +.editor-toolbar__actions .editor-save-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.editor-toolbar__actions .editor-save-button.is-saving { + color: #1f6d3a; + opacity: 0.85; +} +.editor-toolbar__actions .editor-save-button .button-spinner { + width: 12px; + height: 12px; +} + +/* Dirty banner — top of editor canvas */ +.editor-dirty-banner { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 14px; + background: #fff7e6; + border-bottom: 1px solid #f0d8a8; + color: #9b6a18; + font-size: 12px; + font-weight: 500; +} +.editor-dirty-banner::before { + content: "●"; + color: #d49b00; + font-size: 10px; +} + +/* Python editor wrapper */ +.python-editor { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + background: #fff; +} +.python-editor__status { + display: flex; + align-items: center; + gap: 16px; + padding: 4px 14px; + background: #f5f7fa; + border-bottom: 1px solid #e6e8ec; + font-size: 11px; + color: #6b7280; +} +.python-editor__canvas { + flex: 1; + min-height: 0; + position: relative; + overflow: hidden; +} + + +/* Python editor mount — multiple instances, only active visible */ +.python-editor-mount { + display: flex; + width: 100%; + height: 100%; + min-height: 0; + flex-direction: column; + background: #fff; +} + /* ============ 只读编辑器样式 ============ */ .readonly-editor-container { display: flex; From 6b9f8518108b8eb3ff0f29be2b7c6c3afc2edcb2 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:14:18 +0800 Subject: [PATCH 03/13] update: scripts.py api --- backend/src/backend/scripts.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/backend/src/backend/scripts.py b/backend/src/backend/scripts.py index a15b1ff..58952ec 100644 --- a/backend/src/backend/scripts.py +++ b/backend/src/backend/scripts.py @@ -11,6 +11,7 @@ from common.config import settings from common.db.models import ( Scripts, StorageObjects, + Users, Versions, ) from common.ids import new_ulid @@ -182,6 +183,8 @@ def validate_script_content(content: str, script_type: str) -> bytes: def script_payload( script: Scripts, storage_object: StorageObjects | dict[str, Any] | None, + *, + owner_display_name: str | None = None, ) -> dict[str, Any]: if storage_object is None: relative_path = None @@ -212,6 +215,7 @@ def script_payload( "workspace_id": script.workspace_id, "current_object_id": script.current_object_id, "owner_user_id": script.owner_user_id, + "owner_display_name": owner_display_name, "script_name": script.script_name, "script_type": script.script_type, "visibility": script.visibility, @@ -921,11 +925,12 @@ async def list_scripts( session: AsyncSession = Depends(database_session), ) -> dict[str, Any]: statement = ( - select(Scripts, StorageObjects) + select(Scripts, StorageObjects, Users.display_name) .join( StorageObjects, StorageObjects.storage_object_id == Scripts.current_object_id, ) + .outerjoin(Users, Users.user_id == Scripts.owner_user_id) .where( Scripts.workspace_id == context.workspace.workspace_id, Scripts.status == "active", @@ -936,7 +941,8 @@ async def list_scripts( return { "request_id": context.request_id, "data": [ - script_payload(script, storage_object) for script, storage_object in rows + script_payload(script, storage_object, owner_display_name=owner_display_name) + for script, storage_object, owner_display_name in rows ], "meta": {"count": len(rows)}, } From a09551bd3c92124ef866aa9c186f4e2c2192ea17 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:14:47 +0800 Subject: [PATCH 04/13] update: workspace members 403 --- .../components/platform/ScriptExplorer.tsx | 41 +++---------------- frontend/app/services/api.ts | 1 + 2 files changed, 6 insertions(+), 36 deletions(-) diff --git a/frontend/app/components/platform/ScriptExplorer.tsx b/frontend/app/components/platform/ScriptExplorer.tsx index 211b848..bda8c0b 100644 --- a/frontend/app/components/platform/ScriptExplorer.tsx +++ b/frontend/app/components/platform/ScriptExplorer.tsx @@ -1,8 +1,8 @@ -import { type ChangeEvent, type MouseEvent as ReactMouseEvent, type RefObject, useEffect, useMemo, useState } from "react"; +import { type ChangeEvent, type MouseEvent as ReactMouseEvent, type RefObject, useMemo } from "react"; import Icon from "../common/Icon"; import { WorkspaceTreeGroup } from "~/features/platform/WorkspaceTree"; -import type { ScriptItem, WorkspaceDirectory, WorkspaceMember } from "~/services/api"; -import { useApi, useAuth, type AuthUser } from "~/context/AuthContext"; +import type { ScriptItem, WorkspaceDirectory } from "~/services/api"; +import { type AuthUser } from "~/context/AuthContext"; type ScriptExplorerProps = { scripts: ScriptItem[]; @@ -50,37 +50,6 @@ export function ScriptExplorer({ uploadInputRef, onHandleUpload, }: ScriptExplorerProps) { - const api = useApi(); - const { currentWorkspace } = useAuth(); - const [members, setMembers] = useState([]); - - useEffect(() => { - if (!currentWorkspace) { - setMembers([]); - return; - } - let cancelled = false; - api - .listWorkspaceMembers(currentWorkspace.workspace_id) - .then((list) => { - if (!cancelled) setMembers(list); - }) - .catch(() => { - if (!cancelled) setMembers([]); - }); - return () => { - cancelled = true; - }; - }, [api, currentWorkspace]); - - const displayNameByUserId = useMemo(() => { - const map = new Map(); - for (const m of members) { - map.set(m.user_id, m.display_name || m.username || m.user_id); - } - return map; - }, [members]); - const memberScriptGroups = useMemo(() => { const visibleScripts = user?.is_system_admin === true @@ -106,7 +75,7 @@ export function ScriptExplorer({ }[] = []; for (const [ownerUserId, groupScripts] of byOwner.entries()) { const displayName = - displayNameByUserId.get(ownerUserId) ?? + groupScripts[0]?.owner_display_name ?? (ownerUserId === user?.user_id ? user?.display_name : null) ?? `${ownerUserId.slice(-6)}…`; const groupUser = @@ -139,7 +108,7 @@ export function ScriptExplorer({ }); return groups; - }, [filteredScripts, user, displayNameByUserId]); + }, [filteredScripts, user]); return (