From 7443bb3260f5b0bdd94b735ad1c93700fc89fd10 Mon Sep 17 00:00:00 2001 From: liangxu Date: Thu, 7 May 2026 12:07:51 +0800 Subject: [PATCH] feat(templates): confirm before leaving wizard with draft content Intercept route changes and tab close when the template wizard has unsubmitted content so users can save a draft, discard, or stay instead of silently losing their progress. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/admin-web/src/i18n/messages/en-US.ts | 7 + apps/admin-web/src/i18n/messages/zh-CN.ts | 7 + .../src/views/TemplateWizardView.vue | 152 ++++++++++++++++-- 3 files changed, 156 insertions(+), 10 deletions(-) diff --git a/apps/admin-web/src/i18n/messages/en-US.ts b/apps/admin-web/src/i18n/messages/en-US.ts index 2d082f6..bd1fc59 100644 --- a/apps/admin-web/src/i18n/messages/en-US.ts +++ b/apps/admin-web/src/i18n/messages/en-US.ts @@ -939,6 +939,13 @@ const enUS = { queued: 'Task submitted. The article is now waiting in the generation queue.', submitted: 'Generation task submitted. Refreshing article data.', }, + leaveConfirm: { + title: 'Save as draft?', + content: 'This template wizard has content. Save it as a draft before leaving.', + stay: 'Keep editing', + discard: 'Leave without saving', + save: 'Save draft', + }, }, }, article: { diff --git a/apps/admin-web/src/i18n/messages/zh-CN.ts b/apps/admin-web/src/i18n/messages/zh-CN.ts index 4ec3900..6c669d3 100644 --- a/apps/admin-web/src/i18n/messages/zh-CN.ts +++ b/apps/admin-web/src/i18n/messages/zh-CN.ts @@ -905,6 +905,13 @@ const zhCN = { queued: "任务已提交,正在排队生成", submitted: "生成任务已提交,正在刷新文章列表。", }, + leaveConfirm: { + title: "是否保存为草稿?", + content: "当前模版向导已有填写内容。离开前可以保存为草稿,稍后继续编辑。", + stay: "继续编辑", + discard: "不保存离开", + save: "保存草稿", + }, }, }, article: { diff --git a/apps/admin-web/src/views/TemplateWizardView.vue b/apps/admin-web/src/views/TemplateWizardView.vue index d45f326..394c4d9 100644 --- a/apps/admin-web/src/views/TemplateWizardView.vue +++ b/apps/admin-web/src/views/TemplateWizardView.vue @@ -15,9 +15,9 @@ import type { } from '@geo/shared-types' import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' import { message } from 'ant-design-vue' -import { computed, nextTick, ref, watch } from 'vue' +import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' -import { useRoute, useRouter } from 'vue-router' +import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router' import KnowledgeGroupSelect from '@/components/KnowledgeGroupSelect.vue' import { articlesApi, brandsApi, normalizeInputParams, templatesApi } from '@/lib/api' @@ -151,6 +151,9 @@ const router = useRouter() let competitorKeySeed = 0 let outlineNodeSeed = 0 +let allowWizardLeave = false +let pendingLeavePromise: Promise | null = null +let pendingLeaveResolve: ((canLeave: boolean) => void) | null = null const CUSTOM_OUTLINE_MAX_LENGTH = 15 const currentStep = ref(0) @@ -158,6 +161,8 @@ const articleLocale = ref('zh-CN') const currentArticleId = ref(null) const restoringDraft = ref(false) const savingDraft = ref(false) +const leaveConfirmOpen = ref(false) +const leaveConfirmBusy = ref(false) const selectedBrandId = ref(null) const brandName = ref('') @@ -248,6 +253,7 @@ const mutation = useMutation({ queryClient.invalidateQueries({ queryKey: ['templates'] }), ]) message.info(t('templates.wizard.messages.queued')) + allowWizardLeave = true await router.replace('/articles/templates') }, onError: (error) => { @@ -410,6 +416,22 @@ const isPageLoading = computed( templateDetailQuery.isPending.value || (draftQueryEnabled.value && draftArticleQuery.isPending.value), ) +const hasWizardDraftContent = computed( + () => + Boolean(selectedBrandId.value) || + Boolean(brandName.value.trim()) || + Boolean(officialWebsite.value.trim()) || + Boolean(brandSummary.value.trim()) || + keywordDrafts.value.some((item) => item.trim()) || + competitorDrafts.value.some(hasCompetitorDraftContent) || + assistTitles.value.length > 0 || + Boolean(customTitle.value.trim()) || + Boolean(keyPoints.value.trim()) || + selectedKnowledgeGroupIds.value.length > 0 || + customOutlineSections.value.some((item) => item.label.trim()) || + Boolean(customOutlineInput.value.trim()) || + generatedOutline.value.some(hasOutlineDraftContent), +) const assistStages = computed(() => assistTaskKind.value === 'analyze' ? [ @@ -621,6 +643,87 @@ watch(selectedBrandId, async (brandId) => { })) }) +onBeforeRouteLeave(async () => { + if (!shouldConfirmBeforeLeavingWizard()) { + return true + } + return requestLeaveConfirmation() +}) + +onMounted(() => { + window.addEventListener('beforeunload', handleBeforeUnload) +}) + +onBeforeUnmount(() => { + window.removeEventListener('beforeunload', handleBeforeUnload) +}) + +function shouldConfirmBeforeLeavingWizard(): boolean { + return enabled.value && !allowWizardLeave && hasWizardDraftContent.value +} + +function requestLeaveConfirmation(): Promise { + if (pendingLeavePromise) { + return pendingLeavePromise + } + + leaveConfirmOpen.value = true + pendingLeavePromise = new Promise((resolve) => { + pendingLeaveResolve = resolve + }) + return pendingLeavePromise +} + +function resolveLeaveConfirmation(canLeave: boolean): void { + leaveConfirmOpen.value = false + leaveConfirmBusy.value = false + const resolve = pendingLeaveResolve + pendingLeavePromise = null + pendingLeaveResolve = null + resolve?.(canLeave) +} + +async function handleSaveDraftAndLeave(): Promise { + if (leaveConfirmBusy.value) { + return + } + + leaveConfirmBusy.value = true + const saved = await saveWizardDraft() + if (saved) { + allowWizardLeave = true + resolveLeaveConfirmation(true) + return + } + leaveConfirmBusy.value = false +} + +function handleDiscardAndLeave(): void { + allowWizardLeave = true + resolveLeaveConfirmation(true) +} + +function handleStayOnWizard(): void { + resolveLeaveConfirmation(false) +} + +function handleBeforeUnload(event: BeforeUnloadEvent): void { + if (!shouldConfirmBeforeLeavingWizard()) { + return + } + + event.preventDefault() + event.returnValue = '' +} + +function hasCompetitorDraftContent(item: DraftCompetitor): boolean { + return Boolean(item.name.trim() || item.website.trim() || item.description.trim()) +} + +function hasOutlineDraftContent(item: OutlineDraftNode): boolean { + return Boolean(item.outline.trim()) || item.children.some(hasOutlineDraftContent) +} + function nextCompetitorKey(): string { competitorKeySeed += 1 return `competitor-${competitorKeySeed}` @@ -1086,14 +1189,9 @@ function handleGenerate(): void { }) } -async function handleSaveDraftAndExit(): Promise { +async function saveWizardDraft(): Promise { if (!enabled.value || savingDraft.value) { - return - } - - if (currentStep.value === 0) { - await router.replace('/articles/templates') - return + return false } savingDraft.value = true @@ -1109,14 +1207,19 @@ async function handleSaveDraftAndExit(): Promise { queryClient.invalidateQueries({ queryKey: ['workspace'] }), ]) message.success(t('templates.wizard.messages.draftSaved')) - await router.replace('/articles/templates') + return true } catch (error) { message.error(formatError(error) || t('templates.wizard.messages.draftSaveFailed')) + return false } finally { savingDraft.value = false } } +async function handleSaveDraftAndExit(): Promise { + await router.replace('/articles/templates') +} + function dedupeStrings(values: string[]): string[] { const seen = new Set() return values @@ -2658,6 +2761,29 @@ function onStructureDragEnd(): void {

{{ assistStages[assistStageIndex] }}

+ + +

{{ t('templates.wizard.leaveConfirm.content') }}

+ +
@@ -2669,6 +2795,12 @@ function onStructureDragEnd(): void { background: #fff; } +.leave-confirm-text { + margin: 0; + color: #52627a; + line-height: 1.7; +} + .wizard-page__loading { padding: 40px; }