52997e36fe
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.
3922 lines
113 KiB
Vue
3922 lines
113 KiB
Vue
<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>
|