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
+78 -12
View File
@@ -53,7 +53,7 @@ const columns = computed<TableColumnsType<AIPointUsageLog>>(() => [
title: t('aiPoints.table.points'),
dataIndex: 'points',
key: 'points',
width: 110,
width: 188,
align: 'right',
},
{
@@ -89,7 +89,7 @@ function handleTableChange(nextPage: number, nextPageSize: number): void {
function getAIPointUsageTypeLabel(usageType: string): string {
const key = `shell.aiUsageTypes.${usageType}`
const label = t(key)
return label === key ? usageType : label
return label === key ? t('aiPoints.usageTypeFallback') : label
}
function getAIPointStatusMeta(status: string): { label: string; color: string } {
@@ -108,8 +108,48 @@ function getAIPointStatusMeta(status: string): { label: string; color: string }
}
}
function formatAIPointDelta(item: AIPointUsageLog): string {
return item.status === 'refunded' ? `+${item.points}` : `-${item.points}`
function getAIPointStatusHelp(status: string): string | null {
switch (status) {
case 'pending':
return t('shell.aiUsagePendingHelp')
case 'refunded':
return t('aiPoints.refundedHelp')
default:
return null
}
}
function getAIPointAmountMeta(item: AIPointUsageLog): {
primary: string
detail: string
tone: 'charge' | 'refund' | 'neutral'
} {
switch (item.status) {
case 'refunded':
return {
primary: t('aiPoints.amount.netZero'),
detail: t('aiPoints.amount.refundedFlow', { points: item.points }),
tone: 'refund',
}
case 'pending':
return {
primary: `-${item.points}`,
detail: t('aiPoints.amount.pendingFlow', { points: item.points }),
tone: 'charge',
}
case 'completed':
return {
primary: `-${item.points}`,
detail: t('aiPoints.amount.completedFlow', { points: item.points }),
tone: 'charge',
}
default:
return {
primary: t('aiPoints.amount.zero'),
detail: t('aiPoints.amount.failedFlow'),
tone: 'neutral',
}
}
}
</script>
@@ -208,12 +248,13 @@ function formatAIPointDelta(item: AIPointUsageLog): string {
</template>
<template v-else-if="column.key === 'points'">
<span
class="points-delta"
:class="{ 'points-delta--refund': record.status === 'refunded' }"
<div
class="points-cell"
:class="`points-cell--${getAIPointAmountMeta(record).tone}`"
>
{{ formatAIPointDelta(record) }}
</span>
<strong>{{ getAIPointAmountMeta(record).primary }}</strong>
<span>{{ getAIPointAmountMeta(record).detail }}</span>
</div>
</template>
<template v-else-if="column.key === 'request_chars'">
@@ -229,7 +270,10 @@ function formatAIPointDelta(item: AIPointUsageLog): string {
:bordered="false"
>
{{ getAIPointStatusMeta(record.status).label }}
<a-tooltip v-if="record.status === 'pending'" :title="t('shell.aiUsagePendingHelp')">
<a-tooltip
v-if="getAIPointStatusHelp(record.status)"
:title="getAIPointStatusHelp(record.status)"
>
<QuestionCircleOutlined class="status-help-icon" />
</a-tooltip>
</a-tag>
@@ -397,15 +441,37 @@ function formatAIPointDelta(item: AIPointUsageLog): string {
color: #cf1322 !important;
}
.points-delta {
.points-cell {
display: grid;
gap: 2px;
justify-items: end;
min-width: 132px;
line-height: 1.35;
}
.points-cell strong {
color: #cf1322;
font-weight: 600;
}
.points-delta--refund {
.points-cell span {
color: #8c8c8c;
font-size: 12px;
white-space: nowrap;
}
.points-cell--refund strong {
color: #595959;
}
.points-cell--refund span {
color: #389e0d;
}
.points-cell--neutral strong {
color: #595959;
}
.status-tag {
display: inline-flex;
align-items: center;
@@ -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 {
+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>
+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">