diff --git a/spx-gui/src/apps/xbuilder/pages/editor/index.vue b/spx-gui/src/apps/xbuilder/pages/editor/index.vue index e683866cb..f0a9973c3 100644 --- a/spx-gui/src/apps/xbuilder/pages/editor/index.vue +++ b/spx-gui/src/apps/xbuilder/pages/editor/index.vue @@ -59,6 +59,7 @@ import ProjectEditor from '@/components/editor/ProjectEditor.vue' import { CodeEditorProvider, loadMonaco } from '@/components/editor/spx-code-editor' import { usePublishProject } from '@/components/project' import { EditingMode, type ILocalCache } from '@/components/editor/editing' +import { editorLeaveConfirm } from '@/components/editor/leave-confirm' import { EditorState } from '@/components/editor/editor-state' import { cloudHelpers } from '@/models/common/cloud' import { localHelpers, type LocalHelpers } from '@/models/common/local' @@ -213,6 +214,7 @@ onBeforeRouteLeave(async () => { * If it is OK to leave, return true, otherwise return false. */ async function checkChangesNotToBeSaved(es: EditorState) { + if (editorLeaveConfirm.consumeSkipOnce()) return true const hasEdits = es.editing.mode === EditingMode.EffectFree && es.editing.dirty if (!hasEdits) return true return confirm({ @@ -220,10 +222,12 @@ async function checkChangesNotToBeSaved(es: EditorState) { en: 'Leave editor', zh: '离开编辑器' }), - content: t({ - en: `Project edits will not be saved if you leave now. Are you sure to leave?`, - zh: `若现在离开,对项目的修改将不会被保存。确定要离开吗?` - }), + content: t( + editorLeaveConfirm.messageOverride ?? { + en: `Project edits will not be saved if you leave now. Are you sure to leave?`, + zh: `若现在离开,对项目的修改将不会被保存。确定要离开吗?` + } + ), cancelText: t({ en: 'Keep editing', zh: '继续编辑' diff --git a/spx-gui/src/components/editor/leave-confirm.ts b/spx-gui/src/components/editor/leave-confirm.ts new file mode 100644 index 000000000..c33ecf917 --- /dev/null +++ b/spx-gui/src/components/editor/leave-confirm.ts @@ -0,0 +1,47 @@ +import { shallowRef } from 'vue' + +import type { LocaleMessage } from '@/utils/i18n' + +// Max age (ms) for a skip request to still suppress the leave confirmation. Time-bound so a +// request that is never consumed cannot get "stuck": some navigations (e.g. editor -> editor +// route updates, or starting a course from a non-editor page) never reach the editor's +// `onBeforeRouteLeave`, and an un-expiring request would otherwise suppress a later, genuine +// leave confirmation. +const skipConfirmTimeout = 1000 + +/** + * Controls the editor's "leave editor" confirmation. + * + * This is an editor-owned extension point: other features (e.g. tutorials) can drive the + * editor's leave behavior without the editor depending on them. The editor only exposes + * generic capabilities here and stays ignorant of who is driving it. + * + * - `messageOverride`: replace the confirmation message (e.g. tutorial-specific copy). + * - `requestSkipOnce` / `consumeSkipOnce`: skip the confirmation for a single, expected + * navigation (e.g. starting a course, or returning to the course series). + */ +class EditorLeaveConfirm { + private messageOverrideRef = shallowRef(null) + + get messageOverride(): LocaleMessage | null { + return this.messageOverrideRef.value + } + setMessageOverride(message: LocaleMessage | null) { + this.messageOverrideRef.value = message + } + + private skipRequestedAt = 0 + requestSkipOnce() { + this.skipRequestedAt = Date.now() + } + consumeSkipOnce() { + const skip = Date.now() - this.skipRequestedAt < skipConfirmTimeout + this.skipRequestedAt = 0 + return skip + } +} + +// App-level single instance: there is only ever one editor leave confirmation in play, and +// the drivers (tutorial, success modal) live outside the editor component tree, so they reach +// it by import rather than provide/inject. +export const editorLeaveConfirm = new EditorLeaveConfirm() diff --git a/spx-gui/src/components/tutorials/TutorialCourseSuccessModal.vue b/spx-gui/src/components/tutorials/TutorialCourseSuccessModal.vue index 4894b1177..58accbe80 100644 --- a/spx-gui/src/components/tutorials/TutorialCourseSuccessModal.vue +++ b/spx-gui/src/components/tutorials/TutorialCourseSuccessModal.vue @@ -8,6 +8,7 @@ import type { CourseSeries } from '@/apis/course-series' import { useI18n } from '@/utils/i18n' import { UIButton, UIImg, UIModal, UIModalClose } from '@/components/ui' +import { editorLeaveConfirm } from '@/components/editor/leave-confirm' import { DefaultException, useMessageHandle } from '@/utils/exception' import successImg from './success.png' @@ -37,10 +38,19 @@ function handleCancel() { emit('cancelled') } -function handleBrowseTutorials() { - emit('cancelled') - router.push('/tutorials') -} +const { fn: handleBackToCourseSeries } = useMessageHandle( + async () => { + // Request the skip before anything else so the time-bound window isn't shortened + // by work in the `cancelled` handler. + editorLeaveConfirm.requestSkipOnce() + emit('cancelled') + await router.push(`/course-series/${props.series.id}`) + }, + { + en: 'Failed to go back to course series', + zh: '返回系列课程失败' + } +) const hasNextCourse = computed(() => { const currentCourse = props.course @@ -96,8 +106,8 @@ const { fn: handleStartNextCourse } = useMessageHandle(
{{ courseCompleteMessage }}
- - {{ $t({ zh: '浏览所有课程', en: 'Browse all courses' }) }} + + {{ $t({ zh: '返回系列课程', en: 'Back to series courses' }) }} {{ $t({ zh: '学习下一个课程', en: 'Learn next course' }) }} diff --git a/spx-gui/src/components/tutorials/TutorialRoot.vue b/spx-gui/src/components/tutorials/TutorialRoot.vue index 1f3938730..892fb3e55 100644 --- a/spx-gui/src/components/tutorials/TutorialRoot.vue +++ b/spx-gui/src/components/tutorials/TutorialRoot.vue @@ -7,6 +7,7 @@ import { useIsRouteLoaded } from '@/utils/route-loading' import { isTutorialTopic, provideTutorial, Tutorial } from './tutorial' import { useCopilot } from '@/components/copilot/context' +import { editorLeaveConfirm } from '@/components/editor/leave-confirm' import * as tutorialCourseSuccess from './TutorialCourseSuccess.vue' import * as tutorialCourseExitLink from './TutorialCourseExitLink' import * as tutorialStateIndicator from './TutorialStateIndicator.vue' @@ -27,7 +28,15 @@ watch( (currentCourse, _, onCleanup) => { if (currentCourse == null) return + // Drive the editor's leave confirmation with tutorial-specific copy while a course is + // active; the editor exposes the override and stays unaware of tutorials. + editorLeaveConfirm.setMessageOverride({ + en: 'Tutorial edits will not be saved if you leave now. Are you sure to leave?', + zh: '教程中的修改不会被保存,确认要离开吗?' + }) + const disposers = [ + () => editorLeaveConfirm.setMessageOverride(null), copilot.registerCustomElement({ tagName: tutorialCourseSuccess.tagName, description: tutorialCourseSuccess.detailedDescription, diff --git a/spx-gui/src/components/tutorials/tutorial.ts b/spx-gui/src/components/tutorials/tutorial.ts index 6b7cc129f..86a3f8dad 100644 --- a/spx-gui/src/components/tutorials/tutorial.ts +++ b/spx-gui/src/components/tutorials/tutorial.ts @@ -6,6 +6,7 @@ import { timeout, until } from '@/utils/utils' import { userSessionStorageRef } from '@/utils/user-storage' import type { Copilot, Topic } from '@/components/copilot/copilot' import { tagName as highlightLinkTagName } from '@/components/copilot/markdown-elements/HighlightLink.vue' +import { editorLeaveConfirm } from '@/components/editor/leave-confirm' import type { Course } from '@/apis/course' import type { CourseSeries } from '@/apis/course-series' @@ -71,6 +72,10 @@ export class Tutorial { const { entrypoint } = course if (entrypoint) { + // A course entrypoint may point to a non-editor route. Navigating away from the + // editor would otherwise trigger its leave confirmation, but starting a course is + // an explicit, expected action, so skip the confirmation for this navigation. + editorLeaveConfirm.requestSkipOnce() await this.router.push(entrypoint) await until(this.isRouteLoaded) await timeout(100) // Wait for detailed UI rendering