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
+72 -44
View File
@@ -96,9 +96,10 @@ const trackingPlatformOptions = [
value: platform.id,
})),
] as const
const allQuestionOptionValue = 'all'
const selectedBrandId = useStorage<number | null>('tracking_selected_brand', null)
const selectedKeywordId = useStorage<number | null>('tracking_selected_keyword', null)
const selectedQuestionId = useStorage<number | null>('tracking_selected_question', null)
const selectedPlatformId = useStorage<string>('tracking_selected_ai_platform_id', 'all')
const selectedBusinessDate = useStorage<string>('tracking_selected_business_date', trackingToday)
const selectedCitationWindowDays = useStorage<number>('tracking_selected_citation_window_days', 7)
@@ -108,6 +109,14 @@ selectedBusinessDate.value =
normalizeTrackingBusinessDate(selectedBusinessDate.value) ?? trackingToday
selectedCitationWindowDays.value = normalizeCitationWindowDays(selectedCitationWindowDays.value)
const selectedQuestionSetOptionValue = computed<string | number>({
get: () => selectedQuestionId.value ?? allQuestionOptionValue,
set: (value) => {
selectedQuestionId.value =
typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null
},
})
const brandsQuery = useQuery({
queryKey: ['tracking', 'brands'],
queryFn: () => brandsApi.list(),
@@ -135,37 +144,48 @@ watch(
{ immediate: true },
)
const keywordsQuery = useQuery({
queryKey: computed(() => ['tracking', 'keywords', selectedBrandId.value]),
const questionsQuery = useQuery({
queryKey: computed(() => ['tracking', 'questions', selectedBrandId.value]),
enabled: computed(() => Boolean(selectedBrandId.value)),
queryFn: () => brandsApi.listKeywords(selectedBrandId.value as number),
queryFn: () => brandsApi.listQuestions(selectedBrandId.value as number),
})
const questionSetOptions = computed(() => [
{ label: t('tracking.allQuestionSets'), value: allQuestionOptionValue },
...(questionsQuery.data.value ?? []).map((item) => ({
label: item.question_text,
value: item.id,
})),
])
watch(
() => keywordsQuery.data.value,
(keywords) => {
if (!keywords?.length) {
() => questionsQuery.data.value,
(questions) => {
const requestedQuestionId = parseNumericQuery(route.query.question_id)
if (!questions?.length) {
if (!requestedQuestionId) {
selectedQuestionId.value = null
}
return
}
const requestedKeywordId = parseNumericQuery(route.query.keyword_id)
if (requestedKeywordId && keywords.some((item) => item.id === requestedKeywordId)) {
selectedKeywordId.value = requestedKeywordId
if (requestedQuestionId && questions.some((item) => item.id === requestedQuestionId)) {
selectedQuestionId.value = requestedQuestionId
return
}
if (selectedKeywordId.value && keywords.some((item) => item.id === selectedKeywordId.value)) {
if (selectedQuestionId.value && questions.some((item) => item.id === selectedQuestionId.value)) {
return
}
selectedKeywordId.value = keywords[0].id
selectedQuestionId.value = null
},
{ immediate: true },
)
watch(selectedBrandId, (newId, oldId) => {
if (oldId && newId !== oldId) {
selectedKeywordId.value = null
selectedQuestionId.value = null
}
})
@@ -189,14 +209,25 @@ watch(
)
watch(
[selectedBrandId, selectedKeywordId, selectedPlatformId, selectedBusinessDate],
([brandId, keywordId, platformId, businessDate]) => {
[
selectedBrandId,
selectedQuestionId,
selectedPlatformId,
selectedBusinessDate,
() => questionsQuery.data.value,
],
([brandId, questionId, platformId, businessDate, questions]) => {
const requestedQuestionId = parseNumericQuery(route.query.question_id)
if (requestedQuestionId && !questionId && !Array.isArray(questions)) {
return
}
const nextBrandId = brandId ? String(brandId) : undefined
const nextKeywordId = keywordId ? String(keywordId) : undefined
const nextQuestionId = questionId ? String(questionId) : undefined
const nextPlatformId = platformId !== 'all' ? platformId : undefined
const nextBusinessDate = normalizeTrackingBusinessDate(businessDate) ?? trackingToday
const currentBrandId = normalizeQueryValue(route.query.brand_id) || undefined
const currentKeywordId = normalizeQueryValue(route.query.keyword_id) || undefined
const currentQuestionId = normalizeQueryValue(route.query.question_id) || undefined
const currentPlatformId = normalizeMonitoringPlatformFilter(route.query.ai_platform_id)
const currentBusinessDate =
normalizeTrackingBusinessDate(route.query.business_date) ?? trackingToday
@@ -204,7 +235,7 @@ watch(
if (
currentBrandId === nextBrandId &&
currentKeywordId === nextKeywordId &&
currentQuestionId === nextQuestionId &&
currentPlatformId === (nextPlatformId ?? 'all') &&
currentBusinessDate === nextBusinessDate &&
!hasLegacyCitationDaysQuery
@@ -216,7 +247,7 @@ watch(
name: 'tracking',
query: {
...(nextBrandId ? { brand_id: nextBrandId } : {}),
...(nextKeywordId ? { keyword_id: nextKeywordId } : {}),
...(nextQuestionId ? { question_id: nextQuestionId } : {}),
...(nextPlatformId ? { ai_platform_id: nextPlatformId } : {}),
business_date: nextBusinessDate,
},
@@ -229,7 +260,7 @@ const dashboardQuery = useQuery({
'tracking',
'dashboard',
selectedBrandId.value,
selectedKeywordId.value,
selectedQuestionId.value,
selectedPlatformId.value,
selectedBusinessDate.value,
]),
@@ -237,7 +268,7 @@ const dashboardQuery = useQuery({
queryFn: () =>
monitoringApi.dashboardComposite({
brand_id: selectedBrandId.value ?? undefined,
keyword_id: selectedKeywordId.value,
question_id: selectedQuestionId.value,
days: trackingMaxHistoryDays,
ai_platform_id: selectedPlatformId.value !== 'all' ? selectedPlatformId.value : undefined,
business_date: selectedBusinessDate.value,
@@ -249,7 +280,7 @@ const citationSummaryQuery = useQuery({
'tracking',
'citation-summary',
selectedBrandId.value,
selectedKeywordId.value,
selectedQuestionId.value,
selectedPlatformId.value,
selectedBusinessDate.value,
selectedCitationWindowDays.value,
@@ -258,7 +289,7 @@ const citationSummaryQuery = useQuery({
queryFn: () =>
monitoringApi.citationSummary({
brand_id: selectedBrandId.value ?? undefined,
keyword_id: selectedKeywordId.value,
question_id: selectedQuestionId.value,
business_date: selectedBusinessDate.value,
ai_platform_id: selectedPlatformId.value !== 'all' ? selectedPlatformId.value : undefined,
days: selectedCitationWindowDays.value,
@@ -275,12 +306,9 @@ const collectNowMutation = useMutation({
if (!selectedBrandId.value) {
throw new Error('tracking_brand_required')
}
if (!selectedKeywordId.value) {
throw new Error('tracking_keyword_required')
}
return monitoringApi.collectNow(selectedBrandId.value, {
keyword_id: selectedKeywordId.value,
question_id: selectedQuestionId.value ?? undefined,
platform_ids: selectedPlatformId.value !== 'all' ? [selectedPlatformId.value] : undefined,
})
},
@@ -293,10 +321,6 @@ const collectNowMutation = useMutation({
message.warning(t('tracking.collectBrandRequired'))
return
}
if (error instanceof Error && error.message === 'tracking_keyword_required') {
message.warning(t('tracking.collectKeywordRequired'))
return
}
message.error(formatError(error))
},
})
@@ -343,9 +367,6 @@ const collectNowDisabledReason = computed(() => {
if (!selectedBrandId.value) {
return t('tracking.collectBrandRequired')
}
if (!selectedKeywordId.value) {
return t('tracking.collectKeywordRequired')
}
if (selectedBusinessDate.value !== trackingToday) {
return t('tracking.collectTodayOnly')
}
@@ -649,11 +670,20 @@ function openQuestion(question: MonitoringHotQuestion): void {
date_from: selectedBusinessDate.value,
date_to: selectedBusinessDate.value,
...(selectedPlatformId.value !== 'all' ? { ai_platform_id: selectedPlatformId.value } : {}),
...(selectedKeywordId.value ? { keyword_id: String(selectedKeywordId.value) } : {}),
},
})
}
function filterQuestionOption(input: string, option?: { label?: string }): boolean {
const keyword = input.trim().toLowerCase()
if (!keyword) {
return true
}
return String(option?.label ?? '')
.toLowerCase()
.includes(keyword)
}
function formatPercent(value: number | null | undefined): string {
if (value === null || value === undefined) {
return '--'
@@ -832,18 +862,16 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
/>
</div>
<div class="tracking-action-item">
<span class="tracking-action-label">关键词</span>
<span class="tracking-action-label">问题集</span>
<a-select
v-model:value="selectedKeywordId"
allow-clear
v-model:value="selectedQuestionSetOptionValue"
show-search
class="tracking-select"
option-filter-prop="label"
:filter-option="filterQuestionOption"
:not-found-content="t('tracking.noQuestions')"
:placeholder="t('tracking.keywordPlaceholder')"
:options="
(keywordsQuery.data.value ?? []).map((item) => ({
label: item.name,
value: item.id,
}))
"
:options="questionSetOptions"
/>
</div>
<div class="tracking-action-item">