feat(questions): make brand questions the primary monitoring & template axis
Deployment Config CI / Deployment Config (push) Successful in 29s
Frontend CI / Frontend (push) Successful in 4m4s
Backend CI / Backend (push) Failing after 7m12s

- add /questions/combination-fill endpoint with AI-driven, IP-region-aware matrix fill
- extract ip2region resolver from ops/app into shared/ipregion for cross-service use
- thread question_id filter through dashboard composite, citation summary, and collect-now
- switch template wizard from keyword inputs to brand-question selection (primary + supplemental)
- pass brand_question and supplemental_questions through assist/title/outline prompts
- add AiWaitingModal + 45s client timeout and request_timeout error mapping for long AI flows

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 15:59:39 +08:00
parent 37b0b32327
commit 1eae6fb6d4
36 changed files with 2119 additions and 412 deletions
+254 -107
View File
@@ -10,6 +10,7 @@ import {
import type {
ArticleDetail,
JsonValue,
Question,
TemplateAnalyzeResult,
TemplateAssistCompetitor,
TemplateOutlineNode,
@@ -20,6 +21,7 @@ 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'
@@ -170,7 +172,8 @@ const brandName = ref('')
const officialWebsite = ref('')
const brandSummary = ref('')
const keywordDrafts = ref<string[]>([])
const primaryQuestionId = ref<number | null>(null)
const supplementalQuestionIds = ref<number[]>([])
const competitorDrafts = ref<DraftCompetitor[]>([])
const assistTitles = ref<string[]>([])
@@ -218,10 +221,10 @@ const draftArticleQuery = useQuery({
queryFn: () => articlesApi.detail(draftArticleId.value as number),
})
const keywordsQuery = useQuery({
queryKey: computed(() => ['brands', selectedBrandId.value, 'keywords']),
const questionsQuery = useQuery({
queryKey: computed(() => ['brands', selectedBrandId.value, 'questions', 'wizard']),
enabled: computed(() => enabled.value && Boolean(selectedBrandId.value)),
queryFn: () => brandsApi.listKeywords(selectedBrandId.value as number),
queryFn: () => brandsApi.listQuestions(selectedBrandId.value as number),
})
const competitorsQuery = useQuery({
@@ -284,18 +287,41 @@ const selectedBrand = computed(
() => brandsQuery.data.value?.find((brand) => brand.id === selectedBrandId.value) ?? null,
)
const keywordOptions = computed(() =>
(keywordsQuery.data.value ?? []).map((item) => ({
label: item.name,
value: item.name,
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 normalizedBrandName = computed(
() => brandName.value.trim() || selectedBrand.value?.name || '',
)
const finalTitle = computed(() => customTitle.value.trim() || selectedTitle.value.trim())
const primaryKeyword = computed(() => keywordDrafts.value[0]?.trim() || '')
const primaryKeyword = computed(() => primaryQuestionText.value)
const showCompetitorsCard = computed(
() =>
!isResearchReportTemplate.value &&
@@ -333,7 +359,8 @@ const templateRenderContext = computed(() =>
brandName: normalizedBrandName.value,
officialWebsite: officialWebsite.value,
brandSummary: brandSummary.value,
primaryKeyword: primaryKeyword.value,
primaryQuestion: primaryQuestionText.value,
supplementalQuestions: supplementalQuestionTexts.value,
inputParams: buildStructureInputParams(),
competitorCount: buildCompetitorPayload().length,
}),
@@ -389,16 +416,16 @@ const generateStepCopy = computed<TemplateStepConfig>(
() => wizardConfig.value?.steps?.generate ?? {},
)
const brandCardCopy = computed<TemplateCardCopy>(
() => wizardConfig.value?.basic?.cards?.brand ?? {},
() => sanitizeLegacyKeywordCopy(wizardConfig.value?.basic?.cards?.brand ?? {}),
)
const keywordCardCopy = computed<TemplateCardCopy>(
() => wizardConfig.value?.basic?.cards?.keywords ?? {},
const questionCardCopy = computed<TemplateCardCopy>(
() => sanitizeQuestionCardCopy(wizardConfig.value?.basic?.cards?.keywords ?? {}),
)
const competitorsCardCopy = computed<TemplateCardCopy>(
() => wizardConfig.value?.basic?.cards?.competitors ?? {},
)
const titleCardCopy = computed<TemplateCardCopy>(
() => wizardConfig.value?.structure?.cards?.choose_title ?? {},
() => sanitizeLegacyKeywordCopy(wizardConfig.value?.structure?.cards?.choose_title ?? {}),
)
const outlineCardCopy = computed<TemplateCardCopy>(
() => wizardConfig.value?.structure?.cards?.outline ?? {},
@@ -442,7 +469,8 @@ const hasWizardDraftContent = computed(
Boolean(brandName.value.trim()) ||
Boolean(officialWebsite.value.trim()) ||
Boolean(brandSummary.value.trim()) ||
keywordDrafts.value.some((item) => item.trim()) ||
Boolean(primaryQuestionId.value) ||
supplementalQuestionIds.value.length > 0 ||
competitorDrafts.value.some(hasCompetitorDraftContent) ||
assistTitles.value.length > 0 ||
Boolean(customTitle.value.trim()) ||
@@ -456,7 +484,7 @@ const assistStages = computed(() =>
assistTaskKind.value === 'analyze'
? [
t('templates.wizard.assist.analyzeStages.brand'),
t('templates.wizard.assist.analyzeStages.keywords'),
t('templates.wizard.assist.analyzeStages.questions'),
showCompetitorsCard.value
? t('templates.wizard.assist.analyzeStages.competitors')
: t('templates.wizard.assist.analyzeStages.context'),
@@ -495,7 +523,8 @@ function resetWizardState(detail: NonNullable<typeof templateDetail.value>): voi
brandName.value = ''
officialWebsite.value = ''
brandSummary.value = ''
keywordDrafts.value = []
primaryQuestionId.value = null
supplementalQuestionIds.value = []
competitorDrafts.value = []
assistTitles.value = []
selectedTitle.value = ''
@@ -515,7 +544,8 @@ function resetWizardState(detail: NonNullable<typeof templateDetail.value>): voi
brandName: '',
officialWebsite: '',
brandSummary: '',
primaryKeyword: '',
primaryQuestion: '',
supplementalQuestions: [],
inputParams: {},
competitorCount: 0,
}),
@@ -530,7 +560,8 @@ function resetWizardState(detail: NonNullable<typeof templateDetail.value>): voi
brandName: '',
officialWebsite: '',
brandSummary: '',
primaryKeyword: '',
primaryQuestion: '',
supplementalQuestions: [],
inputParams: {},
competitorCount: 0,
}),
@@ -553,7 +584,9 @@ function applyDraftArticleState(detail: ArticleDetail): void {
brandName.value = stringFromUnknown(draftState.brand_name) || ''
officialWebsite.value = stringFromUnknown(draftState.official_website) || ''
brandSummary.value = stringFromUnknown(draftState.brand_summary) || ''
keywordDrafts.value = stringListFromUnknown(draftState.keyword_drafts) ?? []
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) || ''
@@ -619,6 +652,29 @@ watch(
{ 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
@@ -643,9 +699,9 @@ watch(selectedBrandId, async (brandId) => {
brandSummary.value = brand.description
}
const keywordsResult = await keywordsQuery.refetch()
keywordDrafts.value = dedupeStrings((keywordsResult.data ?? []).map((item) => item.name))
primaryQuestionId.value = null
supplementalQuestionIds.value = []
await questionsQuery.refetch()
if (!showCompetitorsCard.value) {
competitorDrafts.value = []
@@ -759,6 +815,14 @@ function validateBasicInfo(): boolean {
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
}
@@ -859,8 +923,8 @@ async function runAnalyzeTask(notifySuccess: boolean): Promise<void> {
brand_id: selectedBrandId.value,
brand_name: normalizedBrandName.value,
website: officialWebsite.value.trim() || null,
brand_question: primaryQuestionText.value,
input_params: buildAssistInputParams(),
existing_keywords: keywordDrafts.value,
existing_competitors: competitorPayload.length > 0 ? competitorPayload : undefined,
})
@@ -893,8 +957,9 @@ async function runTitleTask(): Promise<void> {
brand_name: normalizedBrandName.value,
website: officialWebsite.value.trim() || null,
brand_summary: brandSummary.value.trim() || null,
input_params: buildAssistInputParams(),
keywords: keywordDrafts.value,
brand_question: primaryQuestionText.value,
input_params: buildTitleInputParams(),
keywords: primaryQuestionText.value ? [primaryQuestionText.value] : [],
competitors: competitorPayload.length > 0 ? competitorPayload : undefined,
})
@@ -928,8 +993,10 @@ async function runOutlineTask(): Promise<void> {
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: keywordDrafts.value,
keywords: selectedQuestionTexts.value,
competitors: competitorPayload.length > 0 ? competitorPayload : undefined,
outline_sections: selectedOutlineLabels.value,
})
@@ -1019,7 +1086,6 @@ function applyAnalyzeResult(result: TemplateAnalyzeResult): void {
brandSummary.value = result.brand_summary.trim()
}
keywordDrafts.value = dedupeStrings([...keywordDrafts.value, ...(result.keywords ?? [])])
if (showCompetitorsCard.value) {
competitorDrafts.value = mergeCompetitors(competitorDrafts.value, result.competitors ?? [])
}
@@ -1097,8 +1163,42 @@ function buildCompetitorPayload(): TemplateAssistCompetitor[] {
}))
}
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, '后续生成')
}
function buildAssistInputParams(): Record<string, JsonValue> {
const payload: 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)
@@ -1110,8 +1210,16 @@ function buildAssistInputParams(): Record<string, JsonValue> {
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
}
@@ -1141,8 +1249,12 @@ function buildPayload(): Record<string, JsonValue> {
brand_name: normalizedBrandName.value || undefined,
brand_summary: brandSummary.value.trim() || undefined,
official_website: officialWebsite.value.trim() || undefined,
primary_keyword: primaryKeyword.value || undefined,
keywords: keywordDrafts.value.length > 0 ? dedupeStrings(keywordDrafts.value) : 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,
@@ -1173,7 +1285,10 @@ function buildDraftState(): Record<string, JsonValue> {
brand_name: brandName.value.trim(),
official_website: officialWebsite.value.trim(),
brand_summary: brandSummary.value.trim(),
keyword_drafts: dedupeStrings(keywordDrafts.value),
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,
@@ -1945,17 +2060,23 @@ function buildTemplateRenderContext(input: {
brandName: string
officialWebsite: string
brandSummary: string
primaryKeyword: 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(),
primary_keyword: input.primaryKeyword.trim() || input.brandName.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)) {
@@ -2338,17 +2459,60 @@ function onStructureDragEnd(): void {
<div class="wizard-card">
<div class="wizard-card__header">
<div>
<h3>{{ keywordCardCopy.title || t('templates.wizard.sections.keywords') }}</h3>
<p>{{ keywordCardCopy.hint || t('templates.wizard.hints.keywords') }}</p>
<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>
<a-select
v-model:value="keywordDrafts"
mode="tags"
:options="keywordOptions"
:placeholder="t('templates.wizard.placeholders.keywords')"
style="width: 100%"
/>
</div>
<div v-if="showCompetitorsCard" class="wizard-card">
@@ -2789,22 +2953,12 @@ function onStructureDragEnd(): void {
</footer>
</template>
<a-modal
<AiWaitingModal
:open="assistModalOpen"
:footer="null"
:closable="false"
:mask-closable="false"
centered
width="640px"
wrap-class-name="wizard-assist-modal"
>
<div class="assist-modal">
<div class="assist-orb" />
<h3>{{ assistHeading }}</h3>
<a-progress :percent="assistProgress" status="active" :show-info="false" />
<p>{{ assistStages[assistStageIndex] }}</p>
</div>
</a-modal>
:title="assistHeading"
:progress="assistProgress"
:stage="assistStages[assistStageIndex]"
/>
<a-modal
:open="leaveConfirmOpen"
@@ -2997,12 +3151,50 @@ function onStructureDragEnd(): void {
content: '';
}
.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;
}
.wizard-ai-banner {
display: flex;
align-items: center;
@@ -3574,51 +3766,6 @@ function onStructureDragEnd(): void {
gap: 12px;
}
.assist-modal {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
padding: 24px 20px 12px;
text-align: center;
}
.assist-orb {
width: 76px;
height: 76px;
border-radius: 999px;
background:
radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.92), transparent 38%),
conic-gradient(from 120deg, #4f8fff, #7dd3fc, #e879f9, #4f8fff);
box-shadow:
0 18px 38px rgba(79, 143, 255, 0.2),
inset 0 0 18px rgba(255, 255, 255, 0.4);
animation: orb-float 2.6s ease-in-out infinite;
}
.assist-modal h3 {
margin: 0;
color: #1b2741;
font-size: 22px;
font-weight: 700;
}
.assist-modal p {
margin: 0;
color: #5f6f8a;
font-size: 15px;
}
@keyframes orb-float {
0%,
100% {
transform: translateY(0) scale(1);
}
50% {
transform: translateY(-6px) scale(1.04);
}
}
@media (max-width: 1024px) {
.wizard-section--structure {
grid-template-columns: 1fr;
@@ -3628,6 +3775,10 @@ function onStructureDragEnd(): void {
grid-template-columns: 1fr;
}
.brand-question-panel {
grid-template-columns: 1fr;
}
.competitor-table__head,
.competitor-table__row {
grid-template-columns: 1fr;
@@ -3678,9 +3829,5 @@ function onStructureDragEnd(): void {
width: 100%;
justify-content: flex-end;
}
.assist-modal h3 {
font-size: 26px;
}
}
</style>