From cc2da42ac1cf31bcb34ce11a36c8646500e2ba37 Mon Sep 17 00:00:00 2001 From: Ethanlita Date: Tue, 23 Jun 2026 15:41:28 +0800 Subject: [PATCH 1/5] feat(tutorial): tailor leave-editor copy and route success modal to series - Show tutorial-specific copy in the leave-editor confirm dialog when a course is active; keep the original copy otherwise - Make the course success modal's "back to course series" button navigate to the current course's series instead of /tutorials Co-Authored-By: Claude Opus 4.8 --- spx-gui/src/apps/xbuilder/pages/editor/index.vue | 16 ++++++++++++---- .../tutorials/TutorialCourseSuccessModal.vue | 8 ++++---- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/spx-gui/src/apps/xbuilder/pages/editor/index.vue b/spx-gui/src/apps/xbuilder/pages/editor/index.vue index e683866cb..1bd952b7d 100644 --- a/spx-gui/src/apps/xbuilder/pages/editor/index.vue +++ b/spx-gui/src/apps/xbuilder/pages/editor/index.vue @@ -58,6 +58,7 @@ import EditorContextProvider from '@/components/editor/EditorContextProvider.vue import ProjectEditor from '@/components/editor/ProjectEditor.vue' import { CodeEditorProvider, loadMonaco } from '@/components/editor/spx-code-editor' import { usePublishProject } from '@/components/project' +import { useTutorial } from '@/components/tutorials/tutorial' import { EditingMode, type ILocalCache } from '@/components/editor/editing' import { EditorState } from '@/components/editor/editor-state' import { cloudHelpers } from '@/models/common/cloud' @@ -84,6 +85,7 @@ const i18n = useI18n() const { t } = i18n const { isOnline } = useNetwork() const m = useMessage() +const tutorial = useTutorial() const confirmOpenTargetWithAnotherInCache = (targetName: string, cachedName: string): Promise => { return confirm({ @@ -215,15 +217,21 @@ onBeforeRouteLeave(async () => { async function checkChangesNotToBeSaved(es: EditorState) { const hasEdits = es.editing.mode === EditingMode.EffectFree && es.editing.dirty if (!hasEdits) return true + const inTutorial = tutorial.currentCourse != null return confirm({ title: t({ en: 'Leave editor', zh: '离开编辑器' }), - content: t({ - en: `Project edits will not be saved if you leave now. Are you sure to leave?`, - zh: `若现在离开,对项目的修改将不会被保存。确定要离开吗?` - }), + content: inTutorial + ? t({ + en: `Tutorial edits will not be saved if you leave now. Are you sure to leave?`, + zh: `教程中的修改不会被保存,确认要离开吗?` + }) + : t({ + 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/tutorials/TutorialCourseSuccessModal.vue b/spx-gui/src/components/tutorials/TutorialCourseSuccessModal.vue index 4894b1177..9c3563c26 100644 --- a/spx-gui/src/components/tutorials/TutorialCourseSuccessModal.vue +++ b/spx-gui/src/components/tutorials/TutorialCourseSuccessModal.vue @@ -37,9 +37,9 @@ function handleCancel() { emit('cancelled') } -function handleBrowseTutorials() { +function handleBackToCourseSeries() { emit('cancelled') - router.push('/tutorials') + router.push(`/course-series/${props.series.id}`) } const hasNextCourse = computed(() => { @@ -96,8 +96,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' }) }} From c3b510ae1423e89aec5d3477c5616ab31e446093 Mon Sep 17 00:00:00 2001 From: Ethanlita Date: Tue, 23 Jun 2026 18:14:40 +0800 Subject: [PATCH 2/5] feat(tutorial): skip leave confirmation for course navigation Add a one-shot skip flag on Tutorial so explicit, expected navigations away from the editor do not trigger the leave confirmation: - "Back to course series" in the success modal requests the skip before navigating to the series page - startCourse requests the skip before pushing the course entrypoint, which may point to a non-editor route Co-Authored-By: Claude Opus 4.8 --- .../src/apps/xbuilder/pages/editor/index.vue | 1 + .../tutorials/TutorialCourseSuccessModal.vue | 1 + spx-gui/src/components/tutorials/tutorial.ts | 17 +++++++++++++++++ 3 files changed, 19 insertions(+) diff --git a/spx-gui/src/apps/xbuilder/pages/editor/index.vue b/spx-gui/src/apps/xbuilder/pages/editor/index.vue index 1bd952b7d..f83000013 100644 --- a/spx-gui/src/apps/xbuilder/pages/editor/index.vue +++ b/spx-gui/src/apps/xbuilder/pages/editor/index.vue @@ -215,6 +215,7 @@ onBeforeRouteLeave(async () => { * If it is OK to leave, return true, otherwise return false. */ async function checkChangesNotToBeSaved(es: EditorState) { + if (tutorial.consumeSkipLeaveConfirm()) return true const hasEdits = es.editing.mode === EditingMode.EffectFree && es.editing.dirty if (!hasEdits) return true const inTutorial = tutorial.currentCourse != null diff --git a/spx-gui/src/components/tutorials/TutorialCourseSuccessModal.vue b/spx-gui/src/components/tutorials/TutorialCourseSuccessModal.vue index 9c3563c26..fd9a95fdb 100644 --- a/spx-gui/src/components/tutorials/TutorialCourseSuccessModal.vue +++ b/spx-gui/src/components/tutorials/TutorialCourseSuccessModal.vue @@ -39,6 +39,7 @@ function handleCancel() { function handleBackToCourseSeries() { emit('cancelled') + props.tutorial.requestSkipLeaveConfirm() router.push(`/course-series/${props.series.id}`) } diff --git a/spx-gui/src/components/tutorials/tutorial.ts b/spx-gui/src/components/tutorials/tutorial.ts index 6b7cc129f..1d056c715 100644 --- a/spx-gui/src/components/tutorials/tutorial.ts +++ b/spx-gui/src/components/tutorials/tutorial.ts @@ -61,6 +61,19 @@ export class Tutorial { this.abandonPredictionCountRef.value = 0 } + // One-shot flag to skip the editor's leave confirmation on the next navigation. + // Used when leaving the editor is an explicit, expected action (e.g. clicking + // "back to course series" in the course success modal), making a confirm redundant. + private skipLeaveConfirmOnce = false + requestSkipLeaveConfirm() { + this.skipLeaveConfirmOnce = true + } + consumeSkipLeaveConfirm() { + const skip = this.skipLeaveConfirmOnce + this.skipLeaveConfirmOnce = false + return skip + } + async startCourse(course: Course, series: CourseSeries): Promise { try { this.copilot.endCurrentSession() @@ -71,6 +84,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. + this.requestSkipLeaveConfirm() await this.router.push(entrypoint) await until(this.isRouteLoaded) await timeout(100) // Wait for detailed UI rendering From d22a085354d3d780f0b95fa3eb91c61cf0612ea3 Mon Sep 17 00:00:00 2001 From: Ethanlita Date: Tue, 23 Jun 2026 18:27:06 +0800 Subject: [PATCH 3/5] fix(tutorial): time-bound the leave-confirm skip to avoid a stuck flag A plain one-shot flag could leak: navigations that don't reach the editor's onBeforeRouteLeave (e.g. editor -> editor route updates when starting the next course, or starting a course from a non-editor page) never consume it, so a later genuine leave would silently skip its confirmation. Expire the skip request after skipLeaveConfirmTimeout instead. Co-Authored-By: Claude Opus 4.8 --- spx-gui/src/components/tutorials/tutorial.ts | 21 +++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/spx-gui/src/components/tutorials/tutorial.ts b/spx-gui/src/components/tutorials/tutorial.ts index 1d056c715..f03b06f1e 100644 --- a/spx-gui/src/components/tutorials/tutorial.ts +++ b/spx-gui/src/components/tutorials/tutorial.ts @@ -15,6 +15,13 @@ import { tutorialCourseAbandonDismissal, tutorialCourseAbandonPrediction } from const tutorialKey: InjectionKey = Symbol('tutorial') +// Max age (ms) for a `requestSkipLeaveConfirm` request to still suppress the editor's +// 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 +// flag would otherwise suppress a later, genuine leave confirmation. +const skipLeaveConfirmTimeout = 1000 + export function useTutorial() { const tutorial = inject(tutorialKey) if (tutorial == null) { @@ -61,16 +68,16 @@ export class Tutorial { this.abandonPredictionCountRef.value = 0 } - // One-shot flag to skip the editor's leave confirmation on the next navigation. - // Used when leaving the editor is an explicit, expected action (e.g. clicking - // "back to course series" in the course success modal), making a confirm redundant. - private skipLeaveConfirmOnce = false + // Skip the editor's leave confirmation for the navigation that immediately follows an + // explicit, expected action (e.g. clicking "back to course series" in the success modal, + // or starting a course). Time-bound via `skipLeaveConfirmTimeout` so it cannot get stuck. + private skipLeaveConfirmRequestedAt = 0 requestSkipLeaveConfirm() { - this.skipLeaveConfirmOnce = true + this.skipLeaveConfirmRequestedAt = Date.now() } consumeSkipLeaveConfirm() { - const skip = this.skipLeaveConfirmOnce - this.skipLeaveConfirmOnce = false + const skip = Date.now() - this.skipLeaveConfirmRequestedAt < skipLeaveConfirmTimeout + this.skipLeaveConfirmRequestedAt = 0 return skip } From 8fda2406ced0f5d62a6c2051e954c2705a23b241 Mon Sep 17 00:00:00 2001 From: Ethanlita Date: Wed, 24 Jun 2026 09:46:21 +0800 Subject: [PATCH 4/5] refactor(tutorial): address review feedback on success modal - Request the leave-confirm skip before emit('cancelled') so the time-bound window isn't shortened by the cancel handler - Wrap "back to course series" navigation in useMessageHandle to surface router.push rejections, consistent with handleStartNextCourse - Use sentence case for the button label: "Back to series courses" - Align the tutorial.ts comment with the actual button label Co-Authored-By: Claude Opus 4.8 --- .../tutorials/TutorialCourseSuccessModal.vue | 20 +++++++++++++------ spx-gui/src/components/tutorials/tutorial.ts | 2 +- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/spx-gui/src/components/tutorials/TutorialCourseSuccessModal.vue b/spx-gui/src/components/tutorials/TutorialCourseSuccessModal.vue index fd9a95fdb..7e84f0f5c 100644 --- a/spx-gui/src/components/tutorials/TutorialCourseSuccessModal.vue +++ b/spx-gui/src/components/tutorials/TutorialCourseSuccessModal.vue @@ -37,11 +37,19 @@ function handleCancel() { emit('cancelled') } -function handleBackToCourseSeries() { - emit('cancelled') - props.tutorial.requestSkipLeaveConfirm() - router.push(`/course-series/${props.series.id}`) -} +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. + props.tutorial.requestSkipLeaveConfirm() + 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 @@ -98,7 +106,7 @@ const { fn: handleStartNextCourse } = useMessageHandle(
- {{ $t({ zh: '返回系列课程', en: 'Back to Series Courses' }) }} + {{ $t({ zh: '返回系列课程', en: 'Back to series courses' }) }} {{ $t({ zh: '学习下一个课程', en: 'Learn next course' }) }} diff --git a/spx-gui/src/components/tutorials/tutorial.ts b/spx-gui/src/components/tutorials/tutorial.ts index f03b06f1e..8c8f5350c 100644 --- a/spx-gui/src/components/tutorials/tutorial.ts +++ b/spx-gui/src/components/tutorials/tutorial.ts @@ -69,7 +69,7 @@ export class Tutorial { } // Skip the editor's leave confirmation for the navigation that immediately follows an - // explicit, expected action (e.g. clicking "back to course series" in the success modal, + // explicit, expected action (e.g. clicking "Back to series courses" in the success modal, // or starting a course). Time-bound via `skipLeaveConfirmTimeout` so it cannot get stuck. private skipLeaveConfirmRequestedAt = 0 requestSkipLeaveConfirm() { From c2a7a2e56135bf0a9182a94cb2f5beebd0a70bef Mon Sep 17 00:00:00 2001 From: Ethanlita Date: Thu, 25 Jun 2026 16:56:32 +0800 Subject: [PATCH 5/5] refactor(editor): introduce editor-owned leave-confirm extension point Invert the dependency so the editor no longer needs tutorial knowledge. Add an editor-owned `editorLeaveConfirm` controller exposing a generic message override and a one-shot, time-bound skip. Tutorial drives it the same way it drives copilot (TutorialRoot sets the tutorial-specific copy while a course is active; startCourse and the success modal request the skip), and the editor page's leave guard only reads the controller. The editor stays unaware of tutorials (no components/editor -> tutorials import), and pages/editor no longer imports useTutorial. Co-Authored-By: Claude Opus 4.8 --- .../src/apps/xbuilder/pages/editor/index.vue | 21 ++++----- .../src/components/editor/leave-confirm.ts | 47 +++++++++++++++++++ .../tutorials/TutorialCourseSuccessModal.vue | 3 +- .../src/components/tutorials/TutorialRoot.vue | 9 ++++ spx-gui/src/components/tutorials/tutorial.ts | 23 +-------- 5 files changed, 68 insertions(+), 35 deletions(-) create mode 100644 spx-gui/src/components/editor/leave-confirm.ts diff --git a/spx-gui/src/apps/xbuilder/pages/editor/index.vue b/spx-gui/src/apps/xbuilder/pages/editor/index.vue index f83000013..f0a9973c3 100644 --- a/spx-gui/src/apps/xbuilder/pages/editor/index.vue +++ b/spx-gui/src/apps/xbuilder/pages/editor/index.vue @@ -58,8 +58,8 @@ import EditorContextProvider from '@/components/editor/EditorContextProvider.vue import ProjectEditor from '@/components/editor/ProjectEditor.vue' import { CodeEditorProvider, loadMonaco } from '@/components/editor/spx-code-editor' import { usePublishProject } from '@/components/project' -import { useTutorial } from '@/components/tutorials/tutorial' 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' @@ -85,7 +85,6 @@ const i18n = useI18n() const { t } = i18n const { isOnline } = useNetwork() const m = useMessage() -const tutorial = useTutorial() const confirmOpenTargetWithAnotherInCache = (targetName: string, cachedName: string): Promise => { return confirm({ @@ -215,24 +214,20 @@ onBeforeRouteLeave(async () => { * If it is OK to leave, return true, otherwise return false. */ async function checkChangesNotToBeSaved(es: EditorState) { - if (tutorial.consumeSkipLeaveConfirm()) return true + if (editorLeaveConfirm.consumeSkipOnce()) return true const hasEdits = es.editing.mode === EditingMode.EffectFree && es.editing.dirty if (!hasEdits) return true - const inTutorial = tutorial.currentCourse != null return confirm({ title: t({ en: 'Leave editor', zh: '离开编辑器' }), - content: inTutorial - ? t({ - en: `Tutorial edits will not be saved if you leave now. Are you sure to leave?`, - zh: `教程中的修改不会被保存,确认要离开吗?` - }) - : 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 7e84f0f5c..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' @@ -41,7 +42,7 @@ 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. - props.tutorial.requestSkipLeaveConfirm() + editorLeaveConfirm.requestSkipOnce() emit('cancelled') await router.push(`/course-series/${props.series.id}`) }, 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 8c8f5350c..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' @@ -15,13 +16,6 @@ import { tutorialCourseAbandonDismissal, tutorialCourseAbandonPrediction } from const tutorialKey: InjectionKey = Symbol('tutorial') -// Max age (ms) for a `requestSkipLeaveConfirm` request to still suppress the editor's -// 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 -// flag would otherwise suppress a later, genuine leave confirmation. -const skipLeaveConfirmTimeout = 1000 - export function useTutorial() { const tutorial = inject(tutorialKey) if (tutorial == null) { @@ -68,19 +62,6 @@ export class Tutorial { this.abandonPredictionCountRef.value = 0 } - // Skip the editor's leave confirmation for the navigation that immediately follows an - // explicit, expected action (e.g. clicking "Back to series courses" in the success modal, - // or starting a course). Time-bound via `skipLeaveConfirmTimeout` so it cannot get stuck. - private skipLeaveConfirmRequestedAt = 0 - requestSkipLeaveConfirm() { - this.skipLeaveConfirmRequestedAt = Date.now() - } - consumeSkipLeaveConfirm() { - const skip = Date.now() - this.skipLeaveConfirmRequestedAt < skipLeaveConfirmTimeout - this.skipLeaveConfirmRequestedAt = 0 - return skip - } - async startCourse(course: Course, series: CourseSeries): Promise { try { this.copilot.endCurrentSession() @@ -94,7 +75,7 @@ export class Tutorial { // 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. - this.requestSkipLeaveConfirm() + editorLeaveConfirm.requestSkipOnce() await this.router.push(entrypoint) await until(this.isRouteLoaded) await timeout(100) // Wait for detailed UI rendering