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:
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user