From 3dd96b21b7395dd5ba074d412b0297a35dbc4d0b Mon Sep 17 00:00:00 2001 From: Stakataka_3030 Date: Wed, 30 Sep 2026 17:15:00 +0800 Subject: [PATCH 01/12] fix: guard scene loading and autosave state --- packages/origine2/src/locales/en.po | 12 ++ packages/origine2/src/locales/ja.po | 12 ++ packages/origine2/src/locales/ko.po | 12 ++ packages/origine2/src/locales/zhCn.po | 12 ++ .../pages/editor/TextEditor/TextEditor.tsx | 115 +++++++++++++----- .../editor/TextEditor/textEditor.module.scss | 14 +++ 6 files changed, 147 insertions(+), 30 deletions(-) diff --git a/packages/origine2/src/locales/en.po b/packages/origine2/src/locales/en.po index 9c1b4326d..b5e0056b2 100644 --- a/packages/origine2/src/locales/en.po +++ b/packages/origine2/src/locales/en.po @@ -761,9 +761,15 @@ msgstr "Scene" msgid "场景与分支" msgstr "Scenes and branches" +msgid "场景保存失败,请重试" +msgstr "Failed to save the scene. Please retry." + msgid "场景文件" msgstr "Scene file" +msgid "场景读取失败,请检查文件是否存在后重试" +msgstr "Failed to load the scene. Check that the file exists and retry." + msgid "场景返回" msgstr "Return from scene" @@ -1565,6 +1571,9 @@ msgstr "Uploading" msgid "正在加载模板配置" msgstr "Loading template configuration" +msgid "正在读取场景…" +msgstr "Loading scene…" + msgid "正在读取用户数据设置" msgstr "Reading user data settings" @@ -2405,6 +2414,9 @@ msgstr "Rename template directory" msgid "重命名游戏目录" msgstr "Rename game directory" +msgid "重试" +msgstr "Retry" + msgid "鉴赏" msgstr "Appreciation" diff --git a/packages/origine2/src/locales/ja.po b/packages/origine2/src/locales/ja.po index 9394358a1..75f798361 100644 --- a/packages/origine2/src/locales/ja.po +++ b/packages/origine2/src/locales/ja.po @@ -761,9 +761,15 @@ msgstr "シーン" msgid "场景与分支" msgstr "シーンとブランチ" +msgid "场景保存失败,请重试" +msgstr "シーンの保存に失敗しました。再試行してください。" + msgid "场景文件" msgstr "シーンファイル" +msgid "场景读取失败,请检查文件是否存在后重试" +msgstr "シーンの読み込みに失敗しました。ファイルが存在することを確認して再試行してください。" + msgid "场景返回" msgstr "シーンから戻る" @@ -1565,6 +1571,9 @@ msgstr "アップロード中" msgid "正在加载模板配置" msgstr "テンプレート設定を読み込み中" +msgid "正在读取场景…" +msgstr "シーンを読み込み中…" + msgid "正在读取用户数据设置" msgstr "ユーザーデータ設定を読み込み中" @@ -2405,6 +2414,9 @@ msgstr "テンプレート名変更" msgid "重命名游戏目录" msgstr "ゲームディレクトリ名変更" +msgid "重试" +msgstr "再試行" + msgid "鉴赏" msgstr "鑑賞" diff --git a/packages/origine2/src/locales/ko.po b/packages/origine2/src/locales/ko.po index 99056f141..ad3c66c67 100644 --- a/packages/origine2/src/locales/ko.po +++ b/packages/origine2/src/locales/ko.po @@ -761,9 +761,15 @@ msgstr "장면" msgid "场景与分支" msgstr "장면 및 분기" +msgid "场景保存失败,请重试" +msgstr "장면 저장에 실패했습니다. 다시 시도하세요." + msgid "场景文件" msgstr "장면 파일" +msgid "场景读取失败,请检查文件是否存在后重试" +msgstr "장면을 불러오지 못했습니다. 파일이 있는지 확인한 후 다시 시도하세요." + msgid "场景返回" msgstr "씬 반환" @@ -1565,6 +1571,9 @@ msgstr "업로드 중" msgid "正在加载模板配置" msgstr "템플릿 구성을 불러오는 중" +msgid "正在读取场景…" +msgstr "장면을 불러오는 중…" + msgid "正在读取用户数据设置" msgstr "사용자 데이터 설정을 읽는 중" @@ -2405,6 +2414,9 @@ msgstr "템플릿 목록의 이름 바꾸기" msgid "重命名游戏目录" msgstr "게임 목록의 이름 바꾸기" +msgid "重试" +msgstr "다시 시도" + msgid "鉴赏" msgstr "감상(갤러리)" diff --git a/packages/origine2/src/locales/zhCn.po b/packages/origine2/src/locales/zhCn.po index d06242210..42d41da97 100644 --- a/packages/origine2/src/locales/zhCn.po +++ b/packages/origine2/src/locales/zhCn.po @@ -761,9 +761,15 @@ msgstr "场景" msgid "场景与分支" msgstr "场景与分支" +msgid "场景保存失败,请重试" +msgstr "场景保存失败,请重试" + msgid "场景文件" msgstr "场景文件" +msgid "场景读取失败,请检查文件是否存在后重试" +msgstr "场景读取失败,请检查文件是否存在后重试" + msgid "场景返回" msgstr "场景返回" @@ -1565,6 +1571,9 @@ msgstr "正在上传" msgid "正在加载模板配置" msgstr "正在加载模板配置" +msgid "正在读取场景…" +msgstr "正在读取场景…" + msgid "正在读取用户数据设置" msgstr "正在读取用户数据设置" @@ -2405,6 +2414,9 @@ msgstr "重命名模板目录" msgid "重命名游戏目录" msgstr "重命名游戏目录" +msgid "重试" +msgstr "重试" + msgid "鉴赏" msgstr "鉴赏" diff --git a/packages/origine2/src/pages/editor/TextEditor/TextEditor.tsx b/packages/origine2/src/pages/editor/TextEditor/TextEditor.tsx index c3c2e5ce2..f48e3221e 100644 --- a/packages/origine2/src/pages/editor/TextEditor/TextEditor.tsx +++ b/packages/origine2/src/pages/editor/TextEditor/TextEditor.tsx @@ -13,7 +13,9 @@ import { eventBus } from '@/utils/eventBus'; import useEditorStore from '@/store/useEditorStore'; import { useGameEditorContext } from '@/store/useGameEditorStore'; import { api } from '@/api'; -import { useValue } from "@/hooks/useValue"; +import { t } from '@lingui/macro'; +import { useValue } from '@/hooks/useValue'; +import { Button } from '@fluentui/react-components'; // 最近一次通过点击光标同步到引擎的场景,所有文本编辑器标签页共享 let lastClickSyncedScenePath = ''; @@ -26,10 +28,19 @@ interface ITextEditorProps { export default function TextEditor(props: ITextEditorProps) { const target = useGameEditorContext((state) => state.currentTag); const tags = useGameEditorContext((state) => state.tags); - const currentText = useRef('Loading Scene Data......'); + // 加载状态只在界面展示,不能作为场景文本进入保存流程。 + const currentText = useRef(''); const sceneName = tags.find((e) => e.path === target?.path)!.name; const isAutoWarp = useEditorStore.use.isAutoWarp(); - const isEditorReady = useValue(false); // 读取完脚本才能算准备就绪 + const isEditorReady = useValue(false); + const loadError = useValue(null); + const loadedPath = useRef(null); + const applyingRemoteText = useRef(false); + const request = useRef(null); + const requestSequence = useRef(0); + const editVersion = useRef(0); + const savedVersion = useRef(0); + const saveQueue = useRef>(Promise.resolve()); // 准备获取 Monaco // 建立 Ref @@ -52,6 +63,7 @@ export default function TextEditor(props: ITextEditorProps) { const editorValue = editor.getValue(); const targetValue = editorValue.split('\n')[event.position.lineNumber - 1]; if (event.reason === monaco.editor.CursorChangeReason.Explicit) { + if (loadedPath.current !== props.targetPath) return; const scenePath = target?.path ?? ''; // 切换标签页后引擎仍停留在其他场景,此时即使点击的是已记录的同一行也需要同步 const isSceneChanged = scenePath !== lastClickSyncedScenePath; @@ -119,27 +131,35 @@ export default function TextEditor(props: ITextEditorProps) { * @param {string} value * @param {any} ev */ - const submitChange = useMemo(() => debounce((value: string | undefined, ev: monaco.editor.IModelContentChangedEvent) => { + const submitChange = useMemo(() => debounce((value: string, version: number) => { logger.debug('编辑器提交更新'); // 这里直接使用临时储存的行数, 一般来说光标位置就在改变的行 const lineNumber = editorLineHolder.getSceneLine(props.targetPath); - // const lineNumber = ev.changes[0].range.startLineNumber; - // const trueLineNumber = getTrueLinenumber(lineNumber, value ?? ""); - if (value || value === '') currentText.current = value; - eventBus.emit('editor:update-scene', { scene: currentText.current }); - api.assetsControllerEditTextFile({textFile: currentText.current, path: props.targetPath}).then((res) => { - const targetValue = currentText.current.split('\n')[lineNumber - 1]; + eventBus.emit('editor:update-scene', { scene: value }); + // 按修改顺序保存,避免较早的请求后完成并覆盖较新的内容。 + saveQueue.current = saveQueue.current.then(() => api.assetsControllerEditTextFile({textFile: value, path: props.targetPath})).then((res) => { + // 后端写入失败时仍返回成功状态码,需同时检查写入结果。 + if ((res.data as unknown) !== 'Updated.') throw new Error('Scene save failed'); + if (loadedPath.current !== props.targetPath) return; + savedVersion.current = Math.max(savedVersion.current, version); + if (version === editVersion.current) loadError.set(null); + const targetValue = value.split('\n')[lineNumber - 1]; EditorPreviewClient.sendSyncScene({ scenePath: target?.path ?? '', lineNumber, lineCommandString: targetValue, }); + }).catch(() => { + if (loadedPath.current !== props.targetPath) return; + loadError.set(t`场景保存失败,请重试`); }); }, 500), [props.targetPath, target?.path]); - const handleChange = (value: string | undefined, ev: monaco.editor.IModelContentChangedEvent) => { - if (!isEditorReady.value) return; - submitChange(value, ev); + const handleChange = (value: string | undefined) => { + if (loadedPath.current !== props.targetPath || applyingRemoteText.current || value === undefined) return; + currentText.current = value; + editVersion.current += 1; + submitChange(value, editVersion.current); }; useEffect(() => { @@ -147,6 +167,7 @@ export default function TextEditor(props: ITextEditorProps) { }, [submitChange]); const syncCurrentLine = useCallback(() => { + if (loadedPath.current !== props.targetPath) return; const lineNumber = editorLineHolder.getSceneLine(props.targetPath) || editorRef.current?.getPosition()?.lineNumber || 1; EditorPreviewClient.sendSyncScene({ scenePath: target?.path ?? '', @@ -163,34 +184,55 @@ export default function TextEditor(props: ITextEditorProps) { }; }, [syncCurrentLine]); - function updateEditData() { + const updateEditData = useCallback(() => { const path = props.targetPath; + const model = editorRef.current?.getModel(); + // 焦点事件可能早于 Monaco 挂载,尚未保存的修改也不能被磁盘内容覆盖。 + if (!model || editVersion.current !== savedVersion.current) return; + request.current?.abort(); + const controller = new AbortController(); + request.current = controller; + const sequence = ++requestSequence.current; + const version = editVersion.current; + loadError.set(null); axios - .get(path) + .get(path, { + signal: controller.signal, + timeout: 15000, + responseType: 'text', + transformResponse: [(data) => data], + }) .then((res) => res.data) .then((data) => { - const dataStr = data.toString(); - if (dataStr === currentText.current) { - return; - } - currentText.current = dataStr; - eventBus.emit('editor:update-scene', { scene: dataStr }); - const model = editorRef.current?.getModel(); - model?.applyEdits([ - { - range: model.getFullModelRange(), - text: currentText.current, - forceMoveMarkers: true + if (controller.signal.aborted || sequence !== requestSequence.current || version !== editVersion.current || editorRef.current?.getModel() !== model) return; + if (typeof data !== 'string') throw new Error('Invalid scene text response'); + applyingRemoteText.current = true; + try { + if (model.getValue() !== data) { + model.applyEdits([{ range: model.getFullModelRange(), text: data, forceMoveMarkers: true }]); } - ]); + } finally { + applyingRemoteText.current = false; + } + currentText.current = data; + loadedPath.current = path; isEditorReady.value = true; + eventBus.emit('editor:update-scene', { scene: data }); const targetPosition = editorLineHolder.getScenePosition(props.targetPath); editorRef?.current?.setPosition(targetPosition); editorRef?.current?.revealPositionInCenterIfOutsideViewport(targetPosition, monaco.editor.ScrollType.Immediate); + }).catch(() => { + if (controller.signal.aborted || sequence !== requestSequence.current || version !== editVersion.current) return; + loadError.set(t`场景读取失败,请检查文件是否存在后重试`); }); - } + }, [props.targetPath]); useEffect(() => { + loadedPath.current = null; + isEditorReady.value = false; + editVersion.current = 0; + savedVersion.current = 0; + updateEditData(); const handleVisibilityChange = () => { if (document.visibilityState === 'visible') { updateEditData(); @@ -201,10 +243,13 @@ export default function TextEditor(props: ITextEditorProps) { document.addEventListener('visibilitychange', handleVisibilityChange); return () => { + request.current?.abort(); + requestSequence.current += 1; + loadedPath.current = null; window.removeEventListener('focus', handleVisibilityChange); document.removeEventListener('visibilitychange', handleVisibilityChange); }; - }, []); + }, [updateEditData]); return (
+ {(!isEditorReady.value || loadError.value) &&
+ {loadError.value ?? t`正在读取场景…`} + {loadError.value && } +
}
); } diff --git a/packages/origine2/src/pages/editor/TextEditor/textEditor.module.scss b/packages/origine2/src/pages/editor/TextEditor/textEditor.module.scss index c5a28d682..99abf5775 100644 --- a/packages/origine2/src/pages/editor/TextEditor/textEditor.module.scss +++ b/packages/origine2/src/pages/editor/TextEditor/textEditor.module.scss @@ -1,4 +1,18 @@ .textEditor_main{ + position: relative; height: 100%; width: 100%; } + +.textEditor_status{ + position: absolute; + top: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + gap: 8px; + padding: 12px; + color: var(--colorNeutralForeground1); + background: var(--colorNeutralBackground1); +} From 41ac5c7393e818b4dd54aa7638e7f76ce453849f Mon Sep 17 00:00:00 2001 From: Mahiru Date: Thu, 1 Oct 2026 10:44:14 +0800 Subject: [PATCH 02/12] =?UTF-8?q?fix:=20=E6=96=87=E4=BB=B6=E6=9C=AA?= =?UTF-8?q?=E5=8F=98=E6=9B=B4=E6=97=B6=EF=BC=8C=E4=B8=8D=E8=A6=81=E5=9C=A8?= =?UTF-8?q?=E5=88=87=E5=9B=9E=E7=AA=97=E5=8F=A3=E6=97=B6=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8=E8=A7=86=E5=9B=BE=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/origine2/src/pages/editor/TextEditor/TextEditor.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/origine2/src/pages/editor/TextEditor/TextEditor.tsx b/packages/origine2/src/pages/editor/TextEditor/TextEditor.tsx index f48e3221e..e72834b54 100644 --- a/packages/origine2/src/pages/editor/TextEditor/TextEditor.tsx +++ b/packages/origine2/src/pages/editor/TextEditor/TextEditor.tsx @@ -206,6 +206,8 @@ export default function TextEditor(props: ITextEditorProps) { .then((data) => { if (controller.signal.aborted || sequence !== requestSequence.current || version !== editVersion.current || editorRef.current?.getModel() !== model) return; if (typeof data !== 'string') throw new Error('Invalid scene text response'); + // 已加载且磁盘内容未变时直接返回,保留用户的选区与滚动位置。 + if (loadedPath.current === path && model.getValue() === data) return; applyingRemoteText.current = true; try { if (model.getValue() !== data) { From 4f54d93d4ccde20d160953c6587d5b3486e97ea9 Mon Sep 17 00:00:00 2001 From: Mahiru Date: Thu, 1 Oct 2026 10:49:41 +0800 Subject: [PATCH 03/12] =?UTF-8?q?=E8=B0=83=E6=95=B4=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E6=B5=AE=E5=B1=82=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/editor/TextEditor/TextEditor.tsx | 2 +- .../editor/TextEditor/textEditor.module.scss | 18 ++++++++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/origine2/src/pages/editor/TextEditor/TextEditor.tsx b/packages/origine2/src/pages/editor/TextEditor/TextEditor.tsx index e72834b54..0429cb20a 100644 --- a/packages/origine2/src/pages/editor/TextEditor/TextEditor.tsx +++ b/packages/origine2/src/pages/editor/TextEditor/TextEditor.tsx @@ -270,7 +270,7 @@ export default function TextEditor(props: ITextEditorProps) { /> {(!isEditorReady.value || loadError.value) &&
{loadError.value ?? t`正在读取场景…`} - {loadError.value &&