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:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user