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] 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;