feat(questions): make brand questions the primary monitoring & template axis
- 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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user