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
@@ -2,6 +2,7 @@
import {
DeleteOutlined,
EditOutlined,
BulbOutlined,
LeftOutlined,
RobotOutlined,
SaveOutlined,
@@ -15,10 +16,11 @@ import type {
} from '@geo/shared-types'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { message } from 'ant-design-vue'
import { computed, reactive, ref } from 'vue'
import { computed, onBeforeUnmount, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import AiWaitingModal from '@/components/AiWaitingModal.vue'
import { brandsApi } from '@/lib/api'
import { formatError } from '@/lib/errors'
@@ -39,10 +41,18 @@ const createMode = ref<CreateMode>('combination')
const candidateRows = ref<CandidateRow[]>([])
const editingCandidateId = ref<string | null>(null)
const pageNotice = ref('')
const aiCandidateModalOpen = ref(false)
const aiCandidateProgress = ref(12)
const aiCandidateStageIndex = ref(0)
const aiWaitingKind = ref<'candidate' | 'fill'>('candidate')
let aiCandidateProgressTimer: number | null = null
let aiCandidateCloseTimer: number | null = null
const vFocus = {
mounted(el: HTMLElement) {
const input = el.tagName === 'INPUT' ? el : el.querySelector('input')
const input =
el instanceof HTMLInputElement ? el : el.querySelector<HTMLInputElement>('input')
if (input) {
input.focus()
const len = input.value.length
@@ -52,11 +62,11 @@ const vFocus = {
}
const expansionForm = reactive({
region: ['合肥', '上海', '华东', ''],
prefix: ['好用的', '专业的', '靠谱的', ''],
core: ['GEO', 'AI搜索优化', ''],
industry: ['工具', '平台', '服务商', ''],
suffix: ['哪家好', '怎么选', '推荐', ''],
region: [''],
prefix: [''],
core: [''],
industry: [''],
suffix: [''],
})
const aiForm = reactive({
@@ -112,6 +122,20 @@ const remainingQuestions = computed(() => {
}
return Math.max(maxQuestions.value - usedQuestions.value, 0)
})
const selectedQuestionCount = computed(() => selectedCandidates.value.length)
const displayUsedQuestions = computed(() => usedQuestions.value + selectedQuestionCount.value)
const displayRemainingQuestions = computed(() => {
if (!maxQuestions.value) {
return 0
}
return Math.max(maxQuestions.value - displayUsedQuestions.value, 0)
})
const canSelectMoreCandidates = computed(() => {
if (!maxQuestions.value) {
return true
}
return displayUsedQuestions.value < maxQuestions.value
})
const questionLimitReached = computed(() => {
if (!maxQuestions.value) {
@@ -164,6 +188,24 @@ const createModeSource = computed<QuestionSource>(() => {
return 'combination'
})
const aiCandidateStages = computed(() => [
aiWaitingKind.value === 'fill'
? t('brands.questions.aiFill.waitingStages.region')
: t('brands.questions.ai.waitingStages.context'),
aiWaitingKind.value === 'fill'
? t('brands.questions.aiFill.waitingStages.generate')
: t('brands.questions.ai.waitingStages.generate'),
aiWaitingKind.value === 'fill'
? t('brands.questions.aiFill.waitingStages.refine')
: t('brands.questions.ai.waitingStages.refine'),
])
const aiWaitingTitle = computed(() =>
aiWaitingKind.value === 'fill'
? t('brands.questions.aiFill.waitingTitle')
: t('brands.questions.ai.waitingTitle'),
)
const isPageLoading = computed(
() =>
brandQuery.isPending.value ||
@@ -172,7 +214,10 @@ const isPageLoading = computed(
)
const isGenerating = computed(
() => previewMutations.combination.isPending.value || previewMutations.ai.isPending.value,
() =>
previewMutations.combination.isPending.value ||
previewMutations.ai.isPending.value ||
combinationFillMutation.isPending.value,
)
const previewMutations = {
@@ -219,6 +264,33 @@ const previewMutations = {
}),
}
const combinationFillMutation = useMutation({
mutationFn: () =>
brandsApi.fillQuestionCombination(brandId.value, {
region: splitLines(expansionForm.region),
prefix: splitLines(expansionForm.prefix),
core: splitLines(expansionForm.core),
industry: splitLines(expansionForm.industry),
suffix: splitLines(expansionForm.suffix),
}),
onSuccess: (result) => {
fillExpansionColumn('region', result.region)
fillExpansionColumn('prefix', result.prefix)
fillExpansionColumn('core', result.core)
fillExpansionColumn('industry', result.industry)
fillExpansionColumn('suffix', result.suffix)
pageNotice.value = ''
message.success(
result.ai_points_charged
? t('brands.questions.aiFill.charged', { count: result.ai_points_charged })
: t('brands.questions.aiFill.success'),
)
},
onError: (error) => {
pageNotice.value = formatError(error)
},
})
const materializeMutation = useMutation({
mutationFn: () =>
brandsApi.materializeQuestions(brandId.value, {
@@ -324,6 +396,11 @@ function removeItem(column: ColumnKey, index: number) {
}
}
function fillExpansionColumn(column: ColumnKey, values: string[]): void {
const cleaned = splitLines(values).slice(0, 4)
expansionForm[column].splice(0, expansionForm[column].length, ...cleaned, '')
}
function combinationPartCount(value: string | string[]): number {
const count = splitLines(value).length
return count > 0 ? count : 1
@@ -345,7 +422,7 @@ function toCandidateRows(candidates: QuestionCandidate[]): CandidateRow[] {
return candidates.map((candidate, index) => ({
...candidate,
id: `${Date.now()}-${index}-${candidate.text}`,
selected: !candidate.suggest_skip,
selected: false,
}))
}
@@ -372,7 +449,7 @@ function buildLocalCandidates(texts: string[], source: QuestionSource): Candidat
too_short: tooShort,
duplicate,
suggest_skip: tooShort || duplicate || invalid,
selected: !(tooShort || duplicate || invalid),
selected: false,
})
}
return rows
@@ -388,6 +465,62 @@ function changeCreateMode(mode: CreateMode): void {
pageNotice.value = ''
}
function clearAiCandidateProgressTimer(): void {
if (aiCandidateProgressTimer === null) {
return
}
window.clearInterval(aiCandidateProgressTimer)
aiCandidateProgressTimer = null
}
function clearAiCandidateCloseTimer(): void {
if (aiCandidateCloseTimer === null) {
return
}
window.clearTimeout(aiCandidateCloseTimer)
aiCandidateCloseTimer = null
}
function beginAiCandidateTask(kind: 'candidate' | 'fill' = 'candidate'): void {
clearAiCandidateProgressTimer()
clearAiCandidateCloseTimer()
aiWaitingKind.value = kind
aiCandidateModalOpen.value = true
aiCandidateProgress.value = 12
aiCandidateStageIndex.value = 0
aiCandidateProgressTimer = window.setInterval(() => {
const nextProgress = Math.min(92, aiCandidateProgress.value + 8)
aiCandidateProgress.value = nextProgress
aiCandidateStageIndex.value = nextProgress >= 66 ? 2 : nextProgress >= 38 ? 1 : 0
}, 700)
}
async function fillCombinationWords(): Promise<void> {
pageNotice.value = ''
beginAiCandidateTask('fill')
try {
await combinationFillMutation.mutateAsync()
} finally {
finishAiCandidateTask()
}
}
function finishAiCandidateTask(): void {
clearAiCandidateProgressTimer()
aiCandidateProgress.value = 100
aiCandidateStageIndex.value = aiCandidateStages.value.length - 1
aiCandidateCloseTimer = window.setTimeout(() => {
aiCandidateModalOpen.value = false
aiCandidateCloseTimer = null
}, 180)
}
onBeforeUnmount(() => {
clearAiCandidateProgressTimer()
clearAiCandidateCloseTimer()
})
async function generateCandidates(): Promise<void> {
pageNotice.value = ''
if (questionLimitReached.value) {
@@ -423,7 +556,12 @@ async function generateCandidates(): Promise<void> {
pageNotice.value = t('brands.questions.errors.emptyInput')
return
}
await previewMutations.ai.mutateAsync()
beginAiCandidateTask('candidate')
try {
await previewMutations.ai.mutateAsync()
} finally {
finishAiCandidateTask()
}
}
function removeCandidate(id: string): void {
@@ -431,7 +569,6 @@ function removeCandidate(id: string): void {
}
function refreshCandidate(row: CandidateRow): void {
const wasSkipped = row.suggest_skip
row.text = row.text.trim()
const key = normalizeQuestionKey(row.text)
const duplicateInBatch = candidateRows.value.some(
@@ -440,11 +577,6 @@ function refreshCandidate(row: CandidateRow): void {
row.too_short = row.text.length < 4
row.duplicate = Boolean(key) && (existingQuestionSet.value.has(key) || duplicateInBatch)
row.suggest_skip = row.too_short || row.duplicate || !questionLooksLikeQuestion(row.text)
if (row.suggest_skip) {
row.selected = false
} else if (wasSkipped) {
row.selected = true
}
}
function refreshAllCandidates(): void {
@@ -462,13 +594,20 @@ function stopEditingCandidate(row: CandidateRow): void {
function updateCandidateSelection(row: CandidateRow, checked: boolean): void {
refreshCandidate(row)
if (row.suggest_skip && checked) {
pageNotice.value = t('brands.questions.errors.invalidCandidate')
if (checked && !row.selected && !canSelectMoreCandidates.value) {
pageNotice.value = t('brands.questions.messages.selectionLimitReached', {
limit: maxQuestions.value,
})
return
}
pageNotice.value = ''
row.selected = checked
}
function isCandidateSelectionDisabled(row: CandidateRow): boolean {
return !row.selected && !canSelectMoreCandidates.value
}
async function saveCandidates(): Promise<void> {
refreshAllCandidates()
if (!selectedCandidates.value.length) {
@@ -570,8 +709,19 @@ function backToBrands(): void {
<h3>{{ t('brands.questions.modal.methods.combination') }}</h3>
<p>{{ t('brands.questions.page.expansionHint') }}</p>
</div>
<div class="tool-estimate">
{{ t('brands.questions.combination.estimate', { count: expansionToolPreviewCount }) }}
<div class="tool-header-actions">
<a-button
class="tool-ai-fill"
:loading="combinationFillMutation.isPending.value"
:disabled="previewMutations.combination.isPending.value"
@click="fillCombinationWords"
>
<template #icon><RobotOutlined /></template>
{{ t('brands.questions.aiFill.button') }}
</a-button>
<div class="tool-estimate">
{{ t('brands.questions.combination.estimate', { count: expansionToolPreviewCount }) }}
</div>
</div>
</div>
@@ -740,10 +890,10 @@ function backToBrands(): void {
v-for="row in candidateRows"
:key="row.id"
class="candidate-row"
:class="{ 'candidate-row--muted': row.suggest_skip }"
>
<a-checkbox
:checked="row.selected"
:disabled="isCandidateSelectionDisabled(row)"
@change="(event: Event) => updateCandidateSelection(row, (event.target as HTMLInputElement).checked)"
/>
<a-input
@@ -784,11 +934,11 @@ function backToBrands(): void {
<div class="footer-meta">
{{
t('brands.questions.usage', {
used: usedQuestions,
used: displayUsedQuestions,
total: maxQuestions || '--',
})
}}
<span>{{ t('brands.questions.remaining', { count: remainingQuestions }) }}</span>
<span>{{ t('brands.questions.remaining', { count: displayRemainingQuestions }) }}</span>
</div>
<div class="footer-actions">
<a-button @click="backToBrands">{{ t('common.cancel') }}</a-button>
@@ -817,6 +967,13 @@ function backToBrands(): void {
</div>
</footer>
</template>
<AiWaitingModal
:open="aiCandidateModalOpen"
:title="aiWaitingTitle"
:progress="aiCandidateProgress"
:stage="aiCandidateStages[aiCandidateStageIndex]"
/>
</div>
</template>
@@ -1010,6 +1167,45 @@ function backToBrands(): void {
content: '';
}
.tool-header-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
.tool-ai-fill {
background: linear-gradient(135deg, #1677ff, #722ed1) !important;
border: none !important;
color: #ffffff !important;
font-weight: 600;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(114, 46, 209, 0.25);
transition: all 0.3s ease;
padding: 0 16px;
}
.tool-ai-fill:hover {
background: linear-gradient(135deg, #2563eb, #8b5cf6) !important;
box-shadow: 0 6px 16px rgba(114, 46, 209, 0.4);
transform: translateY(-1px);
}
.tool-ai-fill[disabled],
.tool-ai-fill.ant-btn-loading {
background: #f1f5f9 !important;
border: 1px solid #e2e8f0 !important;
color: #94a3b8 !important;
box-shadow: none !important;
transform: none !important;
}
.tool-ai-fill :deep(.anticon) {
font-size: 16px;
}
.tool-estimate {
min-width: 168px;
padding: 8px 12px;
@@ -1199,19 +1395,24 @@ function backToBrands(): void {
}
.candidate-list {
display: flex;
flex-direction: column;
background: #ffffff;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 12px;
align-items: stretch;
padding: 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
}
.candidate-empty {
grid-column: 1 / -1;
padding: 32px;
color: #94a3b8;
text-align: center;
background: #f8fafc;
background: #ffffff;
border: 1px dashed #d8e0ec;
border-radius: 8px;
}
.candidate-row {
@@ -1219,18 +1420,21 @@ function backToBrands(): void {
grid-template-columns: 24px minmax(0, 1fr) auto auto;
gap: 12px;
align-items: center;
min-height: 58px;
padding: 12px 16px;
background: transparent;
border-bottom: 1px solid #f1f5f9;
transition: background-color 0.2s ease;
}
.candidate-row:last-child {
border-bottom: none;
background: #ffffff;
border: 1px solid #e6edf5;
border-radius: 8px;
transition:
background-color 0.2s ease,
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.candidate-row:hover {
background-color: #f8fafc;
background-color: #fbfdff;
border-color: #b7d6ff;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.04);
}
.candidate-row__text {
@@ -1254,15 +1458,6 @@ function backToBrands(): void {
opacity: 1;
}
.candidate-row--muted {
background: #f8fafc;
color: #94a3b8;
}
.candidate-row--muted .candidate-row__text {
color: #94a3b8;
}
.wizard-page__footer {
position: sticky;
bottom: 0;
@@ -1322,7 +1517,7 @@ function backToBrands(): void {
}
.candidate-row {
grid-template-columns: 24px minmax(0, 1fr) auto;
grid-template-columns: 24px minmax(0, 1fr) auto auto;
}
.wizard-page__footer {