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) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 12:07:51 +08:00
parent c1e7c5e90c
commit 7443bb3260
3 changed files with 156 additions and 10 deletions
@@ -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: {
@@ -905,6 +905,13 @@ const zhCN = {
queued: "任务已提交,正在排队生成",
submitted: "生成任务已提交,正在刷新文章列表。",
},
leaveConfirm: {
title: "是否保存为草稿?",
content: "当前模版向导已有填写内容。离开前可以保存为草稿,稍后继续编辑。",
stay: "继续编辑",
discard: "不保存离开",
save: "保存草稿",
},
},
},
article: {
+142 -10
View File
@@ -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<boolean> | 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<number | null>(null)
const restoringDraft = ref(false)
const savingDraft = ref(false)
const leaveConfirmOpen = ref(false)
const leaveConfirmBusy = ref(false)
const selectedBrandId = ref<number | null>(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<boolean> {
if (pendingLeavePromise) {
return pendingLeavePromise
}
leaveConfirmOpen.value = true
pendingLeavePromise = new Promise<boolean>((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<void> {
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<void> {
async function saveWizardDraft(): Promise<boolean> {
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<void> {
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<void> {
await router.replace('/articles/templates')
}
function dedupeStrings(values: string[]): string[] {
const seen = new Set<string>()
return values
@@ -2658,6 +2761,29 @@ function onStructureDragEnd(): void {
<p>{{ assistStages[assistStageIndex] }}</p>
</div>
</a-modal>
<a-modal
:open="leaveConfirmOpen"
:title="t('templates.wizard.leaveConfirm.title')"
:closable="false"
:mask-closable="false"
:keyboard="false"
centered
width="460px"
>
<p class="leave-confirm-text">{{ t('templates.wizard.leaveConfirm.content') }}</p>
<template #footer>
<a-button :disabled="leaveConfirmBusy" @click="handleStayOnWizard">
{{ t('templates.wizard.leaveConfirm.stay') }}
</a-button>
<a-button danger :disabled="leaveConfirmBusy" @click="handleDiscardAndLeave">
{{ t('templates.wizard.leaveConfirm.discard') }}
</a-button>
<a-button type="primary" :loading="leaveConfirmBusy" @click="handleSaveDraftAndLeave">
{{ t('templates.wizard.leaveConfirm.save') }}
</a-button>
</template>
</a-modal>
</div>
</template>
@@ -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;
}