Files
geo/apps/admin-web/src/views/TemplateWizardView.vue
T
root 52997e36fe fix(content-gen): strip URLs and site-list sections from generated content
Forbid URLs/domains/markdown links across prompt rules (runtime, platform
templates, outline/imitation/title flows) and rename "官网" placeholder to
"官网信息" with explicit instructions that links are background only.
Add sanitizeGeneratedArticleMarkdown to strip URLs, HTML images, and
"网站列表/官网列表" labels from LLM output, wired into article, prompt-rule,
template, and outline generation. Drop the site_list outline section seed
and filter blocked outline keys in the wizard so stored drafts cannot
resurrect it.
2026-05-24 22:19:43 +08:00

3922 lines
113 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import {
DeleteOutlined,
HolderOutlined,
LeftOutlined,
PlusOutlined,
RobotOutlined,
StarFilled,
} from '@ant-design/icons-vue'
import type {
ArticleDetail,
JsonValue,
Question,
TemplateAnalyzeResult,
TemplateAssistCompetitor,
TemplateOutlineNode,
} from '@geo/shared-types'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { message } from 'ant-design-vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import AiWaitingModal from '@/components/AiWaitingModal.vue'
import KnowledgeGroupSelect from '@/components/KnowledgeGroupSelect.vue'
import { articlesApi, brandsApi, normalizeInputParams, templatesApi } from '@/lib/api'
import { getTemplateMeta } from '@/lib/display'
import { formatError } from '@/lib/errors'
import { useCompanyStore } from '@/stores/company'
interface DraftCompetitor {
key: string
libraryId?: number
name: string
website: string
description: string
saved: boolean
}
interface OutlineDraftNode {
id: string
outline: string
children: OutlineDraftNode[]
}
interface TemplateHeroConfig {
accent?: string
helper?: string
}
interface TemplateOutlineSectionConfig {
key: string
label?: string
label_template?: string
default?: boolean
}
interface TemplateOutlineConfig {
allow_custom?: boolean
custom_placeholder?: string
custom_max_length?: number
preview_title?: string
preview_caption?: string
sections?: TemplateOutlineSectionConfig[]
}
interface TemplateStepConfig {
title?: string
description?: string
}
interface TemplateCardCopy {
title?: string
hint?: string
visible?: boolean
}
interface TemplateReviewIntroHookOption {
key: string
label: string
description?: string
default?: boolean
}
interface TemplateReviewIntroHookConfig {
title?: string
hint?: string
field_name?: string
required?: boolean
options?: TemplateReviewIntroHookOption[]
}
interface TemplateBasicConfig {
analyze_button_label?: string
cards?: {
brand?: TemplateCardCopy
keywords?: TemplateCardCopy
template_fields?: TemplateCardCopy
competitors?: TemplateCardCopy
}
}
interface TemplateStructureConfig {
cards?: {
choose_title?: TemplateCardCopy
outline?: TemplateCardCopy
preview?: TemplateCardCopy
}
review_intro_hook?: TemplateReviewIntroHookConfig
}
interface TemplateReviewConfig {
card?: TemplateCardCopy
alert_message?: string
}
interface TemplateDerivedInputConfig {
source?: string
fallback_number?: number
}
interface TemplateWizardConfig {
managed_fields?: string[]
derived_inputs?: Record<string, TemplateDerivedInputConfig>
steps?: {
basic?: TemplateStepConfig
structure?: TemplateStepConfig
generate?: TemplateStepConfig
}
basic?: TemplateBasicConfig
structure?: TemplateStructureConfig
review?: TemplateReviewConfig
title_prompt_template?: string
outline?: TemplateOutlineConfig
}
interface TemplateCardConfig {
hero?: TemplateHeroConfig
wizard?: TemplateWizardConfig
}
interface ResolvedOutlineOption {
key: string
label: string
default: boolean
custom?: boolean
}
const templateTokenPattern = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g
const queryClient = useQueryClient()
const { locale: uiLocale, t } = useI18n()
const route = useRoute()
const router = useRouter()
const companyStore = useCompanyStore()
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)
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 officialWebsite = ref('')
const brandSummary = ref('')
const primaryQuestionId = ref<number | null>(null)
const supplementalQuestionIds = ref<number[]>([])
const competitorDrafts = ref<DraftCompetitor[]>([])
const assistTitles = ref<string[]>([])
const selectedTitle = ref('')
const customTitle = ref('')
const reviewIntroHook = ref('')
const keyPoints = ref('')
const selectedKnowledgeGroupIds = ref<number[]>([])
const outlineSections = ref<string[]>([])
const customOutlineSections = ref<ResolvedOutlineOption[]>([])
const outlineOptionOrder = ref<string[]>([])
const customOutlineInput = ref('')
const generatedOutline = ref<OutlineDraftNode[]>([])
const assistModalOpen = ref(false)
const assistBusy = ref(false)
const assistProgress = ref(12)
const assistStageIndex = ref(0)
const assistTaskKind = ref<'analyze' | 'title' | 'outline'>('analyze')
const competitorSavingKey = ref<string | null>(null)
const templateId = computed(() => Number(route.query.template_id))
const draftArticleId = computed(() => {
const value = Number(route.query.article_id)
return Number.isNaN(value) || value <= 0 ? null : value
})
const enabled = computed(() => !Number.isNaN(templateId.value) && templateId.value > 0)
const draftQueryEnabled = computed(() => enabled.value && draftArticleId.value !== null)
const templateDetailQuery = useQuery({
queryKey: computed(() => ['templates', 'detail', templateId.value]),
enabled,
queryFn: () => templatesApi.detail(templateId.value),
})
const selectedBrand = computed(() => companyStore.currentBrand)
const selectedBrandId = computed(() => selectedBrand.value?.id ?? null)
const normalizedBrandName = computed(() => selectedBrand.value?.name?.trim() || '')
const draftArticleQuery = useQuery({
queryKey: computed(() => ['articles', 'detail', draftArticleId.value]),
enabled: draftQueryEnabled,
queryFn: () => articlesApi.detail(draftArticleId.value as number),
})
const questionsQuery = useQuery({
queryKey: computed(() => ['brands', selectedBrandId.value, 'questions', 'wizard']),
enabled: computed(() => enabled.value && Boolean(selectedBrandId.value)),
queryFn: () => brandsApi.listQuestions(selectedBrandId.value as number),
})
const competitorsQuery = useQuery({
queryKey: computed(() => ['brands', selectedBrandId.value, 'competitors']),
enabled: computed(
() =>
enabled.value &&
Boolean(selectedBrandId.value) &&
templateDetailQuery.data.value?.template_key !== 'research_report' &&
parseTemplateCardConfig(templateDetailQuery.data.value?.card_config).wizard?.basic?.cards
?.competitors?.visible !== false,
),
queryFn: () => brandsApi.listCompetitors(selectedBrandId.value as number),
})
const mutation = useMutation({
mutationFn: (payload: {
article_id?: number
input_params: Record<string, JsonValue>
wizard_state?: Record<string, JsonValue>
}) =>
templatesApi.generate(
templateId.value,
normalizeInputParams(payload.input_params, payload.article_id, payload.wizard_state),
),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['articles'] }),
queryClient.invalidateQueries({ queryKey: ['workspace'] }),
queryClient.invalidateQueries({ queryKey: ['templates'] }),
])
message.info(t('templates.wizard.messages.queued'))
allowWizardLeave = true
await router.replace(resolveExitPath())
},
onError: (error) => {
message.error(formatError(error))
},
})
const templateDetail = computed(() => templateDetailQuery.data.value)
const templateCardConfig = computed<TemplateCardConfig>(() =>
parseTemplateCardConfig(templateDetail.value?.card_config),
)
const wizardConfig = computed<TemplateWizardConfig | null>(
() => templateCardConfig.value.wizard ?? null,
)
const isResearchReportTemplate = computed(
() => templateDetail.value?.template_key === 'research_report',
)
const templateMeta = computed(() => {
const fallback = getTemplateMeta(templateDetail.value?.template_key ?? 'unknown')
return {
...fallback,
accent: templateCardConfig.value.hero?.accent || fallback.accent,
helper: templateCardConfig.value.hero?.helper || fallback.helper,
}
})
function findQuestionById(id: number | null): Question | null {
if (!id) {
return null
}
return questionsQuery.data.value?.find((item) => item.id === id) ?? null
}
const questionOptions = computed(() =>
(questionsQuery.data.value ?? []).map((item) => ({
label: item.question_text,
value: item.id,
})),
)
const supplementalQuestionOptions = computed(() =>
questionOptions.value.filter((item) => item.value !== primaryQuestionId.value),
)
const primaryQuestion = computed(() => findQuestionById(primaryQuestionId.value))
const supplementalQuestions = computed(() =>
supplementalQuestionIds.value
.map((id) => findQuestionById(id))
.filter((item): item is Question => Boolean(item)),
)
const primaryQuestionText = computed(() => primaryQuestion.value?.question_text.trim() || '')
const supplementalQuestionTexts = computed(() =>
supplementalQuestions.value.map((item) => item.question_text.trim()).filter(Boolean),
)
const selectedQuestionTexts = computed(() =>
dedupeStrings([primaryQuestionText.value, ...supplementalQuestionTexts.value]),
)
const finalTitle = computed(() => customTitle.value.trim() || selectedTitle.value.trim())
const primaryKeyword = computed(() => primaryQuestionText.value)
const showCompetitorsCard = computed(
() =>
!isResearchReportTemplate.value &&
wizardConfig.value?.basic?.cards?.competitors?.visible !== false,
)
const canSaveCompetitorsToLibrary = computed(() => Boolean(selectedBrandId.value))
const canManageCompetitors = computed(() => Boolean(selectedBrandId.value))
const customOutlineMaxLength = computed(
() => wizardConfig.value?.outline?.custom_max_length ?? CUSTOM_OUTLINE_MAX_LENGTH,
)
const reviewIntroHookConfig = computed<TemplateReviewIntroHookConfig | undefined>(
() => wizardConfig.value?.structure?.review_intro_hook,
)
const reviewIntroHookOptions = computed<TemplateReviewIntroHookOption[]>(
() => reviewIntroHookConfig.value?.options ?? [],
)
const reviewIntroHookFieldName = computed(
() => reviewIntroHookConfig.value?.field_name?.trim() || 'review_intro_hook',
)
const reviewIntroHookRequired = computed(
() => reviewIntroHookOptions.value.length > 0 && reviewIntroHookConfig.value?.required !== false,
)
const selectedReviewIntroHookOption = computed<TemplateReviewIntroHookOption | null>(
() => reviewIntroHookOptions.value.find((item) => item.key === reviewIntroHook.value) ?? null,
)
const reviewIntroHookPromptValue = computed(() => {
const option = selectedReviewIntroHookOption.value
if (!option) {
return ''
}
return option.description ? `${option.label}${option.description}` : option.label
})
const templateRenderContext = computed(() =>
buildTemplateRenderContext({
locale: articleLocale.value,
brandName: normalizedBrandName.value,
officialWebsite: officialWebsite.value,
brandSummary: brandSummary.value,
primaryQuestion: primaryQuestionText.value,
supplementalQuestions: supplementalQuestionTexts.value,
inputParams: buildStructureInputParams(),
competitorCount: buildCompetitorPayload().length,
}),
)
const titleOptions = computed(() => {
if (assistTitles.value.length > 0) {
return assistTitles.value
}
return buildFallbackTitles(
templateDetail.value?.template_key,
normalizedBrandName.value,
primaryKeyword.value,
uiLocale.value,
)
})
const baseOutlineOptions = computed<ResolvedOutlineOption[]>(() => {
const configured = buildOutlineOptions(
wizardConfig.value?.outline,
templateDetail.value?.template_key,
templateRenderContext.value,
)
return [...configured, ...customOutlineSections.value]
})
const outlineOptions = computed<ResolvedOutlineOption[]>(() => {
const visible = baseOutlineOptions.value.filter((section) => !isBlockedOutlineOption(section))
return sortOutlineOptions(visible, outlineOptionOrder.value)
})
const selectedOutlineLabels = computed(() =>
outlineOptions.value
.filter((section) => outlineSections.value.includes(section.key))
.map((section) => section.label),
)
const previewTitle = computed(
() => finalTitle.value || titleOptions.value[0] || templateDetail.value?.template_name || '--',
)
const previewSections = computed(() =>
outlineOptions.value.filter((section) => outlineSections.value.includes(section.key)),
)
const generatedOutlineNodes = computed(() => serializeOutlineDraftNodes(generatedOutline.value))
const canAddCustomOutline = computed(() => Boolean(wizardConfig.value?.outline?.allow_custom))
const outlinePreviewTitle = computed(
() => wizardConfig.value?.outline?.preview_title || t('templates.wizard.sections.preview'),
)
const outlinePreviewCaption = computed(
() => wizardConfig.value?.outline?.preview_caption || t('templates.wizard.hints.preview'),
)
const basicStepCopy = computed<TemplateStepConfig>(() => wizardConfig.value?.steps?.basic ?? {})
const structureStepCopy = computed<TemplateStepConfig>(
() => wizardConfig.value?.steps?.structure ?? {},
)
const generateStepCopy = computed<TemplateStepConfig>(
() => wizardConfig.value?.steps?.generate ?? {},
)
const brandCardCopy = computed<TemplateCardCopy>(
() => sanitizeLegacyKeywordCopy(wizardConfig.value?.basic?.cards?.brand ?? {}),
)
const questionCardCopy = computed<TemplateCardCopy>(
() => sanitizeQuestionCardCopy(wizardConfig.value?.basic?.cards?.keywords ?? {}),
)
const competitorsCardCopy = computed<TemplateCardCopy>(
() => wizardConfig.value?.basic?.cards?.competitors ?? {},
)
const titleCardCopy = computed<TemplateCardCopy>(
() => sanitizeLegacyKeywordCopy(wizardConfig.value?.structure?.cards?.choose_title ?? {}),
)
const outlineCardCopy = computed<TemplateCardCopy>(
() => wizardConfig.value?.structure?.cards?.outline ?? {},
)
const previewCardCopy = computed<TemplateCardCopy>(
() => wizardConfig.value?.structure?.cards?.preview ?? {},
)
const analyzeButtonLabel = computed(
() => t('templates.wizard.actions.analyzeCompetitors'),
)
const showBrandSummaryBox = computed(() => Boolean(selectedBrandId.value))
const reviewAlertMessage = computed(
() => wizardConfig.value?.review?.alert_message || t('templates.wizard.hints.async'),
)
const isPageLoading = computed(
() =>
templateDetailQuery.isPending.value ||
(draftQueryEnabled.value && draftArticleQuery.isPending.value),
)
const hasWizardDraftContent = computed(
() =>
Boolean(selectedBrandId.value) ||
Boolean(officialWebsite.value.trim()) ||
Boolean(brandSummary.value.trim()) ||
Boolean(primaryQuestionId.value) ||
supplementalQuestionIds.value.length > 0 ||
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'
? [
t('templates.wizard.assist.analyzeStages.context'),
t('templates.wizard.assist.analyzeStages.competitors'),
t('templates.wizard.assist.analyzeStages.refine'),
]
: assistTaskKind.value === 'title'
? [
t('templates.wizard.assist.titleStages.context'),
showCompetitorsCard.value
? t('templates.wizard.assist.titleStages.strategy')
: t('templates.wizard.assist.titleStages.strategyNoCompetitors'),
t('templates.wizard.assist.titleStages.finalize'),
]
: [
showCompetitorsCard.value
? t('templates.wizard.assist.outlineStages.context')
: t('templates.wizard.assist.outlineStages.contextNoCompetitors'),
t('templates.wizard.assist.outlineStages.structure'),
t('templates.wizard.assist.outlineStages.finalize'),
],
)
const assistHeading = computed(() =>
assistTaskKind.value === 'analyze'
? t('templates.wizard.assist.analyzeTitle')
: assistTaskKind.value === 'title'
? t('templates.wizard.assist.titleTitle')
: t('templates.wizard.assist.outlineTitle'),
)
function resetWizardState(detail: NonNullable<typeof templateDetail.value>): void {
currentArticleId.value = draftArticleId.value
currentStep.value = 0
articleLocale.value = 'zh-CN'
hydrateBrandContextFromCurrentBrand()
primaryQuestionId.value = null
supplementalQuestionIds.value = []
competitorDrafts.value = []
assistTitles.value = []
selectedTitle.value = ''
customTitle.value = ''
reviewIntroHook.value = ''
keyPoints.value = ''
selectedKnowledgeGroupIds.value = []
customOutlineSections.value = []
customOutlineInput.value = ''
generatedOutline.value = []
const defaults = buildOutlineOptions(
templateCardConfig.value.wizard?.outline,
detail.template_key,
buildTemplateRenderContext({
locale: 'zh-CN',
brandName: normalizedBrandName.value,
officialWebsite: officialWebsite.value,
brandSummary: brandSummary.value,
primaryQuestion: '',
supplementalQuestions: [],
inputParams: {},
competitorCount: 0,
}),
)
.filter((section) => section.default && !isBlockedOutlineOption(section))
.map((section) => section.key)
outlineOptionOrder.value = buildOutlineOptions(
templateCardConfig.value.wizard?.outline,
detail.template_key,
buildTemplateRenderContext({
locale: 'zh-CN',
brandName: normalizedBrandName.value,
officialWebsite: officialWebsite.value,
brandSummary: brandSummary.value,
primaryQuestion: '',
supplementalQuestions: [],
inputParams: {},
competitorCount: 0,
}),
)
.filter((section) => !isBlockedOutlineOption(section))
.map((section) => section.key)
outlineSections.value = defaults
}
function applyDraftArticleState(detail: ArticleDetail): void {
const draftState = asRecord(detail.wizard_state)
currentArticleId.value = detail.id
if (!draftState) {
return
}
restoringDraft.value = true
currentStep.value = clampStep(numberFromUnknown(draftState.current_step))
articleLocale.value = stringFromUnknown(draftState.locale) || articleLocale.value
hydrateBrandContextFromCurrentBrand()
officialWebsite.value = stringFromUnknown(draftState.official_website) || officialWebsite.value
brandSummary.value = stringFromUnknown(draftState.brand_summary) || brandSummary.value
primaryQuestionId.value = numberFromUnknown(draftState.primary_question_id) ?? null
supplementalQuestionIds.value =
numberListFromUnknown(draftState.supplemental_question_ids)?.slice(0, 3) ?? []
competitorDrafts.value = parseDraftCompetitors(draftState.competitor_drafts)
assistTitles.value = stringListFromUnknown(draftState.assist_titles) ?? []
selectedTitle.value = stringFromUnknown(draftState.selected_title) || ''
customTitle.value = stringFromUnknown(draftState.custom_title) || ''
reviewIntroHook.value = stringFromUnknown(draftState.review_intro_hook) || ''
keyPoints.value = stringFromUnknown(draftState.key_points) || ''
selectedKnowledgeGroupIds.value = numberListFromUnknown(draftState.knowledge_group_ids) ?? []
customOutlineSections.value = parseCustomOutlineSections(draftState.custom_outline_sections)
outlineOptionOrder.value =
sanitizeOutlineKeys(stringListFromUnknown(draftState.outline_option_order)) ?? outlineOptionOrder.value
outlineSections.value =
sanitizeOutlineKeys(stringListFromUnknown(draftState.outline_sections)) ?? outlineSections.value
generatedOutline.value = decorateOutlineNodes(parseOutlineNodes(draftState.generated_outline))
restoringDraft.value = false
}
watch(
[enabled, templateDetail, () => draftArticleQuery.data.value],
([isOpen, detail, draftArticle]) => {
if (!isOpen || !detail) {
return
}
resetWizardState(detail)
if (draftArticle && draftArticle.generate_status === 'draft') {
applyDraftArticleState(draftArticle)
}
},
{ immediate: true },
)
watch(
() => selectedBrand.value,
() => {
hydrateBrandContextFromCurrentBrand()
},
{ immediate: true },
)
watch(
baseOutlineOptions,
(options) => {
outlineOptionOrder.value = syncOutlineOptionOrder(
outlineOptionOrder.value,
options.map((item) => item.key),
)
},
{ immediate: true },
)
watch(
titleOptions,
(options) => {
if (!selectedTitle.value && options.length > 0) {
selectedTitle.value = options[0]
}
},
{ immediate: true },
)
watch(
reviewIntroHookOptions,
(options) => {
if (options.some((item) => item.key === reviewIntroHook.value)) {
return
}
const fallback = options.find((item) => item.default) ?? options[0]
reviewIntroHook.value = fallback?.key ?? ''
},
{ immediate: true },
)
watch(primaryQuestionId, (id) => {
if (!id) {
return
}
supplementalQuestionIds.value = supplementalQuestionIds.value.filter((item) => item !== id)
})
watch(supplementalQuestionIds, (ids) => {
const optionValues = supplementalQuestionOptions.value.map((item) => item.value)
const allowedIds = new Set(optionValues)
const shouldCheckOptions = optionValues.length > 0
const normalized = ids.filter(
(id, index) =>
id !== primaryQuestionId.value &&
ids.indexOf(id) === index &&
(!shouldCheckOptions || allowedIds.has(id)),
)
const limited = normalized.slice(0, 3)
if (limited.length !== ids.length || limited.some((id, index) => id !== ids[index])) {
supplementalQuestionIds.value = limited
}
})
watch(selectedBrandId, async (brandId) => {
if (restoringDraft.value) {
return
}
if (!brandId) {
competitorDrafts.value = competitorDrafts.value.map((item) => ({
...item,
libraryId: undefined,
saved: false,
}))
return
}
const brand = selectedBrand.value
if (brand?.website && !officialWebsite.value.trim()) {
officialWebsite.value = brand.website
}
if (brand?.description && !brandSummary.value.trim()) {
brandSummary.value = brand.description
}
primaryQuestionId.value = null
supplementalQuestionIds.value = []
await questionsQuery.refetch()
if (!showCompetitorsCard.value) {
competitorDrafts.value = []
return
}
const competitorsResult = await competitorsQuery.refetch()
competitorDrafts.value = (competitorsResult.data ?? []).map((item) => ({
key: nextCompetitorKey(),
libraryId: item.id,
name: item.name,
website: item.website ?? '',
description: item.description ?? '',
saved: true,
}))
}, { immediate: true })
function hydrateBrandContextFromCurrentBrand(): void {
const brand = selectedBrand.value
officialWebsite.value = brand?.website ?? ''
brandSummary.value = brand?.description ?? ''
}
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}`
}
function nextOutlineNodeKey(): string {
outlineNodeSeed += 1
return `outline-${outlineNodeSeed}`
}
function validateBasicInfo(): boolean {
if (!normalizedBrandName.value) {
message.warning(t('templates.wizard.messages.missingBrand'))
return false
}
if (!selectedBrandId.value) {
message.warning(t('templates.wizard.messages.selectBrandBeforeQuestion'))
return false
}
if (!primaryQuestionId.value || !primaryQuestionText.value) {
message.warning(t('templates.wizard.messages.missingPrimaryQuestion'))
return false
}
return true
}
function validateAnalyzeInfo(): boolean {
if (!canManageCompetitors.value) {
message.warning(t('templates.wizard.messages.selectBrandBeforeCompetitors'))
return false
}
return true
}
function validateStructure(): boolean {
if (!finalTitle.value) {
message.warning(t('templates.wizard.messages.missingTitle'))
return false
}
if (reviewIntroHookRequired.value && !reviewIntroHook.value) {
message.warning(t('templates.wizard.messages.missingReviewIntroHook'))
return false
}
if (outlineSections.value.length === 0) {
message.warning(t('templates.wizard.messages.missingOutline'))
return false
}
return true
}
function toggleOutlineSection(key: string, checked: boolean): void {
if (checked) {
if (!outlineSections.value.includes(key)) {
outlineSections.value = [...outlineSections.value, key]
}
return
}
outlineSections.value = outlineSections.value.filter((item) => item !== key)
}
function validateGeneratedOutline(): boolean {
if (generatedOutlineNodes.value.length === 0) {
message.warning(t('templates.wizard.messages.missingGeneratedOutline'))
return false
}
return true
}
async function handleNext(): Promise<void> {
if (currentStep.value === 0) {
if (!validateBasicInfo()) {
return
}
await runTitleTask()
return
}
if (currentStep.value === 1) {
if (!validateStructure()) {
return
}
await runOutlineTask()
return
}
}
function handlePrev(): void {
currentStep.value = Math.max(0, currentStep.value - 1)
}
async function handleAnalyze(): Promise<void> {
if (!validateAnalyzeInfo()) {
return
}
await runAnalyzeTask(true)
}
function beginAssistTask(kind: 'analyze' | 'title' | 'outline'): void {
assistTaskKind.value = kind
if (assistBusy.value) {
return
}
assistBusy.value = true
assistModalOpen.value = true
assistProgress.value = 12
assistStageIndex.value = 0
}
function endAssistTask(): void {
void queryClient.invalidateQueries({ queryKey: ['workspace'] })
window.setTimeout(() => {
assistModalOpen.value = false
assistBusy.value = false
}, 180)
}
async function runAnalyzeTask(notifySuccess: boolean): Promise<void> {
if (assistBusy.value) {
return
}
beginAssistTask('analyze')
try {
const competitorPayload = buildCompetitorPayload()
const task = await templatesApi.createAnalyzeTask(templateId.value, {
locale: articleLocale.value,
brand_id: selectedBrandId.value,
brand_name: normalizedBrandName.value,
website: officialWebsite.value.trim() || null,
brand_question: primaryQuestionText.value,
input_params: buildAssistInputParams(),
existing_competitors: competitorPayload.length > 0 ? competitorPayload : undefined,
})
const result = await pollAnalyzeTask(task.task_id)
applyAnalyzeResult(result)
assistProgress.value = 100
assistStageIndex.value = assistStages.value.length - 1
if (notifySuccess) {
message.success(t('templates.wizard.messages.assistReady'))
}
} catch (error) {
message.error(formatError(error))
} finally {
endAssistTask()
}
}
async function runTitleTask(): Promise<void> {
if (assistBusy.value) {
return
}
beginAssistTask('title')
try {
const competitorPayload = buildCompetitorPayload()
const task = await templatesApi.createTitleTask(templateId.value, {
locale: articleLocale.value,
brand_id: selectedBrandId.value,
brand_name: normalizedBrandName.value,
website: officialWebsite.value.trim() || null,
brand_summary: brandSummary.value.trim() || null,
brand_question: primaryQuestionText.value,
input_params: buildTitleInputParams(),
keywords: primaryQuestionText.value ? [primaryQuestionText.value] : [],
competitors: competitorPayload.length > 0 ? competitorPayload : undefined,
})
const titles = await pollTitleTask(task.task_id)
assistTitles.value = dedupeStrings(titles).slice(0, 5)
if (assistTitles.value.length > 0 && !assistTitles.value.includes(selectedTitle.value)) {
selectedTitle.value = assistTitles.value[0]
}
assistProgress.value = 100
assistStageIndex.value = assistStages.value.length - 1
currentStep.value = 1
} catch (error) {
message.error(formatError(error))
} finally {
endAssistTask()
}
}
async function runOutlineTask(): Promise<void> {
if (assistBusy.value) {
return
}
beginAssistTask('outline')
try {
const competitorPayload = buildCompetitorPayload()
const task = await templatesApi.createOutlineTask(templateId.value, {
locale: articleLocale.value,
brand_id: selectedBrandId.value,
brand_name: normalizedBrandName.value,
website: officialWebsite.value.trim() || null,
brand_summary: brandSummary.value.trim() || null,
title: finalTitle.value,
brand_question: primaryQuestionText.value,
supplemental_questions: supplementalQuestionTexts.value,
input_params: buildStructureInputParams(),
keywords: selectedQuestionTexts.value,
competitors: competitorPayload.length > 0 ? competitorPayload : undefined,
outline_sections: selectedOutlineLabels.value,
})
const outline = await pollOutlineTask(task.task_id)
generatedOutline.value = decorateOutlineNodes(outline)
assistProgress.value = 100
assistStageIndex.value = assistStages.value.length - 1
currentStep.value = 2
} catch (error) {
message.error(formatError(error))
} finally {
endAssistTask()
}
}
const ASSIST_POLL_INTERVAL_MS = 3000
const ASSIST_POLL_MAX_ATTEMPTS = 40
async function pollAnalyzeTask(taskId: string): Promise<TemplateAnalyzeResult> {
for (let attempt = 0; attempt < ASSIST_POLL_MAX_ATTEMPTS; attempt += 1) {
const task = await templatesApi.getAnalyzeTaskResult(templateId.value, taskId)
advanceAssistProgress(attempt, task.status)
if (task.status === 'completed' && task.result) {
return task.result
}
if (task.status === 'failed') {
throw new Error(task.error_message || t('templates.wizard.messages.assistFailed'))
}
await sleep(ASSIST_POLL_INTERVAL_MS)
}
throw new Error(t('templates.wizard.messages.assistTimeout'))
}
async function pollTitleTask(taskId: string): Promise<string[]> {
for (let attempt = 0; attempt < ASSIST_POLL_MAX_ATTEMPTS; attempt += 1) {
const task = await templatesApi.getTitleTaskResult(templateId.value, taskId)
advanceAssistProgress(attempt, task.status)
if (task.status === 'completed' && task.result) {
return task.result
}
if (task.status === 'failed') {
throw new Error(task.error_message || t('templates.wizard.messages.titleFailed'))
}
await sleep(ASSIST_POLL_INTERVAL_MS)
}
throw new Error(t('templates.wizard.messages.titleTimeout'))
}
async function pollOutlineTask(taskId: string): Promise<TemplateOutlineNode[]> {
for (let attempt = 0; attempt < ASSIST_POLL_MAX_ATTEMPTS; attempt += 1) {
const task = await templatesApi.getOutlineTaskResult(templateId.value, taskId)
advanceAssistProgress(attempt, task.status)
if (task.status === 'completed' && task.result) {
return task.result
}
if (task.status === 'failed') {
throw new Error(task.error_message || t('templates.wizard.messages.outlineFailed'))
}
await sleep(ASSIST_POLL_INTERVAL_MS)
}
throw new Error(t('templates.wizard.messages.outlineTimeout'))
}
function advanceAssistProgress(attempt: number, status: string): void {
if (status === 'running') {
assistStageIndex.value = Math.min(assistStages.value.length - 1, Math.floor((attempt + 1) / 2))
}
assistProgress.value = Math.min(92, 24 + attempt * 6)
}
function applyAnalyzeResult(result: TemplateAnalyzeResult): void {
if (result.brand_summary?.trim()) {
brandSummary.value = result.brand_summary.trim()
}
if (showCompetitorsCard.value) {
competitorDrafts.value = mergeCompetitors(competitorDrafts.value, result.competitors ?? [])
}
}
function addCompetitorRow(): void {
if (!canManageCompetitors.value) {
message.warning(t('templates.wizard.messages.selectBrandBeforeCompetitors'))
return
}
competitorDrafts.value = [
...competitorDrafts.value,
{
key: nextCompetitorKey(),
name: '',
website: '',
description: '',
saved: false,
},
]
}
function removeCompetitorRow(key: string): void {
competitorDrafts.value = competitorDrafts.value.filter((item) => item.key !== key)
}
async function handleFavoriteCompetitor(item: DraftCompetitor): Promise<void> {
const brandId = selectedBrandId.value
if (!brandId) {
message.warning(t('templates.wizard.messages.selectBrandBeforeFavorite'))
return
}
competitorSavingKey.value = item.key
try {
if (item.saved && item.libraryId) {
await brandsApi.removeCompetitor(brandId, item.libraryId)
item.libraryId = undefined
item.saved = false
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['brands'] }),
queryClient.invalidateQueries({ queryKey: ['brands', brandId, 'competitors'] }),
])
message.success(t('templates.wizard.messages.unsavedCompetitor'))
return
}
if (!item.name.trim()) {
message.warning(t('templates.wizard.messages.missingCompetitorName'))
return
}
const payload = {
name: item.name.trim(),
website: item.website.trim() || null,
description: item.description.trim() || null,
product_lines_json: null,
}
if (item.libraryId) {
await brandsApi.updateCompetitor(brandId, item.libraryId, payload)
} else {
const created = await brandsApi.createCompetitor(brandId, payload)
item.libraryId = created.id
}
item.saved = true
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['brands'] }),
queryClient.invalidateQueries({ queryKey: ['brands', brandId, 'competitors'] }),
])
message.success(t('templates.wizard.messages.savedCompetitor'))
} catch (error) {
message.error(formatError(error))
} finally {
competitorSavingKey.value = null
}
}
function buildCompetitorPayload(): TemplateAssistCompetitor[] {
if (!showCompetitorsCard.value) {
return []
}
return competitorDrafts.value
.filter((item) => item.name.trim() || item.website.trim())
.map<TemplateAssistCompetitor>((item) => ({
name: item.name.trim(),
website: item.website.trim() || null,
description: item.description.trim() || null,
}))
}
function sanitizeQuestionCardCopy(copy: TemplateCardCopy): TemplateCardCopy {
if (copy.title?.includes('关键词') || copy.hint?.includes('关键词')) {
return {
...copy,
title: undefined,
hint: undefined,
}
}
return {
...copy,
title: sanitizeLegacyKeywordText(copy.title) || undefined,
hint: sanitizeLegacyKeywordText(copy.hint) || undefined,
}
}
function sanitizeLegacyKeywordCopy(copy: TemplateCardCopy): TemplateCardCopy {
return {
...copy,
title: sanitizeLegacyKeywordText(copy.title) || undefined,
hint: sanitizeLegacyKeywordText(copy.hint) || undefined,
}
}
function sanitizeLegacyKeywordText(value: string | undefined): string {
return (value || '')
.replace(/品牌主问题/g, '优化关键词')
.replace(/补充覆盖问题/g, '补充关键词')
.replace(/核心关键词/g, '优化关键词')
.replace(/标题生成/g, '后续生成')
}
function buildAssistInputParams(): Record<string, JsonValue> {
const payload: Record<string, JsonValue> = {
brand_question: primaryQuestionText.value,
primary_question: primaryQuestionText.value,
primary_keyword: primaryQuestionText.value,
}
const derivedInputs = wizardConfig.value?.derived_inputs ?? {}
for (const [key, config] of Object.entries(derivedInputs)) {
const value = resolveDerivedInputValue(key, config)
if (value !== null && value !== '' && value !== undefined) {
payload[key] = value
}
}
return payload
}
function buildTitleInputParams(): Record<string, JsonValue> {
return buildAssistInputParams()
}
function buildStructureInputParams(): Record<string, JsonValue> {
const payload = buildAssistInputParams()
if (supplementalQuestionTexts.value.length > 0) {
payload.supplemental_questions = supplementalQuestionTexts.value
payload.keywords = selectedQuestionTexts.value
}
if (reviewIntroHookPromptValue.value) {
payload[reviewIntroHookFieldName.value] = reviewIntroHookPromptValue.value
}
return payload
}
function buildPayload(): Record<string, JsonValue> {
const competitorPayload = buildCompetitorPayload()
const competitorInputValue =
competitorPayload.length > 0
? competitorPayload.map((item) => ({
name: item.name,
website: item.website ?? null,
description: item.description ?? null,
}))
: undefined
const payload: Record<string, JsonValue | undefined> = {
locale: articleLocale.value,
title: finalTitle.value || undefined,
outline_sections: selectedOutlineLabels.value,
article_outline:
generatedOutlineNodes.value.length > 0
? serializeOutlinePayloadNodes(generatedOutlineNodes.value)
: undefined,
key_points: keyPoints.value.trim() || undefined,
brand_id: selectedBrandId.value ?? undefined,
brand_name: normalizedBrandName.value || undefined,
brand_summary: brandSummary.value.trim() || undefined,
official_website: officialWebsite.value.trim() || undefined,
brand_question: primaryQuestionText.value || undefined,
primary_question: primaryQuestionText.value || undefined,
supplemental_questions:
supplementalQuestionTexts.value.length > 0 ? supplementalQuestionTexts.value : undefined,
primary_keyword: primaryQuestionText.value || undefined,
keywords: selectedQuestionTexts.value.length > 0 ? selectedQuestionTexts.value : undefined,
[reviewIntroHookFieldName.value]: reviewIntroHookPromptValue.value || undefined,
knowledge_group_ids:
selectedKnowledgeGroupIds.value.length > 0 ? selectedKnowledgeGroupIds.value : undefined,
competitors: competitorInputValue,
}
const derivedInputs = wizardConfig.value?.derived_inputs ?? {}
const derivedKeys = Object.keys(derivedInputs)
if (derivedKeys.length > 0) {
for (const key of derivedKeys) {
const derivedValue = resolveDerivedInputValue(key, derivedInputs[key])
if (derivedValue !== undefined) {
payload[key] = derivedValue
}
}
}
return Object.fromEntries(
Object.entries(payload).filter(([, value]) => value !== undefined && value !== ''),
) as Record<string, JsonValue>
}
function buildDraftState(): Record<string, JsonValue> {
return {
current_step: currentStep.value,
locale: articleLocale.value,
selected_brand_id: selectedBrandId.value,
brand_name: normalizedBrandName.value,
official_website: officialWebsite.value.trim(),
brand_summary: brandSummary.value.trim(),
primary_question_id: primaryQuestionId.value,
primary_question_text: primaryQuestionText.value,
supplemental_question_ids: supplementalQuestionIds.value,
supplemental_question_texts: supplementalQuestionTexts.value,
competitor_drafts: showCompetitorsCard.value
? competitorDrafts.value.map((item) => ({
library_id: item.libraryId ?? null,
name: item.name.trim(),
website: item.website.trim(),
description: item.description.trim(),
saved: item.saved,
}))
: [],
assist_titles: assistTitles.value,
selected_title: selectedTitle.value,
custom_title: customTitle.value,
title: finalTitle.value || selectedTitle.value || '',
review_intro_hook: reviewIntroHook.value,
knowledge_group_ids: selectedKnowledgeGroupIds.value,
outline_sections: sanitizeOutlineKeys(outlineSections.value) ?? [],
outline_option_order: sanitizeOutlineKeys(outlineOptionOrder.value) ?? [],
custom_outline_sections: customOutlineSections.value.map((item) => ({
key: item.key,
label: item.label,
default: item.default,
custom: item.custom ?? false,
})),
generated_outline: serializeOutlinePayloadNodes(generatedOutlineNodes.value),
key_points: keyPoints.value.trim(),
}
}
function handleGenerate(): void {
if (!validateBasicInfo() || !validateStructure() || !validateGeneratedOutline()) {
return
}
mutation.mutate({
article_id: currentArticleId.value ?? undefined,
input_params: buildPayload(),
wizard_state: buildDraftState(),
})
}
async function saveWizardDraft(): Promise<boolean> {
if (!enabled.value || savingDraft.value) {
return false
}
savingDraft.value = true
try {
const result = await templatesApi.saveDraft(templateId.value, {
article_id: currentArticleId.value ?? undefined,
current_step: currentStep.value,
wizard_state: buildDraftState(),
})
currentArticleId.value = result.article_id
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['articles'] }),
queryClient.invalidateQueries({ queryKey: ['workspace'] }),
])
message.success(t('templates.wizard.messages.draftSaved'))
return true
} catch (error) {
message.error(formatError(error) || t('templates.wizard.messages.draftSaveFailed'))
return false
} finally {
savingDraft.value = false
}
}
function resolveExitPath(): string {
return route.query.from === 'workspace' ? '/workspace' : '/articles/templates'
}
async function handleSaveDraftAndExit(): Promise<void> {
await router.replace(resolveExitPath())
}
function dedupeStrings(values: string[]): string[] {
const seen = new Set<string>()
return values
.map((item) => item.trim())
.filter((item) => {
if (!item) {
return false
}
const key = item.toLowerCase()
if (seen.has(key)) {
return false
}
seen.add(key)
return true
})
}
function mergeCompetitors(
existing: DraftCompetitor[],
incoming: TemplateAssistCompetitor[],
): DraftCompetitor[] {
const next = [...existing]
const indexMap = new Map<string, number>()
next.forEach((item, index) => {
indexMap.set(competitorIdentity(item.name, item.website), index)
})
for (const item of incoming) {
const identity = competitorIdentity(item.name ?? '', item.website ?? '')
if (!identity) {
continue
}
const existingIndex = indexMap.get(identity)
if (existingIndex !== undefined) {
const target = next[existingIndex]
if (!target.website && item.website) {
target.website = item.website
}
if (!target.description && item.description) {
target.description = item.description
}
continue
}
next.push({
key: nextCompetitorKey(),
name: item.name ?? '',
website: item.website ?? '',
description: item.description ?? '',
saved: false,
})
indexMap.set(identity, next.length - 1)
}
return next
}
function competitorIdentity(name: string, website: string): string {
return (website.trim() || name.trim()).toLowerCase()
}
function addCustomOutlineSection(): void {
const label = customOutlineInput.value.trim()
if (!label) {
return
}
if (Array.from(label).length > customOutlineMaxLength.value) {
message.warning(
t('templates.wizard.messages.customOutlineTooLong', { count: customOutlineMaxLength.value }),
)
return
}
const key = `custom-${Date.now()}`
customOutlineSections.value = [
...customOutlineSections.value,
{ key, label, default: true, custom: true },
]
outlineOptionOrder.value = [...outlineOptionOrder.value, key]
outlineSections.value = [...outlineSections.value, key]
customOutlineInput.value = ''
}
function removeOutlineSection(key: string): void {
outlineSections.value = outlineSections.value.filter((item) => item !== key)
const target = outlineOptions.value.find((item) => item.key === key)
if (!target?.custom) {
return
}
customOutlineSections.value = customOutlineSections.value.filter((item) => item.key !== key)
outlineOptionOrder.value = outlineOptionOrder.value.filter((item) => item !== key)
}
function sortOutlineOptions(
options: ResolvedOutlineOption[],
order: string[],
): ResolvedOutlineOption[] {
if (options.length === 0) {
return []
}
const optionMap = new Map(options.map((item) => [item.key, item]))
const ordered: ResolvedOutlineOption[] = []
for (const key of order) {
const option = optionMap.get(key)
if (!option) {
continue
}
ordered.push(option)
optionMap.delete(key)
}
for (const option of options) {
if (optionMap.has(option.key)) {
ordered.push(option)
}
}
return ordered
}
function syncOutlineOptionOrder(current: string[], nextKeys: string[]): string[] {
const nextSet = new Set(nextKeys)
const ordered = current.filter((key) => nextSet.has(key))
const orderedSet = new Set(ordered)
for (const key of nextKeys) {
if (!orderedSet.has(key)) {
ordered.push(key)
}
}
return ordered
}
function decorateOutlineNodes(nodes: TemplateOutlineNode[]): OutlineDraftNode[] {
return nodes
.map((node) => decorateOutlineNode(node))
.filter((node): node is OutlineDraftNode => node !== null)
}
function decorateOutlineNode(node: TemplateOutlineNode): OutlineDraftNode | null {
const outline = node.outline.trim()
if (!outline) {
return null
}
return {
id: nextOutlineNodeKey(),
outline,
children: (node.children ?? [])
.map((child) => decorateOutlineNode(child))
.filter((child): child is OutlineDraftNode => child !== null),
}
}
function serializeOutlineDraftNodes(nodes: OutlineDraftNode[]): TemplateOutlineNode[] {
return nodes
.map((node) => serializeOutlineDraftNode(node))
.filter((node): node is TemplateOutlineNode => node !== null)
}
function serializeOutlineDraftNode(node: OutlineDraftNode): TemplateOutlineNode | null {
const outline = node.outline.trim()
if (!outline) {
return null
}
const children = serializeOutlineDraftNodes(node.children)
return children.length > 0 ? { outline, children } : { outline }
}
function serializeOutlinePayloadNodes(nodes: TemplateOutlineNode[]): JsonValue[] {
return nodes.map((node) => serializeOutlinePayloadNode(node))
}
function serializeOutlinePayloadNode(node: TemplateOutlineNode): JsonValue {
const payload: Record<string, JsonValue> = {
outline: node.outline,
}
if (node.children && node.children.length > 0) {
payload.children = serializeOutlinePayloadNodes(node.children)
}
return payload
}
function addOutlineRootNode(): void {
const node = createEmptyOutlineNode()
generatedOutline.value = [...generatedOutline.value, node]
focusOutlineNode(node.id)
}
function addOutlineNodeAfter(targetId: string): void {
const node = createEmptyOutlineNode()
generatedOutline.value = insertOutlineSibling(generatedOutline.value, targetId, node)
focusOutlineNode(node.id)
}
function addOutlineChildNode(targetId: string): void {
const node = createEmptyOutlineNode()
generatedOutline.value = insertOutlineChild(generatedOutline.value, targetId, node)
focusOutlineNode(node.id)
}
function removeOutlineNode(targetId: string): void {
generatedOutline.value = deleteOutlineNode(generatedOutline.value, targetId)
}
function createEmptyOutlineNode(): OutlineDraftNode {
return { id: nextOutlineNodeKey(), outline: '', children: [] }
}
async function focusOutlineNode(nodeId: string): Promise<void> {
await nextTick()
window.requestAnimationFrame(() => {
const target = document.querySelector<HTMLElement>(`[data-outline-node-id="${nodeId}"]`)
target?.scrollIntoView({ behavior: 'smooth', block: 'center' })
target?.focus({ preventScroll: true })
})
}
function insertOutlineSibling(
nodes: OutlineDraftNode[],
targetId: string,
insertedNode: OutlineDraftNode,
): OutlineDraftNode[] {
const next: OutlineDraftNode[] = []
for (const node of nodes) {
const current: OutlineDraftNode = {
...node,
children: insertOutlineSibling(node.children, targetId, insertedNode),
}
next.push(current)
if (node.id === targetId) {
next.push(insertedNode)
}
}
return next
}
function insertOutlineChild(
nodes: OutlineDraftNode[],
targetId: string,
insertedNode: OutlineDraftNode,
): OutlineDraftNode[] {
return nodes.map((node) => {
if (node.id === targetId) {
return {
...node,
children: [...node.children, insertedNode],
}
}
return {
...node,
children: insertOutlineChild(node.children, targetId, insertedNode),
}
})
}
function deleteOutlineNode(nodes: OutlineDraftNode[], targetId: string): OutlineDraftNode[] {
return nodes
.filter((node) => node.id !== targetId)
.map((node) => ({
...node,
children: deleteOutlineNode(node.children, targetId),
}))
}
function parseTemplateCardConfig(
raw: Record<string, JsonValue> | null | undefined,
): TemplateCardConfig {
if (!raw || typeof raw !== 'object') {
return {}
}
const hero = asRecord(raw.hero)
const wizard = asRecord(raw.wizard)
const structure = asRecord(wizard?.structure)
const outline = asRecord(structure?.outline ?? wizard?.outline)
const steps = asRecord(wizard?.steps)
const basic = asRecord(wizard?.basic)
const review = asRecord(wizard?.review)
const sectionsRaw = Array.isArray(outline?.sections) ? outline?.sections : []
const basicCards = asRecord(basic?.cards)
const structureCards = asRecord(structure?.cards)
return {
hero: hero
? {
accent: stringFromUnknown(hero.accent),
helper: stringFromUnknown(hero.helper),
}
: undefined,
wizard: wizard
? {
managed_fields: stringListFromUnknown(wizard.managed_fields),
derived_inputs: readDerivedInputs(wizard.derived_inputs),
steps: steps
? {
basic: readStepConfig(steps.basic),
structure: readStepConfig(steps.structure),
generate: readStepConfig(steps.generate),
}
: undefined,
basic: basic
? {
analyze_button_label: stringFromUnknown(basic.analyze_button_label),
cards: basicCards
? {
brand: readCardCopy(basicCards.brand),
keywords: readCardCopy(basicCards.keywords),
template_fields: readCardCopy(basicCards.template_fields),
competitors: readCardCopy(basicCards.competitors),
}
: undefined,
}
: undefined,
structure: structure
? {
cards: structureCards
? {
choose_title: readCardCopy(structureCards.choose_title),
outline: readCardCopy(structureCards.outline),
preview: readCardCopy(structureCards.preview),
}
: undefined,
review_intro_hook: readReviewIntroHookConfig(structure.review_intro_hook),
}
: undefined,
review: review
? {
card: readCardCopy(review.card),
alert_message: stringFromUnknown(review.alert_message),
}
: undefined,
title_prompt_template:
stringFromUnknown(structure?.title_prompt_template) ||
stringFromUnknown(wizard.title_prompt_template),
outline: outline
? {
allow_custom: Boolean(outline.allow_custom),
custom_placeholder: stringFromUnknown(outline.custom_placeholder),
custom_max_length:
typeof outline.custom_max_length === 'number' && outline.custom_max_length > 0
? outline.custom_max_length
: undefined,
preview_title: stringFromUnknown(outline.preview_title),
preview_caption: stringFromUnknown(outline.preview_caption),
sections: sectionsRaw
.map((item, index) => ({ item: asRecord(item), index }))
.filter((entry): entry is { item: Record<string, unknown>; index: number } =>
Boolean(entry.item),
)
.map(({ item, index }) => ({
key: stringFromUnknown(item.key) || `section-${index + 1}`,
label: stringFromUnknown(item.label),
label_template: stringFromUnknown(item.label_template),
default: Boolean(item.default),
})),
}
: undefined,
}
: undefined,
}
}
function buildOutlineOptions(
config: TemplateOutlineConfig | undefined,
templateKey: string | undefined,
context: Record<string, string>,
): ResolvedOutlineOption[] {
const configured = (config?.sections ?? [])
.map((section) => {
const label =
resolveTemplateText(section.label_template, context) || section.label || section.key
return {
key: section.key,
label,
default: Boolean(section.default),
}
})
.filter((section) => section.label.trim() !== '')
if (configured.length > 0) {
return configured
}
const defaults = buildDefaultOutlineOptions(templateKey, context.locale || 'zh-CN')
return defaults.map((label, index) => ({
key: `default-${index}`,
label,
default: index < Math.min(3, defaults.length),
}))
}
function isBlockedOutlineOption(section: ResolvedOutlineOption): boolean {
const key = section.key.trim().toLowerCase()
const label = section.label.trim()
return key === 'site_list' || ['网站列表', '官网列表', '网址列表'].includes(label)
}
function sanitizeOutlineKeys(keys: string[] | undefined): string[] | undefined {
if (!keys) {
return undefined
}
const blockedKeys = new Set(
baseOutlineOptions.value.filter((section) => isBlockedOutlineOption(section)).map((section) => section.key),
)
const filtered = keys.filter((key) => !blockedKeys.has(key) && key.trim().toLowerCase() !== 'site_list')
return filtered.length > 0 ? filtered : []
}
function buildFallbackTitles(
_templateKey: string | undefined,
currentBrand: string,
primaryKeyword: string | undefined,
locale: string,
): string[] {
const subject = primaryKeyword?.trim() || currentBrand || 'GEO'
const isEnglish = locale === 'en-US'
return isEnglish
? [`A practical guide to ${subject}`, `${subject}: the key points worth covering`]
: [`${subject} 实用内容指南`, `${subject} 值得展开的核心看点`]
}
function buildDefaultOutlineOptions(_templateKey: string | undefined, locale: string): string[] {
const isEnglish = locale === 'en-US'
return isEnglish
? ['Intro', 'Core Content', 'Key Points', 'Conclusion']
: ['引言', '核心内容', '文章关键要点', '结论']
}
function resolveTemplateText(
template: string | undefined,
context: Record<string, string>,
): string {
const raw = (template || '').trim()
if (!raw) {
return ''
}
return raw.replace(templateTokenPattern, (_match, token: string) => context[token] || '').trim()
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null
}
return value as Record<string, unknown>
}
function stringFromUnknown(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined
}
function stringListFromUnknown(value: unknown): string[] | undefined {
if (!Array.isArray(value)) {
return undefined
}
const items = value.map((item) => (typeof item === 'string' ? item.trim() : '')).filter(Boolean)
return items.length > 0 ? items : undefined
}
function booleanFromUnknown(value: unknown): boolean | undefined {
return typeof value === 'boolean' ? value : undefined
}
function numberFromUnknown(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
}
function numberListFromUnknown(value: unknown): number[] | undefined {
if (!Array.isArray(value)) {
return undefined
}
const items = value
.map((item) => (typeof item === 'number' && Number.isFinite(item) ? item : NaN))
.filter((item) => Number.isFinite(item) && item > 0)
return items.length > 0 ? items : undefined
}
function clampStep(value: number | undefined): number {
if (typeof value !== 'number' || Number.isNaN(value)) {
return 0
}
if (value < 0) {
return 0
}
if (value > 2) {
return 2
}
return value
}
function parseDraftCompetitors(value: unknown): DraftCompetitor[] {
if (!Array.isArray(value)) {
return []
}
return value
.map((item) => asRecord(item))
.filter((item): item is Record<string, unknown> => Boolean(item))
.map((item) => ({
key: nextCompetitorKey(),
libraryId: numberFromUnknown(item.library_id),
name: stringFromUnknown(item.name) || '',
website: stringFromUnknown(item.website) || '',
description: stringFromUnknown(item.description) || '',
saved: Boolean(item.saved),
}))
}
function parseCustomOutlineSections(value: unknown): ResolvedOutlineOption[] {
if (!Array.isArray(value)) {
return []
}
return value
.map((item) => asRecord(item))
.filter((item): item is Record<string, unknown> => Boolean(item))
.map((item, index) => ({
key: stringFromUnknown(item.key) || `custom-${index + 1}`,
label: stringFromUnknown(item.label) || '',
default: Boolean(item.default),
custom: Boolean(item.custom),
}))
.filter((item) => item.label.trim() !== '')
}
function parseOutlineNodes(value: unknown): TemplateOutlineNode[] {
if (!Array.isArray(value)) {
return []
}
return value
.map((item) => parseOutlineNode(item))
.filter((item): item is TemplateOutlineNode => item !== null)
}
function parseOutlineNode(value: unknown): TemplateOutlineNode | null {
const record = asRecord(value)
if (!record) {
return null
}
const outline = stringFromUnknown(record.outline)?.trim()
if (!outline) {
return null
}
const children = parseOutlineNodes(record.children)
return children.length > 0 ? { outline, children } : { outline }
}
function readStepConfig(value: unknown): TemplateStepConfig | undefined {
const record = asRecord(value)
if (!record) {
return undefined
}
return {
title: stringFromUnknown(record.title),
description: stringFromUnknown(record.description),
}
}
function readCardCopy(value: unknown): TemplateCardCopy | undefined {
const record = asRecord(value)
if (!record) {
return undefined
}
return {
title: stringFromUnknown(record.title),
hint: stringFromUnknown(record.hint),
visible: booleanFromUnknown(record.visible),
}
}
function readReviewIntroHookConfig(value: unknown): TemplateReviewIntroHookConfig | undefined {
const record = asRecord(value)
if (!record) {
return undefined
}
const options = Array.isArray(record.options)
? record.options
.map((item, index) => ({ item: asRecord(item), index }))
.filter((entry): entry is { item: Record<string, unknown>; index: number } =>
Boolean(entry.item),
)
.map(({ item, index }) => ({
key: stringFromUnknown(item.key) || `review-hook-${index + 1}`,
label:
stringFromUnknown(item.label) || stringFromUnknown(item.key) || `Hook ${index + 1}`,
description: stringFromUnknown(item.description),
default: Boolean(item.default),
}))
: []
return {
title: stringFromUnknown(record.title),
hint: stringFromUnknown(record.hint),
field_name: stringFromUnknown(record.field_name),
required: booleanFromUnknown(record.required),
options,
}
}
function readDerivedInputs(value: unknown): Record<string, TemplateDerivedInputConfig> | undefined {
const record = asRecord(value)
if (!record) {
return undefined
}
const entries: Array<[string, TemplateDerivedInputConfig]> = []
for (const [key, raw] of Object.entries(record)) {
const item = asRecord(raw)
if (!item) {
continue
}
entries.push([
key,
{
source: stringFromUnknown(item.source),
fallback_number:
typeof item.fallback_number === 'number' ? item.fallback_number : undefined,
},
])
}
if (entries.length === 0) {
return undefined
}
return Object.fromEntries(entries)
}
function resolveDerivedInputValue(
_fieldName: string,
config: TemplateDerivedInputConfig | undefined,
): JsonValue | undefined {
if (!config?.source) {
return undefined
}
switch (config.source) {
case 'brand_name':
return normalizedBrandName.value || undefined
case 'official_website':
return officialWebsite.value.trim() || undefined
case 'brand_summary':
return brandSummary.value.trim() || undefined
case 'primary_keyword':
return primaryKeyword.value || undefined
case 'primary_keyword_or_brand':
return primaryKeyword.value || normalizedBrandName.value || undefined
case 'competitor_count': {
const competitorCount = buildCompetitorPayload().length
if (competitorCount > 0) {
return competitorCount
}
if (typeof config.fallback_number === 'number') {
return config.fallback_number
}
return undefined
}
default:
return undefined
}
}
function reviewIntroHookToneClass(key: string): string {
switch (key) {
case 'question':
return 'review-hook-option--question'
case 'fact':
return 'review-hook-option--fact'
case 'quote':
return 'review-hook-option--quote'
case 'story':
return 'review-hook-option--story'
case 'feeling':
return 'review-hook-option--feeling'
default:
return 'review-hook-option--default'
}
}
function reviewIntroHookBadgeText(key: string): string {
switch (key) {
case 'question':
return '问'
case 'fact':
return '据'
case 'quote':
return '引'
case 'story':
return '事'
case 'feeling':
return '感'
default:
return Array.from(key.trim())[0] || '引'
}
}
function buildTemplateRenderContext(input: {
locale: string
brandName: string
officialWebsite: string
brandSummary: string
primaryQuestion: string
supplementalQuestions: string[]
inputParams: Record<string, JsonValue>
competitorCount: number
}): Record<string, string> {
const primaryQuestion = input.primaryQuestion.trim()
const supplementalQuestions = dedupeStrings(input.supplementalQuestions)
const context: Record<string, string> = {
locale: input.locale.trim(),
current_year: String(new Date().getFullYear()),
brand_name: input.brandName.trim(),
official_website: input.officialWebsite.trim(),
brand_summary: input.brandSummary.trim(),
brand_question: primaryQuestion,
primary_question: primaryQuestion,
primary_keyword: primaryQuestion || input.brandName.trim(),
supplemental_questions: supplementalQuestions.join(', '),
}
for (const [key, value] of Object.entries(input.inputParams)) {
const text = templateValueToString(value)
if (text) {
context[key] = text
}
}
if (!context.count && input.competitorCount > 0) {
context.count = String(input.competitorCount)
}
if (context.count) {
context.top_count = context.count
}
return Object.fromEntries(Object.entries(context).filter(([, value]) => value.trim() !== ''))
}
function templateValueToString(value: JsonValue | undefined): string {
if (value === null || value === undefined) {
return ''
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return String(value).trim()
}
if (Array.isArray(value)) {
return value
.map((item) => templateValueToString(item))
.filter(Boolean)
.join(', ')
}
return Object.values(value)
.map((item) => templateValueToString(item))
.filter(Boolean)
.join(', ')
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
window.setTimeout(resolve, ms)
})
}
const dragState = ref<{ id: string; type: 'branch' | 'leaf'; parentId: string | null } | null>(null)
const dragOverId = ref<string | null>(null)
const structureDragKey = ref<string | null>(null)
const structureDragOverKey = ref<string | null>(null)
function onOutlineDragStart(
e: DragEvent,
id: string,
type: 'branch' | 'leaf',
parentId: string | null,
) {
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', JSON.stringify({ id, type, parentId }))
e.dataTransfer.setData('text/id', id)
setTimeout(() => {
dragState.value = { id, type, parentId }
}, 0)
}
}
function onOutlineDragOver(e: DragEvent, id: string, type: 'branch' | 'leaf') {
e.preventDefault()
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'move'
}
if (dragState.value?.type === type && dragState.value?.id !== id) {
dragOverId.value = id
}
}
function onOutlineDragLeave(_e: DragEvent, id: string) {
if (dragOverId.value === id) {
dragOverId.value = null
}
}
function onOutlineDrop(
e: DragEvent,
targetId: string,
targetType: 'branch' | 'leaf',
targetParentId: string | null,
) {
dragOverId.value = null
if (!e.dataTransfer) return
const raw = e.dataTransfer.getData('text/plain')
if (!raw) return
try {
const data = JSON.parse(raw)
const sourceId = data.id
const sourceType = data.type
const sourceParentId = data.parentId
if (sourceId === targetId) return
if (sourceType === 'branch' && targetType === 'branch') {
const sourceIndex = generatedOutline.value.findIndex(
(n: OutlineDraftNode) => n.id === sourceId,
)
const targetIndex = generatedOutline.value.findIndex(
(n: OutlineDraftNode) => n.id === targetId,
)
if (sourceIndex > -1 && targetIndex > -1) {
generatedOutline.value = moveArrayItemBefore(
generatedOutline.value,
sourceIndex,
targetIndex,
)
}
} else if (sourceType === 'leaf' && targetType === 'leaf') {
const sourceParent = generatedOutline.value.find(
(n: OutlineDraftNode) => n.id === sourceParentId,
)
const targetParent = generatedOutline.value.find(
(n: OutlineDraftNode) => n.id === targetParentId,
)
if (sourceParent && targetParent) {
const sourceIndex = sourceParent.children.findIndex(
(c: OutlineDraftNode) => c.id === sourceId,
)
const targetIndex = targetParent.children.findIndex(
(c: OutlineDraftNode) => c.id === targetId,
)
if (sourceIndex > -1 && targetIndex > -1) {
generatedOutline.value = generatedOutline.value.map((node) => {
if (node.id !== sourceParentId && node.id !== targetParentId) {
return node
}
if (sourceParentId === targetParentId && node.id === sourceParentId) {
return {
...node,
children: moveArrayItemBefore(node.children, sourceIndex, targetIndex),
}
}
if (node.id === sourceParentId) {
const nextChildren = [...node.children]
nextChildren.splice(sourceIndex, 1)
return {
...node,
children: nextChildren,
}
}
if (node.id === targetParentId) {
const movedNode = sourceParent.children[sourceIndex]
if (!movedNode) {
return node
}
const nextChildren = [...node.children]
nextChildren.splice(targetIndex, 0, movedNode)
return {
...node,
children: nextChildren,
}
}
return node
})
}
}
}
} catch (err) {
// ignore
}
dragState.value = null
}
function onOutlineDragEnd() {
dragState.value = null
dragOverId.value = null
}
function moveArrayItemBefore<T>(items: T[], sourceIndex: number, targetIndex: number): T[] {
if (
sourceIndex < 0 ||
targetIndex < 0 ||
sourceIndex >= items.length ||
targetIndex >= items.length ||
sourceIndex === targetIndex
) {
return items
}
const next = [...items]
const [moved] = next.splice(sourceIndex, 1)
if (moved === undefined) {
return items
}
const insertIndex = sourceIndex < targetIndex ? targetIndex - 1 : targetIndex
next.splice(insertIndex, 0, moved)
return next
}
function onStructureDragStart(event: DragEvent, key: string): void {
if (!event.dataTransfer) {
return
}
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', key)
window.setTimeout(() => {
structureDragKey.value = key
}, 0)
}
function onStructureDragOver(event: DragEvent, key: string): void {
event.preventDefault()
if (structureDragKey.value && structureDragKey.value !== key) {
structureDragOverKey.value = key
}
}
function onStructureDragLeave(key: string): void {
if (structureDragOverKey.value === key) {
structureDragOverKey.value = null
}
}
function onStructureDrop(event: DragEvent, key: string): void {
event.preventDefault()
const sourceKey = structureDragKey.value || event.dataTransfer?.getData('text/plain')
structureDragOverKey.value = null
if (!sourceKey || sourceKey === key) {
return
}
const sourceIndex = outlineOptionOrder.value.findIndex((item) => item === sourceKey)
const targetIndex = outlineOptionOrder.value.findIndex((item) => item === key)
if (sourceIndex < 0 || targetIndex < 0) {
return
}
outlineOptionOrder.value = moveArrayItemBefore(outlineOptionOrder.value, sourceIndex, targetIndex)
}
function onStructureDragEnd(): void {
structureDragKey.value = null
structureDragOverKey.value = null
}
</script>
<template>
<div class="wizard-page">
<div v-if="isPageLoading" class="wizard-page__loading">
<a-skeleton active :paragraph="{ rows: 12 }" />
</div>
<template v-else-if="templateDetail">
<header class="wizard-page__header">
<div class="header-left">
<a-button
type="text"
shape="circle"
:loading="savingDraft"
@click="handleSaveDraftAndExit"
>
<template #icon><LeftOutlined /></template>
</a-button>
<div class="header-title-box">
<h2 class="header-title">
创建 "{{ templateDetail.template_name }}" 文章
<span class="header-accent">{{ templateMeta.accent }}</span>
</h2>
<p class="header-eyebrow">{{ templateMeta.helper }}</p>
</div>
</div>
</header>
<main class="wizard-page__main">
<div class="wizard-steps-container">
<a-steps :current="currentStep" class="wizard-steps" size="small">
<a-step :title="basicStepCopy.title || t('templates.wizard.steps.basic')" />
<a-step :title="structureStepCopy.title || t('templates.wizard.steps.structure')" />
<a-step :title="generateStepCopy.title || t('templates.wizard.steps.generate')" />
</a-steps>
</div>
<section v-if="currentStep === 0" class="wizard-section">
<div class="wizard-card">
<div class="wizard-card__header">
<div>
<h3>{{ brandCardCopy.title || t('templates.wizard.sections.brandInfo') }}</h3>
<p>{{ brandCardCopy.hint || t('templates.wizard.hints.brandFlow') }}</p>
</div>
</div>
<div class="field-grid">
<div class="field-item">
<label class="required-asterisk">
{{ t('templates.wizard.sections.language') }}
</label>
<a-select
v-model:value="articleLocale"
:options="[
{ label: t('templates.wizard.localeOptions.zh'), value: 'zh-CN' },
{ label: t('templates.wizard.localeOptions.en'), value: 'en-US' },
]"
/>
</div>
<div class="field-item">
<label class="required-asterisk">
{{ t('templates.wizard.sections.brandName') }}
</label>
<div class="current-brand-field">
<strong>{{ normalizedBrandName || t('shell.currentCompanyPlaceholder') }}</strong>
<span v-if="selectedBrand?.website">{{ selectedBrand.website }}</span>
</div>
</div>
<div class="field-item">
<label>{{ t('templates.wizard.sections.website') }}</label>
<a-input
v-model:value="officialWebsite"
:placeholder="t('templates.wizard.placeholders.website')"
/>
</div>
</div>
<div v-if="showBrandSummaryBox" class="summary-box">
<div class="summary-box__label">
{{ t('templates.wizard.sections.brandSummary') }}
</div>
<p>
{{
brandSummary ||
selectedBrand?.description ||
t('templates.wizard.hints.brandSummaryPlaceholder')
}}
</p>
</div>
</div>
<div class="wizard-card">
<div class="wizard-card__header">
<div>
<h3>
{{ questionCardCopy.title || t('templates.wizard.sections.brandQuestions') }}
</h3>
<p>
{{ questionCardCopy.hint || t('templates.wizard.hints.brandQuestions') }}
</p>
</div>
</div>
<div class="brand-question-panel">
<div class="field-item">
<label class="required-asterisk">
{{ t('templates.wizard.sections.primaryQuestion') }}
</label>
<a-select
v-model:value="primaryQuestionId"
allow-clear
show-search
:disabled="!selectedBrandId"
:loading="questionsQuery.isPending.value"
:not-found-content="
selectedBrandId
? t('templates.wizard.hints.questionEmpty')
: t('templates.wizard.hints.selectBrandFirst')
"
:options="questionOptions"
:placeholder="t('templates.wizard.placeholders.primaryQuestion')"
option-filter-prop="label"
style="width: 100%"
/>
</div>
<div class="field-item">
<div class="optional-field-label">
<label>{{ t('templates.wizard.sections.supplementalQuestions') }}</label>
<span>{{ supplementalQuestionIds.length }}/3</span>
</div>
<a-select
v-model:value="supplementalQuestionIds"
mode="multiple"
show-search
:disabled="!selectedBrandId || !primaryQuestionId"
:loading="questionsQuery.isPending.value"
:max-tag-count="3"
:not-found-content="t('templates.wizard.hints.questionEmpty')"
:options="supplementalQuestionOptions"
:placeholder="t('templates.wizard.placeholders.supplementalQuestions')"
option-filter-prop="label"
style="width: 100%"
/>
<p class="field-helper">
{{ t('templates.wizard.hints.supplementalQuestions') }}
</p>
</div>
</div>
</div>
<div v-if="showCompetitorsCard" class="wizard-card">
<div class="wizard-card__header">
<div>
<h3>
{{ competitorsCardCopy.title || t('templates.wizard.sections.competitors') }}
</h3>
<p>{{ competitorsCardCopy.hint || t('templates.wizard.hints.competitors') }}</p>
</div>
<div class="competitor-header-actions">
<a-button
class="competitor-ai-button"
type="primary"
:loading="assistBusy"
:disabled="!canManageCompetitors"
@click="handleAnalyze"
>
<template #icon><RobotOutlined /></template>
{{ analyzeButtonLabel }}
</a-button>
<a-button
type="default"
:disabled="!canManageCompetitors"
@click="addCompetitorRow"
>
<template #icon><PlusOutlined /></template>
{{ t('templates.wizard.actions.addCompetitor') }}
</a-button>
</div>
</div>
<div v-if="competitorDrafts.length > 0" class="competitor-table">
<div class="competitor-table__head">
<span>{{ t('templates.wizard.table.website') }}</span>
<span>{{ t('templates.wizard.table.name') }}</span>
<span>{{ t('templates.wizard.table.description') }}</span>
<span>{{ t('templates.wizard.table.actions') }}</span>
</div>
<div v-for="item in competitorDrafts" :key="item.key" class="competitor-table__row">
<a-input
v-model:value="item.website"
:placeholder="t('templates.wizard.placeholders.competitorWebsite')"
/>
<a-input
v-model:value="item.name"
:placeholder="t('templates.wizard.placeholders.competitorName')"
/>
<a-input
v-model:value="item.description"
:placeholder="t('templates.wizard.placeholders.competitorDescription')"
/>
<div class="competitor-actions">
<a-tooltip
:title="
canSaveCompetitorsToLibrary
? undefined
: t('templates.wizard.messages.selectBrandBeforeFavorite')
"
>
<span>
<a-button
type="link"
:disabled="!canSaveCompetitorsToLibrary"
:loading="competitorSavingKey === item.key"
@click="handleFavoriteCompetitor(item)"
>
<template v-if="canSaveCompetitorsToLibrary && item.saved" #icon>
<StarFilled />
</template>
{{
canSaveCompetitorsToLibrary && item.saved
? t('templates.wizard.actions.unfavorite')
: t('templates.wizard.actions.favorite')
}}
</a-button>
</span>
</a-tooltip>
<a-button type="text" danger @click="removeCompetitorRow(item.key)">
<template #icon><DeleteOutlined /></template>
</a-button>
</div>
</div>
</div>
<a-empty v-else :description="t('templates.wizard.hints.competitorEmptyEditable')" />
</div>
</section>
<section v-else-if="currentStep === 1" class="wizard-section wizard-section--structure">
<div class="wizard-structure-main">
<div class="wizard-card">
<div class="wizard-card__header">
<div>
<h3>{{ titleCardCopy.title || t('templates.wizard.sections.chooseTitle') }}</h3>
<p>{{ titleCardCopy.hint || t('templates.wizard.hints.titleSelection') }}</p>
</div>
</div>
<a-radio-group v-model:value="selectedTitle" class="title-option-group">
<label v-for="option in titleOptions" :key="option" class="title-option">
<a-radio :value="option">{{ option }}</a-radio>
</label>
</a-radio-group>
<div class="field-item field-item--compact">
<label>{{ t('templates.wizard.sections.customTitle') }}</label>
<a-input
v-model:value="customTitle"
:placeholder="t('templates.wizard.placeholders.customTitle')"
/>
</div>
</div>
<div v-if="reviewIntroHookOptions.length > 0" class="wizard-card">
<div class="wizard-card__header">
<div>
<h3>
{{
reviewIntroHookConfig?.title || t('templates.wizard.sections.reviewIntroHook')
}}
</h3>
<p>
{{ reviewIntroHookConfig?.hint || t('templates.wizard.hints.reviewIntroHook') }}
</p>
</div>
</div>
<div class="review-hook-grid">
<button
v-for="option in reviewIntroHookOptions"
:key="option.key"
type="button"
:class="[
'review-hook-option',
reviewIntroHookToneClass(option.key),
{ 'review-hook-option--active': reviewIntroHook === option.key },
]"
@click="reviewIntroHook = option.key"
>
<span class="review-hook-option__badge">
{{ reviewIntroHookBadgeText(option.key) }}
</span>
<span class="review-hook-option__body">
<span class="review-hook-option__title">{{ option.label }}</span>
<span v-if="option.description" class="review-hook-option__description">
{{ option.description }}
</span>
</span>
<span class="review-hook-option__check"></span>
</button>
</div>
</div>
<div class="wizard-card">
<div class="wizard-card__header">
<div>
<h3>{{ outlineCardCopy.title || t('templates.wizard.sections.structure') }}</h3>
<p>{{ outlineCardCopy.hint || t('templates.wizard.hints.structure') }}</p>
</div>
</div>
<div class="outline-grid">
<div
v-for="section in outlineOptions"
:key="section.key"
:class="[
'outline-option',
{
'outline-option--active': outlineSections.includes(section.key),
'outline-option--dragging': structureDragKey === section.key,
'outline-option--drag-over': structureDragOverKey === section.key,
},
]"
draggable="true"
@dragstart="onStructureDragStart($event, section.key)"
@dragover="onStructureDragOver($event, section.key)"
@dragleave="onStructureDragLeave(section.key)"
@drop="onStructureDrop($event, section.key)"
@dragend="onStructureDragEnd"
>
<a-checkbox
:checked="outlineSections.includes(section.key)"
@change="toggleOutlineSection(section.key, $event.target.checked)"
>
{{ section.label }}
</a-checkbox>
<div class="outline-option__actions">
<a-button
type="text"
danger
size="small"
:title="t('common.delete')"
@click.stop="removeOutlineSection(section.key)"
>
<template #icon><DeleteOutlined /></template>
</a-button>
<span class="outline-option__handle">
<HolderOutlined />
</span>
</div>
</div>
</div>
<div v-if="canAddCustomOutline" class="field-item field-item--compact">
<label>{{ t('templates.wizard.sections.customOutline') }}</label>
<div class="custom-outline-row">
<a-input
v-model:value="customOutlineInput"
:maxlength="customOutlineMaxLength"
show-count
:placeholder="
wizardConfig?.outline?.custom_placeholder ||
t('templates.wizard.placeholders.customOutline')
"
@pressEnter="addCustomOutlineSection"
/>
<a-button @click="addCustomOutlineSection">
{{ t('templates.wizard.actions.addSection') }}
</a-button>
</div>
</div>
</div>
</div>
<aside class="wizard-card preview-card">
<div class="wizard-card__header">
<div>
<h3>{{ previewCardCopy.title || outlinePreviewTitle }}</h3>
<p>{{ previewCardCopy.hint || outlinePreviewCaption }}</p>
</div>
</div>
<div class="preview-shell">
<div class="preview-shell__chrome">
<span />
<span />
<span />
</div>
<div class="preview-shell__body">
<h4>{{ previewTitle }}</h4>
<div v-if="previewSections.length > 0" class="preview-sections">
<div
v-for="section in previewSections"
:key="section.key"
class="preview-section"
>
<div class="preview-section__title">{{ section.label }}</div>
<div class="preview-line preview-line--wide" />
<div class="preview-line" />
<div class="preview-line preview-line--short" />
</div>
</div>
<div v-else class="preview-empty">
{{ t('templates.wizard.hints.previewEmpty') }}
</div>
</div>
</div>
</aside>
</section>
<section v-else class="wizard-section wizard-section--structure">
<div class="wizard-structure-main">
<div class="wizard-card">
<div class="wizard-card__header">
<div>
<h3>{{ t('templates.wizard.sections.generatedOutline') }}</h3>
<p>{{ t('templates.wizard.hints.generatedOutline') }}</p>
</div>
<a-button type="default" @click="addOutlineRootNode">
<template #icon><PlusOutlined /></template>
{{ t('templates.wizard.actions.addOutlineNode') }}
</a-button>
</div>
<div v-if="generatedOutline.length > 0" class="outline-editor">
<div v-for="section in generatedOutline" :key="section.id" class="outline-branch">
<div
class="outline-branch__header"
:class="{
'is-dragging': dragState?.id === section.id,
'drag-over': dragOverId === section.id,
}"
@dragover="onOutlineDragOver($event, section.id, 'branch')"
@dragleave="onOutlineDragLeave($event, section.id)"
@drop="onOutlineDrop($event, section.id, 'branch', null)"
>
<span class="outline-branch__dot" />
<a-textarea
v-model:value="section.outline"
class="outline-input"
:data-outline-node-id="section.id"
:auto-size="{ minRows: 1 }"
:placeholder="t('templates.wizard.placeholders.outlineNode')"
/>
<div class="outline-node-actions">
<a-button type="text" @click="addOutlineChildNode(section.id)">
<template #icon><PlusOutlined /></template>
</a-button>
<a-button type="text" danger @click="removeOutlineNode(section.id)">
<template #icon><DeleteOutlined /></template>
</a-button>
<span
class="outline-node-handle"
draggable="true"
@dragstart="onOutlineDragStart($event, section.id, 'branch', null)"
@dragend="onOutlineDragEnd"
>
<HolderOutlined />
</span>
</div>
</div>
<div class="outline-branch__children">
<div
v-for="child in section.children"
:key="child.id"
class="outline-leaf"
:class="{
'is-dragging': dragState?.id === child.id,
'drag-over': dragOverId === child.id,
}"
@dragover="onOutlineDragOver($event, child.id, 'leaf')"
@dragleave="onOutlineDragLeave($event, child.id)"
@drop="onOutlineDrop($event, child.id, 'leaf', section.id)"
>
<span class="outline-leaf__dot" />
<a-textarea
v-model:value="child.outline"
class="outline-input"
:data-outline-node-id="child.id"
:auto-size="{ minRows: 1 }"
:placeholder="t('templates.wizard.placeholders.outlineNode')"
/>
<div class="outline-node-actions">
<a-button type="text" @click="addOutlineNodeAfter(child.id)">
<template #icon><PlusOutlined /></template>
</a-button>
<a-button type="text" danger @click="removeOutlineNode(child.id)">
<template #icon><DeleteOutlined /></template>
</a-button>
<span
class="outline-node-handle"
draggable="true"
@dragstart="onOutlineDragStart($event, child.id, 'leaf', section.id)"
@dragend="onOutlineDragEnd"
>
<HolderOutlined />
</span>
</div>
</div>
</div>
</div>
</div>
<div v-else class="preview-empty">
{{ t('templates.wizard.hints.outlineEmpty') }}
</div>
<div class="field-item field-item--compact">
<label>{{ t('templates.wizard.sections.keyPoints') }}</label>
<a-textarea
v-model:value="keyPoints"
:rows="5"
:placeholder="t('templates.wizard.placeholders.keyPoints')"
/>
</div>
<div class="field-item field-item--compact">
<label>引用知识库</label>
<KnowledgeGroupSelect
v-model="selectedKnowledgeGroupIds"
placeholder="可选,用于补充文章内容和事实背景"
/>
</div>
<a-alert type="info" show-icon class="review-alert" :message="reviewAlertMessage" />
</div>
</div>
<aside class="wizard-card preview-card">
<div class="wizard-card__header">
<div>
<h3>{{ t('templates.wizard.sections.outlinePreview') }}</h3>
<p>{{ t('templates.wizard.hints.outlinePreview') }}</p>
</div>
</div>
<div class="preview-shell">
<div class="preview-shell__chrome">
<span />
<span />
<span />
</div>
<div class="preview-shell__body">
<h4>{{ previewTitle }}</h4>
<p v-if="keyPoints.trim()" class="preview-lead">{{ keyPoints.trim() }}</p>
<div v-if="generatedOutlineNodes.length > 0" class="preview-sections">
<div
v-for="section in generatedOutlineNodes"
:key="section.outline"
class="preview-section"
>
<div class="preview-section__title">{{ section.outline }}</div>
<div
v-for="child in section.children || []"
:key="`${section.outline}-${child.outline}`"
class="preview-subsection"
>
<div class="preview-subsection__title">{{ child.outline }}</div>
<div class="preview-line preview-line--wide" />
<div class="preview-line" />
<div class="preview-line preview-line--short" />
</div>
<template v-if="!section.children || section.children.length === 0">
<div class="preview-line preview-line--wide" />
<div class="preview-line" />
<div class="preview-line preview-line--short" />
</template>
</div>
</div>
<div v-else class="preview-empty">
{{ t('templates.wizard.hints.outlineEmpty') }}
</div>
</div>
</div>
</aside>
</section>
</main>
<footer class="wizard-page__footer">
<div class="footer-previous">
<a-button v-if="currentStep === 0" class="footer-btn" disabled>
{{ t('common.previous') }}
</a-button>
<a-button v-else class="footer-btn" @click="handlePrev">
{{ t('common.previous') }}
</a-button>
</div>
<div class="footer-actions">
<a-button class="footer-btn" :loading="savingDraft" @click="handleSaveDraftAndExit">
{{ t('common.cancel') }}
</a-button>
<a-button
v-if="currentStep < 2"
type="primary"
class="footer-btn footer-btn--primary"
:loading="assistBusy"
@click="handleNext"
>
{{ t('common.next') }}
</a-button>
<a-button
v-else
type="primary"
class="footer-btn footer-btn--primary"
:loading="mutation.isPending.value"
@click="handleGenerate"
>
{{ t('templates.wizard.actions.submit') }}
</a-button>
</div>
</footer>
</template>
<AiWaitingModal
:open="assistModalOpen"
:title="assistHeading"
:progress="assistProgress"
:stage="assistStages[assistStageIndex]"
/>
<a-modal
:open="leaveConfirmOpen"
:title="t('templates.wizard.leaveConfirm.title')"
:closable="false"
:mask-closable="false"
:keyboard="false"
width="460px"
wrap-class-name="leave-confirm-modal-wrap"
>
<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>
<style scoped>
.wizard-page {
display: flex;
flex-direction: column;
min-height: calc(100vh - 120px);
background: #fff;
}
.leave-confirm-text {
margin: 0;
color: #52627a;
line-height: 1.7;
}
:deep(.leave-confirm-modal-wrap) {
display: flex;
align-items: flex-start;
justify-content: center;
padding: 32px 16px 16px;
overflow: auto;
}
:deep(.leave-confirm-modal-wrap .ant-modal) {
top: 0;
width: min(460px, calc(100vw - 32px)) !important;
margin: 0;
padding-bottom: 0;
}
:deep(.leave-confirm-modal-wrap .ant-modal-footer) {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 12px;
}
:deep(.leave-confirm-modal-wrap .ant-modal-footer .ant-btn) {
margin-inline-start: 0 !important;
}
.wizard-page__loading {
padding: 40px;
}
.wizard-page__header {
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 32px;
border-bottom: 1px solid #edf2f7;
background: #fff;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.header-title-box {
display: flex;
flex-direction: column;
gap: 4px;
}
.header-title {
margin: 0;
color: #111827;
font-size: 20px;
font-weight: 800;
}
.header-accent {
margin-left: 8px;
color: #1677ff;
font-size: 14px;
font-weight: 500;
}
.header-eyebrow {
margin: 0;
color: #6d7c94;
font-size: 13px;
}
.wizard-page__main {
display: flex;
flex-direction: column;
margin: 0 auto;
width: 100%;
max-width: 1160px;
padding: 24px 32px 64px;
}
.wizard-steps-container {
padding: 0 0 24px;
border-bottom: 1px solid #edf2f7;
}
.wizard-section {
display: flex;
flex-direction: column;
}
.wizard-section--structure {
display: grid;
grid-template-columns: minmax(0, 1fr) 460px;
gap: 48px;
align-items: start;
}
.wizard-structure-main {
display: flex;
min-width: 0;
flex-direction: column;
gap: 20px;
}
.wizard-card {
padding: 32px 0;
border-bottom: 1px solid #edf2f7;
}
.wizard-section > .wizard-card:last-child,
.wizard-structure-main > .wizard-card:last-child {
border-bottom: none;
}
.wizard-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.wizard-card__header h3 {
margin: 0;
color: #111827;
font-size: 16px;
font-weight: 800;
display: flex;
align-items: center;
}
.wizard-card__header h3::before {
content: '';
display: inline-block;
width: 4px;
height: 16px;
border-radius: 2px;
background: #111827;
margin-right: 12px;
}
.wizard-card__header p {
margin: 6px 0 0 16px;
color: #667085;
font-size: 13px;
line-height: 1.7;
}
.field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
}
.field-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.field-item--compact {
margin-top: 20px;
}
.field-item > label {
display: inline-flex;
align-items: center;
margin-bottom: 12px;
color: #1f2937;
font-size: 15px;
font-weight: 700;
}
.field-item > label::after {
content: '';
}
.current-brand-field {
display: flex;
min-height: 32px;
flex-direction: column;
justify-content: center;
gap: 2px;
padding: 5px 11px;
border: 1px solid #d9d9d9;
border-radius: 6px;
background: #fafcff;
}
.current-brand-field strong {
overflow: hidden;
color: #111827;
font-size: 14px;
font-weight: 600;
line-height: 20px;
text-overflow: ellipsis;
white-space: nowrap;
}
.current-brand-field span {
overflow: hidden;
color: #667085;
font-size: 12px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
.optional-field-label {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.optional-field-label label {
display: inline-flex;
align-items: center;
color: #1f2937;
font-size: 15px;
font-weight: 700;
}
.optional-field-label label::after {
content: '';
}
.optional-field-label span {
color: #667085;
font-size: 12px;
font-weight: 700;
}
.field-helper {
margin: 0;
color: #667085;
font-size: 12px;
line-height: 1.6;
}
.required-asterisk::before {
margin-right: 4px;
color: #ff4d4f;
content: '*';
}
.brand-question-panel {
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr);
gap: 18px;
}
.summary-box {
margin-top: 18px;
padding: 16px 18px;
border: 1px solid rgba(80, 126, 255, 0.12);
border-radius: 18px;
background: linear-gradient(135deg, rgba(238, 244, 255, 0.86), rgba(247, 249, 255, 0.96));
}
.summary-box__label {
margin-bottom: 8px;
color: #456191;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.summary-box p {
margin: 0;
color: #24324c;
line-height: 1.7;
}
.competitor-header-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
.competitor-ai-button {
border: none;
border-radius: 8px;
background: #1677ff;
box-shadow: 0 4px 12px rgba(22, 119, 255, 0.16);
font-weight: 700;
}
.competitor-ai-button:hover:not([disabled]) {
background: #0958d9 !important;
box-shadow: 0 6px 16px rgba(22, 119, 255, 0.22);
}
.competitor-ai-button[disabled],
.competitor-ai-button.ant-btn-loading {
background: #f1f5f9 !important;
border: 1px solid #e2e8f0 !important;
color: #94a3b8 !important;
box-shadow: none !important;
}
.competitor-table {
border: 1px solid rgba(20, 44, 88, 0.08);
border-radius: 20px;
overflow: hidden;
}
.competitor-table__head,
.competitor-table__row {
display: grid;
grid-template-columns: 1.2fr 1fr 1.4fr 160px;
gap: 12px;
align-items: center;
}
.competitor-table__head {
padding: 14px 16px;
background: rgba(241, 245, 255, 0.92);
color: #52647f;
font-size: 13px;
font-weight: 700;
}
.competitor-table__row {
padding: 16px;
border-top: 1px solid rgba(20, 44, 88, 0.06);
}
.competitor-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
}
.title-option-group {
display: grid;
gap: 12px;
}
.title-option {
display: block;
padding: 16px 18px;
border: 1px solid rgba(20, 44, 88, 0.08);
border-radius: 18px;
background: #ffffff;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s ease;
}
.title-option:hover {
border-color: rgba(22, 119, 255, 0.3);
box-shadow: 0 10px 24px rgba(22, 119, 255, 0.08);
transform: translateY(-1px);
}
.review-hook-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.review-hook-option {
display: flex;
align-items: flex-start;
gap: 12px;
width: 100%;
padding: 16px 18px;
border: 1px solid rgba(20, 44, 88, 0.08);
border-radius: 18px;
background: #ffffff;
text-align: left;
cursor: pointer;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s ease;
}
.review-hook-option:hover {
border-color: rgba(22, 119, 255, 0.24);
box-shadow: 0 10px 24px rgba(22, 119, 255, 0.08);
transform: translateY(-1px);
}
.review-hook-option--active {
border-color: rgba(22, 119, 255, 0.32);
background: rgba(240, 246, 255, 0.96);
box-shadow: 0 12px 28px rgba(22, 119, 255, 0.1);
}
.review-hook-option__badge {
display: inline-flex;
width: 30px;
height: 30px;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 10px;
font-size: 13px;
font-weight: 700;
}
.review-hook-option__body {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 6px;
}
.review-hook-option__title {
color: #1f2f4d;
font-size: 14px;
font-weight: 700;
line-height: 1.5;
}
.review-hook-option__description {
color: #71809a;
font-size: 13px;
line-height: 1.6;
}
.review-hook-option__check {
color: #1677ff;
font-size: 16px;
font-weight: 700;
opacity: 0;
transition: opacity 0.2s ease;
}
.review-hook-option--active .review-hook-option__check {
opacity: 1;
}
.review-hook-option--question .review-hook-option__badge {
background: rgba(255, 163, 67, 0.16);
color: #d67a00;
}
.review-hook-option--fact .review-hook-option__badge {
background: rgba(34, 197, 94, 0.14);
color: #1f8f4a;
}
.review-hook-option--quote .review-hook-option__badge {
background: rgba(59, 130, 246, 0.14);
color: #2667d8;
}
.review-hook-option--story .review-hook-option__badge {
background: rgba(34, 197, 94, 0.12);
color: #198754;
}
.review-hook-option--feeling .review-hook-option__badge {
background: rgba(245, 158, 11, 0.14);
color: #c77a00;
}
.review-hook-option--default .review-hook-option__badge {
background: rgba(107, 114, 128, 0.14);
color: #4b5563;
}
.outline-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.outline-option {
display: flex;
min-height: 64px;
align-items: center;
justify-content: space-between;
margin-inline-start: 0 !important;
padding: 14px 16px;
border: 1px solid rgba(20, 44, 88, 0.08);
border-radius: 18px;
background: rgba(252, 253, 255, 0.96);
cursor: grab;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s ease;
}
.outline-option:hover {
border-color: rgba(22, 119, 255, 0.24);
transform: translateY(-1px);
}
.outline-option--active {
border-color: rgba(22, 119, 255, 0.28);
background: rgba(240, 246, 255, 0.96);
box-shadow: 0 10px 26px rgba(22, 119, 255, 0.08);
}
.outline-option--dragging {
opacity: 0.68;
}
.outline-option--drag-over {
border-color: rgba(22, 119, 255, 0.42);
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
}
.outline-option :deep(.ant-checkbox) {
align-self: flex-start;
margin-top: 3px;
}
.outline-option :deep(.ant-checkbox-wrapper) {
display: flex;
flex: 1;
}
.outline-option :deep(.ant-checkbox + span) {
padding-inline-end: 0;
color: #22304d;
font-size: 14px;
font-weight: 600;
line-height: 1.6;
}
.outline-option__handle {
margin-left: 12px;
color: rgba(71, 93, 131, 0.56);
font-size: 16px;
line-height: 1;
cursor: grab;
}
.outline-option__actions {
display: flex;
align-items: center;
margin-left: 12px;
}
.custom-outline-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
}
.outline-editor {
display: flex;
flex-direction: column;
gap: 18px;
}
.outline-branch {
position: relative;
padding-left: 26px;
}
.outline-branch::before {
position: absolute;
top: 12px;
bottom: 8px;
left: 10px;
border-left: 1px dashed rgba(123, 141, 176, 0.36);
content: '';
}
.outline-branch__header,
.outline-leaf {
position: relative;
display: flex;
gap: 12px;
align-items: center;
}
.outline-branch__dot,
.outline-leaf__dot {
flex-shrink: 0;
width: 10px;
height: 10px;
border-radius: 999px;
background: #d7dfea;
box-shadow: 0 0 0 4px rgba(238, 243, 252, 0.9);
}
.outline-branch__children {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 14px;
margin-left: 16px;
padding-left: 18px;
}
.outline-leaf::before {
position: absolute;
top: 50%;
left: -18px;
width: 12px;
border-top: 1px dashed rgba(123, 141, 176, 0.36);
content: '';
}
.outline-node-actions {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
opacity: 0;
visibility: hidden;
transition:
opacity 0.2s ease,
visibility 0.2s ease;
}
.outline-branch__header:hover .outline-node-actions,
.outline-leaf:hover .outline-node-actions,
.outline-branch__header:focus-within .outline-node-actions,
.outline-leaf:focus-within .outline-node-actions {
opacity: 1;
visibility: visible;
}
.outline-node-handle {
display: inline-flex;
align-items: center;
justify-content: center;
color: #9aa8bf;
font-size: 16px;
cursor: grab;
}
.outline-node-handle:active {
cursor: grabbing;
}
.outline-input {
flex: 1;
min-width: 0;
border-color: transparent !important;
background-color: transparent !important;
box-shadow: none !important;
transition: all 0.2s;
padding: 4px 8px;
resize: none;
}
.outline-input:hover {
background-color: rgba(0, 0, 0, 0.02) !important;
}
.outline-input:focus,
.outline-input:focus-within {
border-color: #1677ff !important;
background-color: #fff !important;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1) !important;
}
.is-dragging {
opacity: 0.4;
}
.drag-over {
border-top: 2px solid #1677ff;
}
.preview-card {
position: sticky;
top: 32px;
border-bottom: none;
}
.preview-shell {
overflow: hidden;
border: 1px solid rgba(20, 44, 88, 0.08);
border-radius: 24px;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
}
.preview-shell__chrome {
display: flex;
gap: 8px;
padding: 14px 16px;
border-bottom: 1px solid rgba(20, 44, 88, 0.08);
background: rgba(246, 249, 255, 0.92);
}
.preview-shell__chrome span {
width: 10px;
height: 10px;
border-radius: 999px;
background: rgba(79, 111, 164, 0.22);
}
.preview-shell__body {
display: flex;
flex-direction: column;
gap: 20px;
padding: 22px 22px 26px;
}
.preview-shell__body h4 {
margin: 0;
color: #132341;
font-size: 28px;
font-weight: 700;
line-height: 1.35;
}
.preview-lead {
margin: 0;
color: #5a6982;
font-size: 14px;
line-height: 1.7;
}
.preview-sections {
display: flex;
flex-direction: column;
gap: 18px;
}
.preview-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.preview-subsection {
display: flex;
flex-direction: column;
gap: 10px;
padding-left: 12px;
}
.preview-section__title {
color: #1b2944;
font-size: 20px;
font-weight: 700;
}
.preview-subsection__title {
color: #33435f;
font-size: 15px;
font-weight: 600;
}
.preview-line {
height: 10px;
border-radius: 999px;
background: linear-gradient(90deg, rgba(224, 232, 247, 0.92), rgba(241, 245, 252, 0.78));
}
.preview-line--wide {
width: 100%;
}
.preview-line--short {
width: 62%;
}
.preview-empty {
padding: 18px;
border: 1px dashed rgba(105, 128, 167, 0.28);
border-radius: 18px;
color: #6f7e97;
font-size: 14px;
line-height: 1.7;
}
.review-alert {
margin-top: 20px;
}
.wizard-page__footer {
position: sticky;
bottom: 0;
z-index: 100;
margin-top: auto;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-top: 1px solid rgba(20, 44, 88, 0.08);
backdrop-filter: blur(14px);
background: rgba(255, 255, 255, 0.88);
}
.footer-previous {
flex: 0 0 auto;
}
.footer-actions {
display: flex;
gap: 12px;
flex: 0 0 auto;
}
.footer-btn.ant-btn {
min-width: 104px;
height: 40px;
border-radius: 8px;
padding: 0 20px;
font-size: 16px;
font-weight: 400;
}
.footer-btn--primary.ant-btn {
min-width: 144px;
}
@media (max-width: 1024px) {
.wizard-section--structure {
grid-template-columns: 1fr;
}
.field-grid {
grid-template-columns: 1fr;
}
.brand-question-panel {
grid-template-columns: 1fr;
}
.competitor-table__head,
.competitor-table__row {
grid-template-columns: 1fr;
}
.competitor-actions {
justify-content: flex-start;
}
.competitor-header-actions {
justify-content: flex-start;
}
.outline-grid {
grid-template-columns: 1fr;
}
.review-hook-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.preview-card {
position: static;
}
}
@media (max-width: 768px) {
.wizard-page__main {
margin: 16px;
}
.wizard-card {
padding: 18px;
border-radius: 22px;
}
.wizard-page__footer {
flex-direction: row;
gap: 12px;
align-items: center;
padding: 14px 16px;
}
.custom-outline-row {
grid-template-columns: 1fr;
}
.review-hook-grid {
grid-template-columns: 1fr;
}
.footer-actions {
justify-content: flex-end;
}
}
@media (max-width: 560px) {
.wizard-page__footer {
gap: 8px;
}
.footer-actions {
gap: 8px;
}
.footer-btn.ant-btn {
min-width: auto;
padding: 0 14px;
font-size: 14px;
}
.footer-btn--primary.ant-btn {
min-width: auto;
}
}
</style>