feat: paginate and search brand questions across selects
Frontend CI / Frontend (push) Successful in 3m19s
Backend CI / Backend (push) Successful in 14m30s

Make GET /api/tenant/brands/:id/questions return a paginated
QuestionListResponse (items/total/page/page_size) with optional `q`
full-text filter, validated page/page_size query params, and per-params
cache keys. Add a usePaginatedBrandQuestions composable backing the
imitation, template-wizard, and tracking question selects with
search-as-you-type and infinite scroll, plus ensure-loaded-by-id so a
deep-linked or pre-selected question is fetched even when off the first
page. Brands view now drives its questions table via server pagination.

Also redesign the Tracking hot-questions and cited-articles lists
(mention-rate badges/bars, metric groups, refreshed styling) and fall
back to citation-fact article_id/title when no high-confidence URL alias
matches, so cited articles surface even without an alias row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 22:01:23 +08:00
parent 184ebfc4c2
commit 9ed857e159
13 changed files with 1030 additions and 189 deletions
+7 -3
View File
@@ -124,6 +124,7 @@ import type {
QuestionCombinationFillResult, QuestionCombinationFillResult,
QuestionCombinationRequest, QuestionCombinationRequest,
QuestionDistillRequest, QuestionDistillRequest,
QuestionListResponse,
QuestionRequest, QuestionRequest,
QuotaSummary, QuotaSummary,
RecentArticle, RecentArticle,
@@ -1139,9 +1140,12 @@ export const brandsApi = {
removeKeyword(brandId: number, keywordId: number) { removeKeyword(brandId: number, keywordId: number) {
return apiClient.remove<null>(`/api/tenant/brands/${brandId}/keywords/${keywordId}`) return apiClient.remove<null>(`/api/tenant/brands/${brandId}/keywords/${keywordId}`)
}, },
listQuestions(brandId: number, keywordId?: number | null) { listQuestions(
return apiClient.get<Question[]>(`/api/tenant/brands/${brandId}/questions`, { brandId: number,
params: keywordId ? { keyword_id: keywordId } : undefined, params?: { keyword_id?: number | null; page?: number; page_size?: number; q?: string },
) {
return apiClient.get<QuestionListResponse>(`/api/tenant/brands/${brandId}/questions`, {
params,
}) })
}, },
previewQuestionCombination(brandId: number, payload: QuestionCombinationRequest) { previewQuestionCombination(brandId: number, payload: QuestionCombinationRequest) {
@@ -0,0 +1,214 @@
import type { Question } from '@geo/shared-types'
import { computed, ref, watch, type Ref } from 'vue'
import { brandsApi } from './api'
const defaultQuestionPageSize = 20
const questionListAllOptionValue = 'all'
export type QuestionSelectOption = {
label: string
value: number | string
}
export function usePaginatedBrandQuestions(
brandId: Ref<number | null | undefined>,
options: {
pageSize?: number
includeAllOption?: boolean
allOptionLabel?: () => string
} = {},
) {
const pageSize = options.pageSize ?? defaultQuestionPageSize
const searchText = ref('')
const items = ref<Question[]>([])
const selectedItems = ref<Question[]>([])
const page = ref(1)
const total = ref(0)
const loading = ref(false)
const initialized = ref(false)
const requestSeq = ref(0)
let pendingLoad: Promise<void> | null = null
const hasMore = computed(() => items.value.length < total.value)
const questionOptions = computed<QuestionSelectOption[]>(() => {
const map = new Map<number | string, QuestionSelectOption>()
if (options.includeAllOption) {
map.set(questionListAllOptionValue, {
label: options.allOptionLabel?.() ?? 'All',
value: questionListAllOptionValue,
})
}
for (const item of [...selectedItems.value, ...items.value]) {
map.set(item.id, {
label: item.question_text,
value: item.id,
})
}
return Array.from(map.values())
})
function loadPage(nextPage: number, mode: 'replace' | 'append'): Promise<void> {
const currentBrandId = brandId.value
if (!currentBrandId) {
items.value = []
total.value = 0
page.value = 1
initialized.value = true
pendingLoad = null
return Promise.resolve()
}
const seq = ++requestSeq.value
loading.value = true
const loadPromise = (async () => {
const result = await brandsApi.listQuestions(currentBrandId, {
page: nextPage,
page_size: pageSize,
q: searchText.value.trim() || undefined,
})
if (seq !== requestSeq.value) {
return
}
page.value = result.page
total.value = result.total
items.value = mode === 'append' ? mergeQuestions(items.value, result.items) : result.items
initialized.value = true
})()
.finally(() => {
if (seq === requestSeq.value) {
loading.value = false
}
if (pendingLoad === loadPromise) {
pendingLoad = null
}
})
pendingLoad = loadPromise
return loadPromise
}
function reset(): void {
void loadPage(1, 'replace')
}
function search(value: string): void {
searchText.value = String(value ?? '').trim()
reset()
}
function loadNextPage(): Promise<void> {
if (loading.value || !hasMore.value) {
return Promise.resolve()
}
return loadPage(page.value + 1, 'append')
}
function handlePopupScroll(event: Event): void {
const target = event.target as HTMLElement | null
if (!target) {
return
}
if (target.scrollTop + target.clientHeight >= target.scrollHeight - 24) {
loadNextPage()
}
}
async function ensureQuestionLoadedById(id: number | null | undefined): Promise<Question | null> {
if (!id) {
return null
}
let existing = findQuestionById(id)
if (existing) {
rememberSelectedByIds([id])
return existing
}
const currentBrandId = brandId.value
if (!currentBrandId) {
return null
}
if (pendingLoad) {
await pendingLoad
existing = findQuestionById(id)
if (existing) {
rememberSelectedByIds([id])
return existing
}
}
if (!initialized.value) {
await loadPage(1, 'replace')
}
existing = findQuestionById(id)
while (!existing && hasMore.value && brandId.value === currentBrandId) {
await loadNextPage()
existing = findQuestionById(id)
}
if (existing) {
rememberSelectedByIds([id])
}
return existing ?? null
}
function rememberSelectedByIds(ids: Array<number | null | undefined>): void {
const wanted = new Set(ids.filter((id): id is number => Number.isFinite(id ?? NaN)))
if (!wanted.size) {
return
}
selectedItems.value = mergeQuestions(
selectedItems.value,
items.value.filter((item) => wanted.has(item.id)),
)
}
function findQuestionById(id: number | null | undefined): Question | null {
if (!id) {
return null
}
return (
items.value.find((item) => item.id === id) ??
selectedItems.value.find((item) => item.id === id) ??
null
)
}
watch(
() => brandId.value,
() => {
searchText.value = ''
selectedItems.value = []
reset()
},
{ immediate: true },
)
return {
hasMore,
initialized,
items,
loading,
options: questionOptions,
search,
searchText,
handlePopupScroll,
ensureQuestionLoadedById,
findQuestionById,
rememberSelectedByIds,
refetch: reset,
}
}
function mergeQuestions(left: Question[], right: Question[]): Question[] {
const map = new Map<number, Question>()
for (const item of left) {
map.set(item.id, item)
}
for (const item of right) {
map.set(item.id, item)
}
return Array.from(map.values())
}
@@ -8,7 +8,12 @@ import {
ThunderboltOutlined, ThunderboltOutlined,
} from '@ant-design/icons-vue' } from '@ant-design/icons-vue'
import { ApiClientError } from '@geo/http-client' import { ApiClientError } from '@geo/http-client'
import type { BrandLibrarySummary, QuestionCandidate, QuestionSource } from '@geo/shared-types' import type {
BrandLibrarySummary,
Question,
QuestionCandidate,
QuestionSource,
} from '@geo/shared-types'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { computed, onBeforeUnmount, reactive, ref } from 'vue' import { computed, onBeforeUnmount, reactive, ref } from 'vue'
@@ -102,7 +107,7 @@ const brandLibrarySummaryQuery = useQuery({
const questionsQuery = useQuery({ const questionsQuery = useQuery({
queryKey: computed(() => ['brands', brandId.value, 'questions', 'all']), queryKey: computed(() => ['brands', brandId.value, 'questions', 'all']),
enabled: computed(() => brandId.value > 0), enabled: computed(() => brandId.value > 0),
queryFn: () => brandsApi.listQuestions(brandId.value), queryFn: () => listAllBrandQuestions(brandId.value),
}) })
const brandLibrarySummary = computed<BrandLibrarySummary | null>( const brandLibrarySummary = computed<BrandLibrarySummary | null>(
@@ -444,6 +449,29 @@ function mergeQuestionCandidateResults(
} }
} }
async function listAllBrandQuestions(currentBrandId: number): Promise<Question[]> {
const pageSize = 100
const items: Question[] = []
let page = 1
let total = 0
do {
const result = await brandsApi.listQuestions(currentBrandId, {
page,
page_size: pageSize,
})
items.push(...result.items)
total = result.total
if (items.length >= total || result.items.length === 0) {
break
}
page += 1
} while (true)
return items
}
type ColumnKey = 'region' | 'prefix' | 'core' | 'industry' | 'suffix' type ColumnKey = 'region' | 'prefix' | 'core' | 'industry' | 'suffix'
function handleInput(column: ColumnKey, index: number) { function handleInput(column: ColumnKey, index: number) {
+38 -5
View File
@@ -63,6 +63,9 @@ const competitorForm = reactive({
product_lines: '', product_lines: '',
}) })
const questionPage = ref(1)
const questionPageSize = ref(10)
const brandListQuery = useQuery({ const brandListQuery = useQuery({
queryKey: ['brands', 'list'], queryKey: ['brands', 'list'],
queryFn: () => brandsApi.list(), queryFn: () => brandsApi.list(),
@@ -74,9 +77,19 @@ const brandLibrarySummaryQuery = useQuery({
}) })
const questionsQuery = useQuery({ const questionsQuery = useQuery({
queryKey: computed(() => ['brands', selectedBrandId.value, 'questions', 'all']), queryKey: computed(() => [
'brands',
selectedBrandId.value,
'questions',
questionPage.value,
questionPageSize.value,
]),
enabled: computed(() => Boolean(selectedBrandId.value)), enabled: computed(() => Boolean(selectedBrandId.value)),
queryFn: () => brandsApi.listQuestions(selectedBrandId.value as number), queryFn: () =>
brandsApi.listQuestions(selectedBrandId.value as number, {
page: questionPage.value,
page_size: questionPageSize.value,
}),
}) })
const competitorsQuery = useQuery({ const competitorsQuery = useQuery({
@@ -93,7 +106,8 @@ const selectedBrand = computed(
() => brandListQuery.data.value?.find((item) => item.id === selectedBrandId.value) ?? null, () => brandListQuery.data.value?.find((item) => item.id === selectedBrandId.value) ?? null,
) )
const currentQuestions = computed(() => questionsQuery.data.value ?? []) const currentQuestions = computed(() => questionsQuery.data.value?.items ?? [])
const questionTotal = computed(() => questionsQuery.data.value?.total ?? 0)
const maxQuestions = computed( const maxQuestions = computed(
() => () =>
@@ -102,7 +116,7 @@ const maxQuestions = computed(
0, 0,
) )
const usedQuestions = computed( const usedQuestions = computed(
() => brandLibrarySummary.value?.used_questions ?? currentQuestions.value.length, () => brandLibrarySummary.value?.used_questions ?? questionTotal.value,
) )
const brandLimitReached = computed(() => { const brandLimitReached = computed(() => {
@@ -308,6 +322,13 @@ watch(
{ immediate: true }, { immediate: true },
) )
watch([questionTotal, questionPageSize], ([total, pageSize]) => {
const maxPage = Math.max(1, Math.ceil(total / pageSize))
if (questionPage.value > maxPage) {
questionPage.value = maxPage
}
})
function parseProductLines(value: string): string[] | undefined { function parseProductLines(value: string): string[] | undefined {
const lines = value const lines = value
.split(',') .split(',')
@@ -344,6 +365,7 @@ function selectBrand(brandId: number): void {
return return
} }
selectedBrandId.value = brandId selectedBrandId.value = brandId
questionPage.value = 1
void queryClient.invalidateQueries({ queryKey: ['brands'] }) void queryClient.invalidateQueries({ queryKey: ['brands'] })
} }
@@ -429,6 +451,11 @@ async function submitSingleQuestion(): Promise<void> {
await questionMutations.createSingle.mutateAsync() await questionMutations.createSingle.mutateAsync()
} }
function handleQuestionTableChange(nextPage: number, nextPageSize: number): void {
questionPage.value = nextPage
questionPageSize.value = nextPageSize
}
async function submitCompetitor(): Promise<void> { async function submitCompetitor(): Promise<void> {
if (!competitorForm.name.trim()) { if (!competitorForm.name.trim()) {
return return
@@ -551,7 +578,13 @@ async function invalidateBrandQueries(): Promise<void> {
:columns="questionColumns" :columns="questionColumns"
:data-source="currentQuestions" :data-source="currentQuestions"
:loading="questionsQuery.isPending.value" :loading="questionsQuery.isPending.value"
:pagination="{ pageSize: 10, showSizeChanger: false }" :pagination="{
current: questionPage,
pageSize: questionPageSize,
total: questionTotal,
showSizeChanger: false,
onChange: handleQuestionTableChange,
}"
row-key="id" row-key="id"
> >
<template #emptyText>{{ t('brands.empty.questions') }}</template> <template #emptyText>{{ t('brands.empty.questions') }}</template>
@@ -7,8 +7,9 @@ import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import KnowledgeGroupSelect from '@/components/KnowledgeGroupSelect.vue' import KnowledgeGroupSelect from '@/components/KnowledgeGroupSelect.vue'
import { articlesApi, brandsApi } from '@/lib/api' import { articlesApi } from '@/lib/api'
import { formatError } from '@/lib/errors' import { formatError } from '@/lib/errors'
import { usePaginatedBrandQuestions } from '@/lib/use-paginated-brand-questions'
import { useCompanyStore } from '@/stores/company' import { useCompanyStore } from '@/stores/company'
const route = useRoute() const route = useRoute()
@@ -34,14 +35,10 @@ const selectedBrand = computed(() => companyStore.currentBrand)
const selectedBrandId = computed(() => selectedBrand.value?.id ?? null) const selectedBrandId = computed(() => selectedBrand.value?.id ?? null)
const currentBrandName = computed(() => selectedBrand.value?.name?.trim() || '') const currentBrandName = computed(() => selectedBrand.value?.name?.trim() || '')
const questionsQuery = useQuery({ const questionList = usePaginatedBrandQuestions(selectedBrandId)
queryKey: computed(() => ['brands', selectedBrandId.value, 'questions', 'imitation']),
enabled: computed(() => Boolean(selectedBrandId.value)),
queryFn: () => brandsApi.listQuestions(selectedBrandId.value as number),
})
const keywordOptions = computed(() => const keywordOptions = computed(() =>
(questionsQuery.data.value ?? []).map((item) => ({ questionList.items.value.map((item) => ({
label: item.question_text, label: item.question_text,
value: item.question_text, value: item.question_text,
})), })),
@@ -239,9 +236,14 @@ function submit(): void {
<a-select <a-select
v-model:value="form.keywords" v-model:value="form.keywords"
mode="tags" mode="tags"
show-search
style="width: 100%" style="width: 100%"
:filter-option="false"
:loading="questionList.loading.value"
:options="keywordOptions" :options="keywordOptions"
:placeholder="t('imitation.create.keywordsPlaceholder')" :placeholder="t('imitation.create.keywordsPlaceholder')"
@search="questionList.search"
@popup-scroll="questionList.handlePopupScroll"
/> />
</div> </div>
+49 -24
View File
@@ -26,6 +26,7 @@ import KnowledgeGroupSelect from '@/components/KnowledgeGroupSelect.vue'
import { articlesApi, brandsApi, normalizeInputParams, templatesApi } from '@/lib/api' import { articlesApi, brandsApi, normalizeInputParams, templatesApi } from '@/lib/api'
import { getTemplateMeta } from '@/lib/display' import { getTemplateMeta } from '@/lib/display'
import { formatError } from '@/lib/errors' import { formatError } from '@/lib/errors'
import { usePaginatedBrandQuestions } from '@/lib/use-paginated-brand-questions'
import { useCompanyStore } from '@/stores/company' import { useCompanyStore } from '@/stores/company'
interface DraftCompetitor { interface DraftCompetitor {
@@ -159,6 +160,7 @@ let outlineNodeSeed = 0
let allowWizardLeave = false let allowWizardLeave = false
let pendingLeavePromise: Promise<boolean> | null = null let pendingLeavePromise: Promise<boolean> | null = null
let pendingLeaveResolve: ((canLeave: boolean) => void) | null = null let pendingLeaveResolve: ((canLeave: boolean) => void) | null = null
let selectedQuestionLoadSeq = 0
const CUSTOM_OUTLINE_MAX_LENGTH = 15 const CUSTOM_OUTLINE_MAX_LENGTH = 15
const currentStep = ref(0) const currentStep = ref(0)
@@ -219,11 +221,7 @@ const draftArticleQuery = useQuery({
queryFn: () => articlesApi.detail(draftArticleId.value as number), queryFn: () => articlesApi.detail(draftArticleId.value as number),
}) })
const questionsQuery = useQuery({ const questionList = usePaginatedBrandQuestions(selectedBrandId)
queryKey: computed(() => ['brands', selectedBrandId.value, 'questions', 'wizard']),
enabled: computed(() => enabled.value && Boolean(selectedBrandId.value)),
queryFn: () => brandsApi.listQuestions(selectedBrandId.value as number),
})
const competitorsQuery = useQuery({ const competitorsQuery = useQuery({
queryKey: computed(() => ['brands', selectedBrandId.value, 'competitors']), queryKey: computed(() => ['brands', selectedBrandId.value, 'competitors']),
@@ -283,18 +281,10 @@ const templateMeta = computed(() => {
}) })
function findQuestionById(id: number | null): Question | null { function findQuestionById(id: number | null): Question | null {
if (!id) { return questionList.findQuestionById(id)
return null
}
return questionsQuery.data.value?.find((item) => item.id === id) ?? null
} }
const questionOptions = computed(() => const questionOptions = computed(() => questionList.options.value)
(questionsQuery.data.value ?? []).map((item) => ({
label: item.question_text,
value: item.id,
})),
)
const supplementalQuestionOptions = computed(() => const supplementalQuestionOptions = computed(() =>
questionOptions.value.filter((item) => item.value !== primaryQuestionId.value), questionOptions.value.filter((item) => item.value !== primaryQuestionId.value),
) )
@@ -635,15 +625,46 @@ watch(primaryQuestionId, (id) => {
supplementalQuestionIds.value = supplementalQuestionIds.value.filter((item) => item !== id) supplementalQuestionIds.value = supplementalQuestionIds.value.filter((item) => item !== id)
}) })
watch(
[primaryQuestionId, supplementalQuestionIds, () => questionList.items.value],
([primaryId, supplementalIds]) => {
questionList.rememberSelectedByIds([primaryId, ...supplementalIds])
},
)
watch(
[selectedBrandId, primaryQuestionId, supplementalQuestionIds],
async ([brandId, primaryId, supplementalIds]) => {
if (!brandId) {
return
}
const ids = [primaryId, ...supplementalIds].filter(
(id): id is number => typeof id === 'number' && Number.isFinite(id) && id > 0,
)
if (!ids.length) {
return
}
const seq = ++selectedQuestionLoadSeq
for (const id of ids) {
await questionList.ensureQuestionLoadedById(id)
if (seq !== selectedQuestionLoadSeq || brandId !== selectedBrandId.value) {
return
}
}
},
{ immediate: true },
)
watch(supplementalQuestionIds, (ids) => { 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( const normalized = ids.filter(
(id, index) => (id, index) =>
id !== primaryQuestionId.value && id !== primaryQuestionId.value &&
ids.indexOf(id) === index && ids.indexOf(id) === index &&
(!shouldCheckOptions || allowedIds.has(id)), typeof id === 'number' &&
Number.isFinite(id) &&
id > 0,
) )
const limited = normalized.slice(0, 3) const limited = normalized.slice(0, 3)
if (limited.length !== ids.length || limited.some((id, index) => id !== ids[index])) { if (limited.length !== ids.length || limited.some((id, index) => id !== ids[index])) {
@@ -676,7 +697,7 @@ watch(
primaryQuestionId.value = null primaryQuestionId.value = null
supplementalQuestionIds.value = [] supplementalQuestionIds.value = []
await questionsQuery.refetch() await questionList.refetch()
if (!showCompetitorsCard.value) { if (!showCompetitorsCard.value) {
competitorDrafts.value = [] competitorDrafts.value = []
@@ -2472,7 +2493,8 @@ function onStructureDragEnd(): void {
allow-clear allow-clear
show-search show-search
:disabled="!selectedBrandId" :disabled="!selectedBrandId"
:loading="questionsQuery.isPending.value" :filter-option="false"
:loading="questionList.loading.value"
:not-found-content=" :not-found-content="
selectedBrandId selectedBrandId
? t('templates.wizard.hints.questionEmpty') ? t('templates.wizard.hints.questionEmpty')
@@ -2480,8 +2502,9 @@ function onStructureDragEnd(): void {
" "
:options="questionOptions" :options="questionOptions"
:placeholder="t('templates.wizard.placeholders.primaryQuestion')" :placeholder="t('templates.wizard.placeholders.primaryQuestion')"
option-filter-prop="label"
style="width: 100%" style="width: 100%"
@search="questionList.search"
@popup-scroll="questionList.handlePopupScroll"
/> />
</div> </div>
@@ -2495,13 +2518,15 @@ function onStructureDragEnd(): void {
mode="multiple" mode="multiple"
show-search show-search
:disabled="!selectedBrandId || !primaryQuestionId" :disabled="!selectedBrandId || !primaryQuestionId"
:loading="questionsQuery.isPending.value" :filter-option="false"
:loading="questionList.loading.value"
:max-tag-count="3" :max-tag-count="3"
:not-found-content="t('templates.wizard.hints.questionEmpty')" :not-found-content="t('templates.wizard.hints.questionEmpty')"
:options="supplementalQuestionOptions" :options="supplementalQuestionOptions"
:placeholder="t('templates.wizard.placeholders.supplementalQuestions')" :placeholder="t('templates.wizard.placeholders.supplementalQuestions')"
option-filter-prop="label"
style="width: 100%" style="width: 100%"
@search="questionList.search"
@popup-scroll="questionList.handlePopupScroll"
/> />
<p class="field-helper"> <p class="field-helper">
{{ t('templates.wizard.hints.supplementalQuestions') }} {{ t('templates.wizard.hints.supplementalQuestions') }}
+490 -102
View File
@@ -1,5 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { EyeOutlined, ReloadOutlined } from '@ant-design/icons-vue' import {
EyeOutlined,
ReloadOutlined,
QuestionCircleOutlined,
ClockCircleOutlined,
RightOutlined,
SearchOutlined,
FileTextOutlined,
} from '@ant-design/icons-vue'
import type { import type {
DesktopAccountInfo, DesktopAccountInfo,
MonitoringCitedArticle, MonitoringCitedArticle,
@@ -17,7 +25,7 @@ import { useRoute, useRouter } from 'vue-router'
import ArticleDetailDrawer from '@/components/ArticleDetailDrawer.vue' import ArticleDetailDrawer from '@/components/ArticleDetailDrawer.vue'
import PageHero from '@/components/PageHero.vue' import PageHero from '@/components/PageHero.vue'
import { brandsApi, monitoringApi, tenantAccountsApi } from '@/lib/api' import { monitoringApi, tenantAccountsApi } from '@/lib/api'
import { resolveAccountCheckedAt, resolveAccountHealth } from '@/lib/desktop-account-runtime' import { resolveAccountCheckedAt, resolveAccountHealth } from '@/lib/desktop-account-runtime'
import { formatDateTime } from '@/lib/display' import { formatDateTime } from '@/lib/display'
import { formatError } from '@/lib/errors' import { formatError } from '@/lib/errors'
@@ -26,6 +34,7 @@ import {
normalizeMonitoringPlatformFilter, normalizeMonitoringPlatformFilter,
normalizeMonitoringPlatformId, normalizeMonitoringPlatformId,
} from '@/lib/monitoring-platforms' } from '@/lib/monitoring-platforms'
import { usePaginatedBrandQuestions } from '@/lib/use-paginated-brand-questions'
import { useCompanyStore } from '@/stores/company' import { useCompanyStore } from '@/stores/company'
const { t } = useI18n() const { t } = useI18n()
@@ -113,6 +122,8 @@ const hotQuestionPage = ref(1)
const citedArticlePage = ref(1) const citedArticlePage = ref(1)
const articleDrawerOpen = ref(false) const articleDrawerOpen = ref(false)
const selectedArticleId = ref<number | null>(null) const selectedArticleId = ref<number | null>(null)
const missingRequestedQuestionId = ref<number | null>(null)
let questionSelectionSeq = 0
selectedPlatformId.value = normalizeMonitoringPlatformFilter(selectedPlatformId.value) selectedPlatformId.value = normalizeMonitoringPlatformFilter(selectedPlatformId.value)
selectedCitationWindowDays.value = normalizeCitationWindowDays(selectedCitationWindowDays.value) selectedCitationWindowDays.value = normalizeCitationWindowDays(selectedCitationWindowDays.value)
@@ -128,43 +139,62 @@ const selectedQuestionSetOptionValue = computed<string | number>({
const selectedBrandId = computed(() => companyStore.currentBrandId) const selectedBrandId = computed(() => companyStore.currentBrandId)
const selectedBrand = computed(() => companyStore.currentBrand) const selectedBrand = computed(() => companyStore.currentBrand)
const questionsQuery = useQuery({ const questionList = usePaginatedBrandQuestions(selectedBrandId, {
queryKey: computed(() => ['tracking', 'questions', selectedBrandId.value]), includeAllOption: true,
enabled: computed(() => Boolean(selectedBrandId.value)), allOptionLabel: () => t('tracking.allQuestionSets'),
queryFn: () => brandsApi.listQuestions(selectedBrandId.value as number),
}) })
const questionSetOptions = computed(() => [ const questionSetOptions = computed(() => questionList.options.value)
{ label: t('tracking.allQuestionSets'), value: allQuestionOptionValue },
...(questionsQuery.data.value ?? []).map((item) => ({
label: item.question_text,
value: item.id,
})),
])
watch( watch(
() => questionsQuery.data.value, [selectedBrandId, () => route.query.question_id, selectedQuestionId],
(questions) => { async ([brandId, requestedQuestionValue, questionId]) => {
const requestedQuestionId = parseNumericQuery(route.query.question_id) const requestedQuestionId = parseNumericQuery(requestedQuestionValue)
if (!questions?.length) { const targetQuestionId = requestedQuestionId ?? questionId
if (!requestedQuestionId) { if (!brandId || !targetQuestionId) {
missingRequestedQuestionId.value = null
return
}
if (requestedQuestionId && missingRequestedQuestionId.value === requestedQuestionId) {
return
}
const seq = ++questionSelectionSeq
if (requestedQuestionId) {
missingRequestedQuestionId.value = null
}
const question = await questionList.ensureQuestionLoadedById(targetQuestionId)
if (seq !== questionSelectionSeq || brandId !== selectedBrandId.value) {
return
}
if (question) {
selectedQuestionId.value = targetQuestionId
return
}
if (requestedQuestionId === targetQuestionId) {
missingRequestedQuestionId.value = targetQuestionId
}
if (selectedQuestionId.value === targetQuestionId) {
selectedQuestionId.value = null selectedQuestionId.value = null
} }
},
{ immediate: true },
)
watch(
() => route.query.question_id,
(value) => {
const requestedQuestionId = parseNumericQuery(route.query.question_id)
if (missingRequestedQuestionId.value && missingRequestedQuestionId.value !== requestedQuestionId) {
missingRequestedQuestionId.value = null
}
if (value || selectedBrandId.value) {
return return
} }
if (requestedQuestionId && questions.some((item) => item.id === requestedQuestionId)) {
selectedQuestionId.value = requestedQuestionId
return
}
if (
selectedQuestionId.value &&
questions.some((item) => item.id === selectedQuestionId.value)
) {
return
}
selectedQuestionId.value = null selectedQuestionId.value = null
}, },
{ immediate: true }, { immediate: true },
@@ -198,11 +228,15 @@ watch(
selectedQuestionId, selectedQuestionId,
selectedPlatformId, selectedPlatformId,
selectedBusinessDate, selectedBusinessDate,
() => questionsQuery.data.value, missingRequestedQuestionId,
], ],
([brandId, questionId, platformId, businessDate, questions]) => { ([brandId, questionId, platformId, businessDate, missingQuestionId]) => {
const requestedQuestionId = parseNumericQuery(route.query.question_id) const requestedQuestionId = parseNumericQuery(route.query.question_id)
if (requestedQuestionId && !questionId && !Array.isArray(questions)) { if (
requestedQuestionId &&
questionId !== requestedQuestionId &&
missingQuestionId !== requestedQuestionId
) {
return return
} }
@@ -688,16 +722,6 @@ function openCitedArticle(item: MonitoringCitedArticle): void {
articleDrawerOpen.value = true articleDrawerOpen.value = true
} }
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 { function formatPercent(value: number | null | undefined): string {
if (value === null || value === undefined) { if (value === null || value === undefined) {
return '--' return '--'
@@ -705,6 +729,19 @@ function formatPercent(value: number | null | undefined): string {
return `${(value * 100).toFixed(value < 0.1 ? 1 : 0)}%` return `${(value * 100).toFixed(value < 0.1 ? 1 : 0)}%`
} }
function getMentionRateClass(rate: number | null | undefined): string {
if (rate === null || rate === undefined || rate === 0) {
return 'rate-zero'
}
if (rate <= 0.3) {
return 'rate-low'
}
if (rate <= 0.7) {
return 'rate-mid'
}
return 'rate-high'
}
function normalizeCitationWindowDays(value: unknown): number { function normalizeCitationWindowDays(value: unknown): number {
const numeric = Number(value) const numeric = Number(value)
return numeric === 30 ? 30 : 7 return numeric === 30 ? 30 : 7
@@ -870,11 +907,15 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
v-model:value="selectedQuestionSetOptionValue" v-model:value="selectedQuestionSetOptionValue"
show-search show-search
class="tracking-select" class="tracking-select"
option-filter-prop="label" :filter-option="false"
:filter-option="filterQuestionOption" :loading="questionList.loading.value"
:not-found-content="t('tracking.noQuestions')" :not-found-content="
questionList.loading.value ? t('common.loading') : t('tracking.noQuestions')
"
:placeholder="t('tracking.keywordPlaceholder')" :placeholder="t('tracking.keywordPlaceholder')"
:options="questionSetOptions" :options="questionSetOptions"
@search="questionList.search"
@popup-scroll="questionList.handlePopupScroll"
/> />
</div> </div>
<div class="tracking-action-item"> <div class="tracking-action-item">
@@ -1239,12 +1280,16 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
<h3 class="panel-title">{{ t('tracking.hotQuestionsTitle') }}</h3> <h3 class="panel-title">{{ t('tracking.hotQuestionsTitle') }}</h3>
<span class="tracking-panel__helper">{{ t('tracking.hotQuestionsHint') }}</span> <span class="tracking-panel__helper">{{ t('tracking.hotQuestionsHint') }}</span>
</div> </div>
<a-input-search <a-input
v-model:value="hotQuestionSearch" v-model:value="hotQuestionSearch"
allow-clear allow-clear
class="tracking-list-search" class="tracking-list-search"
:placeholder="t('tracking.questionSearchPlaceholder')" :placeholder="t('tracking.questionSearchPlaceholder')"
/> >
<template #prefix>
<SearchOutlined class="search-icon" />
</template>
</a-input>
</div> </div>
<div v-if="hotQuestions.length" class="question-list"> <div v-if="hotQuestions.length" class="question-list">
@@ -1255,15 +1300,38 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
class="question-list__item" class="question-list__item"
@click="openQuestion(question)" @click="openQuestion(question)"
> >
<div> <div class="question-list__item-left">
<strong>{{ question.question_text }}</strong> <div class="question-list__item-icon">
<p> <QuestionCircleOutlined />
{{ t('tracking.metrics.mentionRate') }} {{ formatPercent(question.mention_rate) }}
</p>
</div> </div>
<div class="question-list__item-content">
<strong>{{ question.question_text }}</strong>
<div class="mention-rate-container">
<span class="mention-rate-label">{{ t('tracking.metrics.mentionRate') }}</span>
<span class="mention-rate-badge" :class="getMentionRateClass(question.mention_rate)">
{{ formatPercent(question.mention_rate) }}
</span>
<div class="mention-rate-track">
<div
class="mention-rate-bar"
:style="{ width: formatPercent(question.mention_rate) }"
:class="getMentionRateClass(question.mention_rate)"
></div>
</div>
</div>
</div>
</div>
<div class="question-list__right">
<div class="question-list__time-wrap">
<ClockCircleOutlined class="time-icon" />
<span class="question-list__time"> <span class="question-list__time">
{{ question.last_sampled_at ? formatDateTime(question.last_sampled_at) : '--' }} {{ question.last_sampled_at ? formatDateTime(question.last_sampled_at) : '--' }}
</span> </span>
</div>
<div class="question-list__arrow">
<RightOutlined />
</div>
</div>
</button> </button>
</div> </div>
@@ -1303,18 +1371,33 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
:key="item.article_id" :key="item.article_id"
class="article-list__item" class="article-list__item"
> >
<div class="article-list__content"> <div class="article-list__item-left">
<div class="article-list__item-icon">
<FileTextOutlined />
</div>
<div class="article-list__item-content">
<strong>{{ item.article_title }}</strong> <strong>{{ item.article_title }}</strong>
<p>{{ item.publish_platform }}</p> <span class="article-platform-tag">{{ item.publish_platform }}</span>
</div>
</div> </div>
<div class="article-list__side"> <div class="article-list__side">
<div class="article-list__meta"> <div class="article-metric-group">
<span>{{ item.citation_count }} {{ t('tracking.citations') }}</span> <div class="article-metric">
<span> <span class="article-metric-val">{{ item.citation_count }}</span>
{{ t('tracking.columns.share') }} {{ formatPercent(item.source_share) }} <span class="article-metric-lbl">{{ t('tracking.citations') }}</span>
</span>
<strong>{{ formatPercent(item.citation_rate) }}</strong>
</div> </div>
<div class="article-metric-divider"></div>
<div class="article-metric">
<span class="article-metric-val">{{ formatPercent(item.source_share) }}</span>
<span class="article-metric-lbl">{{ t('tracking.columns.share') }}</span>
</div>
<div class="article-metric-divider"></div>
<div class="article-metric accent">
<span class="article-metric-val">{{ formatPercent(item.citation_rate) }}</span>
<span class="article-metric-lbl">引用率</span>
</div>
</div>
<div class="article-actions">
<a-tooltip :title="t('tracking.viewArticleContent')"> <a-tooltip :title="t('tracking.viewArticleContent')">
<a-button <a-button
type="text" type="text"
@@ -1329,6 +1412,7 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
</div> </div>
</div> </div>
</div> </div>
</div>
<a-empty v-else :description="t('tracking.noCitedArticles')" /> <a-empty v-else :description="t('tracking.noCitedArticles')" />
@@ -1558,9 +1642,9 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
.panel-title { .panel-title {
margin: 0; margin: 0;
font-size: 16px; font-size: 17px;
font-weight: 700; font-weight: 700;
color: #1a1a1a; color: #111827;
display: flex; display: flex;
align-items: center; align-items: center;
} }
@@ -1569,10 +1653,10 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
content: ''; content: '';
display: inline-block; display: inline-block;
width: 4px; width: 4px;
height: 14px; height: 16px;
background: #1f5cff; background: linear-gradient(180deg, #3b82f6 0%, #1d4ed8 100%);
border-radius: 2px; border-radius: 4px;
margin-right: 8px; margin-right: 10px;
} }
.tracking-panel__description { .tracking-panel__description {
@@ -1582,13 +1666,54 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
} }
.tracking-panel__helper { .tracking-panel__helper {
color: var(--muted); color: #6b7280;
font-size: 13px; font-size: 13px;
margin-top: 4px;
line-height: 1.5;
} }
.tracking-list-search { .tracking-list-search.ant-input-affix-wrapper {
width: min(100%, 260px); background-color: #f3f4f6;
flex: 0 0 260px; border: 1px solid transparent;
border-radius: 20px;
padding: 6px 14px;
transition: all 0.25s ease;
height: 38px;
align-items: center;
width: min(100%, 280px);
flex: 0 0 280px;
}
.tracking-list-search.ant-input-affix-wrapper:hover {
background-color: #e5e7eb;
border-color: transparent;
}
.tracking-list-search.ant-input-affix-wrapper-focused,
.tracking-list-search.ant-input-affix-wrapper:focus {
background-color: #ffffff;
border-color: #1f5cff;
box-shadow: 0 0 0 3px rgba(31, 92, 255, 0.1), 0 4px 12px rgba(31, 92, 255, 0.05);
}
.tracking-list-search :deep(.ant-input) {
background-color: transparent !important;
font-size: 14px;
color: #1f2937;
border: none !important;
box-shadow: none !important;
height: auto;
}
.tracking-list-search .search-icon {
color: #9ca3af;
font-size: 15px;
margin-right: 6px;
transition: color 0.2s ease;
}
.tracking-list-search.ant-input-affix-wrapper-focused .search-icon {
color: #1f5cff;
} }
.tracking-panel__empty { .tracking-panel__empty {
@@ -1890,32 +2015,76 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 16px;
width: 100%; width: 100%;
padding: 16px; padding: 16px 20px;
border: 1px solid #f0f3fa; border: 1px solid #f0f2f5;
border-radius: 12px; border-radius: 12px;
background: #f7f9fc;
text-align: left;
transition: all 0.2s;
}
.question-list__item:hover,
.article-list__item:hover,
.detail-citation-list__item:hover {
background: #ffffff; background: #ffffff;
border-color: #e5eaf2; text-align: left;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.02);
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
} }
.question-list__item { .question-list__item {
cursor: pointer; cursor: pointer;
} }
.question-list__item:hover,
.article-list__item:hover,
.detail-citation-list__item:hover {
background: #ffffff;
border-color: rgba(31, 92, 255, 0.25);
box-shadow: 0 10px 25px -5px rgba(31, 92, 255, 0.08), 0 8px 10px -6px rgba(31, 92, 255, 0.04);
transform: translateY(-2px);
}
.question-list__item-left {
display: flex;
align-items: center;
gap: 16px;
min-width: 0;
flex: 1;
}
.question-list__item-icon {
width: 38px;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
color: #1f5cff;
font-size: 16px;
flex-shrink: 0;
transition: all 0.25s ease;
}
.question-list__item:hover .question-list__item-icon {
background: linear-gradient(135deg, #1f5cff 0%, #104df5 100%);
color: #ffffff;
transform: scale(1.05);
}
.question-list__item-content {
min-width: 0;
flex: 1;
}
.question-list__item strong, .question-list__item strong,
.article-list__item strong, .article-list__item strong,
.detail-citation-list__item strong { .detail-citation-list__item strong {
display: block; display: block;
font-size: 15px;
font-weight: 600;
color: #1f2937;
line-height: 1.4;
overflow-wrap: anywhere;
}
.question-list__item:hover strong {
color: #111827;
} }
.question-list__item p, .question-list__item p,
@@ -1923,39 +2092,218 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
.detail-citation-list__item p { .detail-citation-list__item p {
margin: 4px 0 0; margin: 4px 0 0;
color: var(--muted); color: var(--muted);
font-size: 13px;
} }
.question-list__time, /* Mention Rate Badge & Progress Bar */
.article-list__meta { .mention-rate-container {
color: var(--muted);
font-size: 12px;
}
.article-list__meta {
display: flex; display: flex;
flex-direction: column; align-items: center;
align-items: flex-end; gap: 8px;
margin-top: 8px;
}
.mention-rate-label {
font-size: 12px;
color: #6b7280;
}
.mention-rate-badge {
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 6px;
line-height: 1.2;
}
.mention-rate-badge.rate-zero {
background: #f3f4f6;
color: #4b5563;
}
.mention-rate-badge.rate-low {
background: #eff6ff;
color: #1d4ed8;
}
.mention-rate-badge.rate-mid {
background: #fdf2f8;
color: #be185d;
}
.mention-rate-badge.rate-high {
background: #f0fdf4;
color: #15803d;
}
.mention-rate-track {
width: 60px;
height: 4px;
background: #e5e7eb;
border-radius: 2px;
overflow: hidden;
}
.mention-rate-bar {
height: 100%;
border-radius: 2px;
transition: width 0.3s ease;
}
.mention-rate-bar.rate-zero {
background: #d1d5db;
}
.mention-rate-bar.rate-low {
background: #3b82f6;
}
.mention-rate-bar.rate-mid {
background: #ec4899;
}
.mention-rate-bar.rate-high {
background: #22c55e;
}
/* Right-side alignment and arrows */
.question-list__right {
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
}
.question-list__time-wrap {
display: flex;
align-items: center;
gap: 6px; gap: 6px;
min-width: 84px; color: #9ca3af;
} }
.article-list__content { .time-icon {
font-size: 13px;
color: #9ca3af;
}
.question-list__time {
font-size: 13px;
color: #6b7280;
}
.question-list__arrow {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: #f3f4f6;
color: #9ca3af;
font-size: 12px;
transition: all 0.25s ease;
}
.question-list__item:hover .question-list__arrow {
background: #eff6ff;
color: #1f5cff;
transform: translateX(4px);
}
.article-list__item-left {
display: flex;
align-items: center;
gap: 16px;
min-width: 0; min-width: 0;
flex: 1;
} }
.article-list__content strong { .article-list__item-icon {
overflow-wrap: anywhere; width: 38px;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
color: #16a34a;
font-size: 16px;
flex-shrink: 0;
transition: all 0.25s ease;
}
.article-list__item:hover .article-list__item-icon {
background: linear-gradient(135deg, #16a34a 0%, #15803d 100%);
color: #ffffff;
transform: scale(1.05);
}
.article-list__item-content {
min-width: 0;
flex: 1;
}
.article-platform-tag {
display: inline-block;
margin-top: 8px;
font-size: 11px;
font-weight: 500;
color: #4b5563;
background: #f3f4f6;
padding: 2px 8px;
border-radius: 6px;
line-height: 1.2;
} }
.article-list__side { .article-list__side {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 16px;
flex: 0 0 auto; flex-shrink: 0;
}
.article-metric-group {
display: flex;
align-items: center;
gap: 20px;
}
.article-metric {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.article-metric-val {
font-size: 14px;
font-weight: 700;
color: #1f2937;
line-height: 1.2;
}
.article-metric-lbl {
font-size: 11px;
color: #9ca3af;
margin-top: 4px;
}
.article-metric.accent .article-metric-val {
color: #1f5cff;
}
.article-metric-divider {
width: 1px;
height: 24px;
background-color: #e5e7eb;
}
.article-actions {
margin-left: 4px;
} }
.article-list__preview-button { .article-list__preview-button {
color: #4b5563; color: #4b5563;
transition: all 0.2s ease;
} }
.article-list__preview-button:hover { .article-list__preview-button:hover {
@@ -1966,7 +2314,28 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
.tracking-pagination { .tracking-pagination {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
margin-top: 16px; margin-top: 20px;
}
.tracking-pagination :deep(.ant-pagination-item) {
border-radius: 6px;
border-color: #e5e7eb;
transition: all 0.2s ease;
}
.tracking-pagination :deep(.ant-pagination-item-active) {
background-color: #1f5cff;
border-color: #1f5cff;
}
.tracking-pagination :deep(.ant-pagination-item-active a) {
color: #ffffff !important;
}
.tracking-pagination :deep(.ant-pagination-prev .ant-pagination-item-link),
.tracking-pagination :deep(.ant-pagination-next .ant-pagination-item-link) {
border-radius: 6px;
border-color: #e5e7eb;
} }
.detail-tabs { .detail-tabs {
@@ -2062,13 +2431,32 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
flex-direction: column; flex-direction: column;
} }
.question-list__item-left {
width: 100%;
}
.question-list__right {
width: 100%;
margin-top: 12px;
justify-content: space-between;
}
.question-list__time { .question-list__time {
align-self: flex-end; align-self: center;
}
.article-list__item-left {
width: 100%;
} }
.article-list__side { .article-list__side {
justify-content: space-between; justify-content: space-between;
width: 100%; width: 100%;
margin-top: 12px;
}
.article-metric-group {
gap: 12px;
} }
.tracking-citation-header { .tracking-citation-header {
+7
View File
@@ -1722,6 +1722,13 @@ export interface Question {
created_at: string created_at: string
} }
export interface QuestionListResponse {
items: Question[]
total: number
page: number
page_size: number
}
export interface BrandLibrarySummary { export interface BrandLibrarySummary {
plan_code: string plan_code: string
plan_name: string plan_name: string
@@ -333,7 +333,7 @@ var routeDocs = map[string]routeDoc{
"PUT /api/tenant/brands/:id/keywords/:kid": {"更新关键词", "修改关键词文本/分组。"}, "PUT /api/tenant/brands/:id/keywords/:kid": {"更新关键词", "修改关键词文本/分组。"},
"DELETE /api/tenant/brands/:id/keywords/:kid": {"删除关键词", "删除某条关键词。"}, "DELETE /api/tenant/brands/:id/keywords/:kid": {"删除关键词", "删除某条关键词。"},
"GET /api/tenant/brands/:id/questions": {"搜索词列表", "返回品牌下的监控搜索词,可按 keyword_id 过滤。"}, "GET /api/tenant/brands/:id/questions": {"搜索词列表", "分页返回品牌下的监控搜索词,可按 keyword_id 过滤,支持 q 搜索。"},
"POST /api/tenant/brands/:id/questions": {"新增搜索词", "为品牌添加一条 GEO 监控搜索词。"}, "POST /api/tenant/brands/:id/questions": {"新增搜索词", "为品牌添加一条 GEO 监控搜索词。"},
"POST /api/tenant/brands/:id/questions/combination-preview": {"拓词工具预览", "按地域词、前缀词、精准词、行业词、后缀词组合生成搜索词候选,仅预览不入库。"}, "POST /api/tenant/brands/:id/questions/combination-preview": {"拓词工具预览", "按地域词、前缀词、精准词、行业词、后缀词组合生成搜索词候选,仅预览不入库。"},
"POST /api/tenant/brands/:id/questions/combination-fill": {"拓词工具 AI 填词", "根据用户 IP 本地识别地域,并结合品牌信息补全拓词工具的五列词组。"}, "POST /api/tenant/brands/:id/questions/combination-fill": {"拓词工具 AI 填词", "根据用户 IP 本地识别地域,并结合品牌信息补全拓词工具的五列词组。"},
+79 -8
View File
@@ -474,10 +474,44 @@ type QuestionResponse struct {
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
} }
func (s *BrandService) ListQuestions(ctx context.Context, brandID int64, keywordID *int64) ([]QuestionResponse, error) { type QuestionListParams struct {
KeywordID *int64
Page int
PageSize int
Query string
}
type QuestionListResponse struct {
Items []QuestionResponse `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
func normalizeQuestionListParams(params QuestionListParams) QuestionListParams {
params.Query = strings.TrimSpace(params.Query)
if params.Page < 1 {
params.Page = 1
}
if params.PageSize < 1 {
params.PageSize = 10
}
if params.PageSize > 100 {
params.PageSize = 100
}
return params
}
func (params QuestionListParams) offset() int {
params = normalizeQuestionListParams(params)
return (params.Page - 1) * params.PageSize
}
func (s *BrandService) ListQuestions(ctx context.Context, brandID int64, params QuestionListParams) (*QuestionListResponse, error) {
actor := auth.MustActor(ctx) actor := auth.MustActor(ctx)
return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, brandQuestionsCacheKey(actor.TenantID, brandID, keywordID), defaultCacheTTL(), func(loadCtx context.Context) ([]QuestionResponse, error) { params = normalizeQuestionListParams(params)
return s.loadBrandQuestions(loadCtx, actor.TenantID, brandID, keywordID) return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, brandQuestionsCacheKey(actor.TenantID, brandID, params), defaultCacheTTL(), func(loadCtx context.Context) (*QuestionListResponse, error) {
return s.loadBrandQuestions(loadCtx, actor.TenantID, brandID, params)
}) })
} }
@@ -857,18 +891,44 @@ func (s *BrandService) loadBrandKeywords(ctx context.Context, tenantID, brandID
return items, nil return items, nil
} }
func (s *BrandService) loadBrandQuestions(ctx context.Context, tenantID, brandID int64, keywordID *int64) ([]QuestionResponse, error) { func (s *BrandService) loadBrandQuestions(ctx context.Context, tenantID, brandID int64, params QuestionListParams) (*QuestionListResponse, error) {
params = normalizeQuestionListParams(params)
countQuery := `
SELECT COUNT(*)::bigint
FROM brand_questions q
WHERE q.brand_id = $1 AND q.tenant_id = $2 AND q.deleted_at IS NULL`
countArgs := []interface{}{brandID, tenantID}
if params.KeywordID != nil {
countQuery += ` AND q.keyword_id = $3`
countArgs = append(countArgs, *params.KeywordID)
}
if params.Query != "" {
countArgs = append(countArgs, "%"+params.Query+"%")
countQuery += fmt.Sprintf(` AND q.question_text ILIKE $%d`, len(countArgs))
}
var total int64
if err := s.pool.QueryRow(ctx, countQuery, countArgs...).Scan(&total); err != nil {
return nil, response.ErrInternal(50010, "query_failed", "failed to count questions")
}
query := ` query := `
SELECT q.id, q.brand_id, q.keyword_id, q.question_text, q.status, q.created_at SELECT q.id, q.brand_id, q.keyword_id, q.question_text, q.status, q.created_at
FROM brand_questions q FROM brand_questions q
WHERE q.brand_id = $1 AND q.tenant_id = $2 AND q.deleted_at IS NULL` WHERE q.brand_id = $1 AND q.tenant_id = $2 AND q.deleted_at IS NULL`
args := []interface{}{brandID, tenantID} args := []interface{}{brandID, tenantID}
if keywordID != nil { if params.KeywordID != nil {
query += ` AND q.keyword_id = $3` query += ` AND q.keyword_id = $3`
args = append(args, *keywordID) args = append(args, *params.KeywordID)
} }
query += ` ORDER BY q.created_at DESC` if params.Query != "" {
args = append(args, "%"+params.Query+"%")
query += fmt.Sprintf(` AND q.question_text ILIKE $%d`, len(args))
}
query += fmt.Sprintf(` ORDER BY q.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
args = append(args, params.PageSize, params.offset())
rows, err := s.pool.Query(ctx, query, args...) rows, err := s.pool.Query(ctx, query, args...)
if err != nil { if err != nil {
@@ -886,7 +946,18 @@ func (s *BrandService) loadBrandQuestions(ctx context.Context, tenantID, brandID
item.CreatedAt = fmt.Sprintf("%v", createdAt) item.CreatedAt = fmt.Sprintf("%v", createdAt)
items = append(items, item) items = append(items, item)
} }
return items, nil if err := rows.Err(); err != nil {
return nil, response.ErrInternal(50010, "scan_failed", err.Error())
}
if items == nil {
items = []QuestionResponse{}
}
return &QuestionListResponse{
Items: items,
Total: total,
Page: params.Page,
PageSize: params.PageSize,
}, nil
} }
func (s *BrandService) loadBrandCompetitors(ctx context.Context, tenantID, brandID int64) ([]CompetitorResponse, error) { func (s *BrandService) loadBrandCompetitors(ctx context.Context, tenantID, brandID int64) ([]CompetitorResponse, error) {
+8 -2
View File
@@ -116,11 +116,17 @@ func brandQuestionsCachePrefix(tenantID, brandID int64) string {
return fmt.Sprintf("brand:questions:%d:%d:", tenantID, brandID) return fmt.Sprintf("brand:questions:%d:%d:", tenantID, brandID)
} }
func brandQuestionsCacheKey(tenantID, brandID int64, keywordID *int64) string { func brandQuestionsCacheKey(tenantID, brandID int64, params QuestionListParams) string {
return fmt.Sprintf("%s%s", brandQuestionsCachePrefix(tenantID, brandID), digestCacheKey(struct { return fmt.Sprintf("%s%s", brandQuestionsCachePrefix(tenantID, brandID), digestCacheKey(struct {
KeywordID *int64 `json:"keyword_id,omitempty"` KeywordID *int64 `json:"keyword_id,omitempty"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Query string `json:"q,omitempty"`
}{ }{
KeywordID: keywordID, KeywordID: params.KeywordID,
Page: params.Page,
PageSize: params.PageSize,
Query: params.Query,
})) }))
} }
@@ -3639,7 +3639,9 @@ func (s *MonitoringService) loadCitedArticles(
rows, err := s.monitoringPool.Query(ctx, ` rows, err := s.monitoringPool.Query(ctx, `
SELECT SELECT
cf.cited_url, cf.cited_url,
cf.normalized_url cf.normalized_url,
NULLIF(cf.article_id, 0),
NULLIF(cf.cited_title, '')
FROM monitoring_citation_facts cf FROM monitoring_citation_facts cf
JOIN question_monitor_runs r JOIN question_monitor_runs r
ON r.tenant_id = cf.tenant_id AND r.id = cf.run_id ON r.tenant_id = cf.tenant_id AND r.id = cf.run_id
@@ -3668,22 +3670,40 @@ func (s *MonitoringService) loadCitedArticles(
for rows.Next() { for rows.Next() {
var citedURL string var citedURL string
var normalizedURL string var normalizedURL string
if scanErr := rows.Scan(&citedURL, &normalizedURL); scanErr != nil { var factArticleID sql.NullInt64
var factCitedTitle sql.NullString
if scanErr := rows.Scan(&citedURL, &normalizedURL, &factArticleID, &factCitedTitle); scanErr != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse cited articles") return nil, response.ErrInternal(50041, "scan_failed", "failed to parse cited articles")
} }
alias, ok := aliasIndex.Match(citedURL, normalizedURL) alias, ok := aliasIndex.Match(citedURL, normalizedURL)
if !ok {
articleID := factArticleID.Int64
if !factArticleID.Valid && ok {
articleID = alias.ArticleID
}
if articleID <= 0 {
continue continue
} }
item := aggregates[alias.ArticleID]
item := aggregates[articleID]
if item.ArticleID == 0 { if item.ArticleID == 0 {
item.ArticleID = alias.ArticleID item.ArticleID = articleID
if ok && strings.TrimSpace(alias.ArticleTitle) != "" {
item.ArticleTitle = alias.ArticleTitle item.ArticleTitle = alias.ArticleTitle
} else if factCitedTitle.Valid && strings.TrimSpace(factCitedTitle.String) != "" {
item.ArticleTitle = factCitedTitle.String
} else {
item.ArticleTitle = "未命名文章"
}
if ok && strings.TrimSpace(alias.PublishPlatform) != "" {
item.PublishPlatform = alias.PublishPlatform item.PublishPlatform = alias.PublishPlatform
} else {
item.PublishPlatform = "已发布内容"
}
} }
item.CitationCount++ item.CitationCount++
totalArticleCitationCount++ totalArticleCitationCount++
aggregates[alias.ArticleID] = item aggregates[articleID] = item
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate cited articles") return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate cited articles")
@@ -3748,11 +3768,12 @@ func (s *MonitoringService) loadCitedArticlesPage(
var stats citedArticleStats var stats citedArticleStats
if err := s.monitoringPool.QueryRow(ctx, ` if err := s.monitoringPool.QueryRow(ctx, `
WITH article_counts AS ( WITH article_counts AS (
SELECT alias.article_id, COUNT(*)::bigint AS citation_count SELECT COALESCE(NULLIF(cf.article_id, 0), alias.article_id) AS article_id,
COUNT(*)::bigint AS citation_count
FROM monitoring_citation_facts cf FROM monitoring_citation_facts cf
JOIN question_monitor_runs r JOIN question_monitor_runs r
ON r.tenant_id = cf.tenant_id AND r.id = cf.run_id ON r.tenant_id = cf.tenant_id AND r.id = cf.run_id
JOIN monitoring_article_url_aliases alias LEFT JOIN monitoring_article_url_aliases alias
ON alias.tenant_id = cf.tenant_id ON alias.tenant_id = cf.tenant_id
AND alias.normalized_url = cf.normalized_url AND alias.normalized_url = cf.normalized_url
AND alias.confidence = 'high' AND alias.confidence = 'high'
@@ -3764,7 +3785,8 @@ func (s *MonitoringService) loadCitedArticlesPage(
AND r.business_date BETWEEN $4::date AND $5::date AND r.business_date BETWEEN $4::date AND $5::date
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6)) AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7)) AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7))
GROUP BY alias.article_id AND COALESCE(NULLIF(cf.article_id, 0), alias.article_id) IS NOT NULL
GROUP BY COALESCE(NULLIF(cf.article_id, 0), alias.article_id)
) )
SELECT COUNT(*)::bigint, COALESCE(SUM(citation_count), 0)::bigint SELECT COUNT(*)::bigint, COALESCE(SUM(citation_count), 0)::bigint
FROM article_counts FROM article_counts
@@ -3780,14 +3802,14 @@ func (s *MonitoringService) loadCitedArticlesPage(
rows, err := s.monitoringPool.Query(ctx, ` rows, err := s.monitoringPool.Query(ctx, `
WITH article_counts AS ( WITH article_counts AS (
SELECT SELECT
alias.article_id, COALESCE(NULLIF(cf.article_id, 0), alias.article_id) AS article_id,
COALESCE(MAX(alias.article_title_snapshot), '未命名文章') AS article_title, COALESCE(MAX(alias.article_title_snapshot), MAX(NULLIF(cf.cited_title, '')), '未命名文章') AS article_title,
COALESCE(MAX(alias.publish_platform_name_snapshot), '已发布内容') AS publish_platform, COALESCE(MAX(alias.publish_platform_name_snapshot), '已发布内容') AS publish_platform,
COUNT(*)::bigint AS citation_count COUNT(*)::bigint AS citation_count
FROM monitoring_citation_facts cf FROM monitoring_citation_facts cf
JOIN question_monitor_runs r JOIN question_monitor_runs r
ON r.tenant_id = cf.tenant_id AND r.id = cf.run_id ON r.tenant_id = cf.tenant_id AND r.id = cf.run_id
JOIN monitoring_article_url_aliases alias LEFT JOIN monitoring_article_url_aliases alias
ON alias.tenant_id = cf.tenant_id ON alias.tenant_id = cf.tenant_id
AND alias.normalized_url = cf.normalized_url AND alias.normalized_url = cf.normalized_url
AND alias.confidence = 'high' AND alias.confidence = 'high'
@@ -3799,7 +3821,8 @@ func (s *MonitoringService) loadCitedArticlesPage(
AND r.business_date BETWEEN $4::date AND $5::date AND r.business_date BETWEEN $4::date AND $5::date
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6)) AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7)) AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7))
GROUP BY alias.article_id AND COALESCE(NULLIF(cf.article_id, 0), alias.article_id) IS NOT NULL
GROUP BY COALESCE(NULLIF(cf.article_id, 0), alias.article_id)
) )
SELECT article_id, article_title, publish_platform, citation_count SELECT article_id, article_title, publish_platform, citation_count
FROM article_counts FROM article_counts
@@ -156,14 +156,39 @@ func (h *BrandHandler) DeleteKeyword(c *gin.Context) {
// Questions // Questions
func (h *BrandHandler) ListQuestions(c *gin.Context) { func (h *BrandHandler) ListQuestions(c *gin.Context) {
brandID, _ := strconv.ParseInt(c.Param("id"), 10, 64) brandID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "brand id must be a number"))
return
}
var keywordID *int64 var keywordID *int64
if v := c.Query("keyword_id"); v != "" { if v := c.Query("keyword_id"); v != "" {
if id, err := strconv.ParseInt(v, 10, 64); err == nil { id, parseErr := strconv.ParseInt(v, 10, 64)
if parseErr != nil || id <= 0 {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", "keyword_id must be a positive integer"))
return
}
keywordID = &id keywordID = &id
} }
page, err := parsePositiveIntQuery(c, "page", 1)
if err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", "page must be a positive integer"))
return
} }
data, err := h.svc.ListQuestions(c.Request.Context(), brandID, keywordID) pageSize, err := parsePositiveIntQuery(c, "page_size", 10)
if err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", "page_size must be a positive integer"))
return
}
data, err := h.svc.ListQuestions(c.Request.Context(), brandID, app.QuestionListParams{
KeywordID: keywordID,
Page: page,
PageSize: pageSize,
Query: c.Query("q"),
})
if err != nil { if err != nil {
response.Error(c, err) response.Error(c, err)
return return
@@ -171,6 +196,21 @@ func (h *BrandHandler) ListQuestions(c *gin.Context) {
response.Success(c, data) response.Success(c, data)
} }
func parsePositiveIntQuery(c *gin.Context, key string, fallback int) (int, error) {
raw := c.Query(key)
if raw == "" {
return fallback, nil
}
value, err := strconv.Atoi(raw)
if err != nil || value <= 0 {
if err != nil {
return 0, err
}
return 0, strconv.ErrSyntax
}
return value, nil
}
func (h *BrandHandler) CreateQuestion(c *gin.Context) { func (h *BrandHandler) CreateQuestion(c *gin.Context) {
brandID, _ := strconv.ParseInt(c.Param("id"), 10, 64) brandID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
var req app.QuestionRequest var req app.QuestionRequest