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,
QuestionCombinationRequest,
QuestionDistillRequest,
QuestionListResponse,
QuestionRequest,
QuotaSummary,
RecentArticle,
@@ -1139,9 +1140,12 @@ export const brandsApi = {
removeKeyword(brandId: number, keywordId: number) {
return apiClient.remove<null>(`/api/tenant/brands/${brandId}/keywords/${keywordId}`)
},
listQuestions(brandId: number, keywordId?: number | null) {
return apiClient.get<Question[]>(`/api/tenant/brands/${brandId}/questions`, {
params: keywordId ? { keyword_id: keywordId } : undefined,
listQuestions(
brandId: number,
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) {
@@ -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,
} from '@ant-design/icons-vue'
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 { message } from 'ant-design-vue'
import { computed, onBeforeUnmount, reactive, ref } from 'vue'
@@ -102,7 +107,7 @@ const brandLibrarySummaryQuery = useQuery({
const questionsQuery = useQuery({
queryKey: computed(() => ['brands', brandId.value, 'questions', 'all']),
enabled: computed(() => brandId.value > 0),
queryFn: () => brandsApi.listQuestions(brandId.value),
queryFn: () => listAllBrandQuestions(brandId.value),
})
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'
function handleInput(column: ColumnKey, index: number) {
+38 -5
View File
@@ -63,6 +63,9 @@ const competitorForm = reactive({
product_lines: '',
})
const questionPage = ref(1)
const questionPageSize = ref(10)
const brandListQuery = useQuery({
queryKey: ['brands', 'list'],
queryFn: () => brandsApi.list(),
@@ -74,9 +77,19 @@ const brandLibrarySummaryQuery = 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)),
queryFn: () => brandsApi.listQuestions(selectedBrandId.value as number),
queryFn: () =>
brandsApi.listQuestions(selectedBrandId.value as number, {
page: questionPage.value,
page_size: questionPageSize.value,
}),
})
const competitorsQuery = useQuery({
@@ -93,7 +106,8 @@ const selectedBrand = computed(
() => 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(
() =>
@@ -102,7 +116,7 @@ const maxQuestions = computed(
0,
)
const usedQuestions = computed(
() => brandLibrarySummary.value?.used_questions ?? currentQuestions.value.length,
() => brandLibrarySummary.value?.used_questions ?? questionTotal.value,
)
const brandLimitReached = computed(() => {
@@ -308,6 +322,13 @@ watch(
{ 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 {
const lines = value
.split(',')
@@ -344,6 +365,7 @@ function selectBrand(brandId: number): void {
return
}
selectedBrandId.value = brandId
questionPage.value = 1
void queryClient.invalidateQueries({ queryKey: ['brands'] })
}
@@ -429,6 +451,11 @@ async function submitSingleQuestion(): Promise<void> {
await questionMutations.createSingle.mutateAsync()
}
function handleQuestionTableChange(nextPage: number, nextPageSize: number): void {
questionPage.value = nextPage
questionPageSize.value = nextPageSize
}
async function submitCompetitor(): Promise<void> {
if (!competitorForm.name.trim()) {
return
@@ -551,7 +578,13 @@ async function invalidateBrandQueries(): Promise<void> {
:columns="questionColumns"
:data-source="currentQuestions"
:loading="questionsQuery.isPending.value"
:pagination="{ pageSize: 10, showSizeChanger: false }"
:pagination="{
current: questionPage,
pageSize: questionPageSize,
total: questionTotal,
showSizeChanger: false,
onChange: handleQuestionTableChange,
}"
row-key="id"
>
<template #emptyText>{{ t('brands.empty.questions') }}</template>
@@ -7,8 +7,9 @@ import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import KnowledgeGroupSelect from '@/components/KnowledgeGroupSelect.vue'
import { articlesApi, brandsApi } from '@/lib/api'
import { articlesApi } from '@/lib/api'
import { formatError } from '@/lib/errors'
import { usePaginatedBrandQuestions } from '@/lib/use-paginated-brand-questions'
import { useCompanyStore } from '@/stores/company'
const route = useRoute()
@@ -34,14 +35,10 @@ const selectedBrand = computed(() => companyStore.currentBrand)
const selectedBrandId = computed(() => selectedBrand.value?.id ?? null)
const currentBrandName = computed(() => selectedBrand.value?.name?.trim() || '')
const questionsQuery = useQuery({
queryKey: computed(() => ['brands', selectedBrandId.value, 'questions', 'imitation']),
enabled: computed(() => Boolean(selectedBrandId.value)),
queryFn: () => brandsApi.listQuestions(selectedBrandId.value as number),
})
const questionList = usePaginatedBrandQuestions(selectedBrandId)
const keywordOptions = computed(() =>
(questionsQuery.data.value ?? []).map((item) => ({
questionList.items.value.map((item) => ({
label: item.question_text,
value: item.question_text,
})),
@@ -239,9 +236,14 @@ function submit(): void {
<a-select
v-model:value="form.keywords"
mode="tags"
show-search
style="width: 100%"
:filter-option="false"
:loading="questionList.loading.value"
:options="keywordOptions"
:placeholder="t('imitation.create.keywordsPlaceholder')"
@search="questionList.search"
@popup-scroll="questionList.handlePopupScroll"
/>
</div>
+49 -24
View File
@@ -26,6 +26,7 @@ import KnowledgeGroupSelect from '@/components/KnowledgeGroupSelect.vue'
import { articlesApi, brandsApi, normalizeInputParams, templatesApi } from '@/lib/api'
import { getTemplateMeta } from '@/lib/display'
import { formatError } from '@/lib/errors'
import { usePaginatedBrandQuestions } from '@/lib/use-paginated-brand-questions'
import { useCompanyStore } from '@/stores/company'
interface DraftCompetitor {
@@ -159,6 +160,7 @@ let outlineNodeSeed = 0
let allowWizardLeave = false
let pendingLeavePromise: Promise<boolean> | null = null
let pendingLeaveResolve: ((canLeave: boolean) => void) | null = null
let selectedQuestionLoadSeq = 0
const CUSTOM_OUTLINE_MAX_LENGTH = 15
const currentStep = ref(0)
@@ -219,11 +221,7 @@ const draftArticleQuery = useQuery({
queryFn: () => articlesApi.detail(draftArticleId.value as number),
})
const questionsQuery = useQuery({
queryKey: computed(() => ['brands', selectedBrandId.value, 'questions', 'wizard']),
enabled: computed(() => enabled.value && Boolean(selectedBrandId.value)),
queryFn: () => brandsApi.listQuestions(selectedBrandId.value as number),
})
const questionList = usePaginatedBrandQuestions(selectedBrandId)
const competitorsQuery = useQuery({
queryKey: computed(() => ['brands', selectedBrandId.value, 'competitors']),
@@ -283,18 +281,10 @@ const templateMeta = computed(() => {
})
function findQuestionById(id: number | null): Question | null {
if (!id) {
return null
}
return questionsQuery.data.value?.find((item) => item.id === id) ?? null
return questionList.findQuestionById(id)
}
const questionOptions = computed(() =>
(questionsQuery.data.value ?? []).map((item) => ({
label: item.question_text,
value: item.id,
})),
)
const questionOptions = computed(() => questionList.options.value)
const supplementalQuestionOptions = computed(() =>
questionOptions.value.filter((item) => item.value !== primaryQuestionId.value),
)
@@ -635,15 +625,46 @@ watch(primaryQuestionId, (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) => {
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)),
typeof id === 'number' &&
Number.isFinite(id) &&
id > 0,
)
const limited = normalized.slice(0, 3)
if (limited.length !== ids.length || limited.some((id, index) => id !== ids[index])) {
@@ -676,7 +697,7 @@ watch(
primaryQuestionId.value = null
supplementalQuestionIds.value = []
await questionsQuery.refetch()
await questionList.refetch()
if (!showCompetitorsCard.value) {
competitorDrafts.value = []
@@ -2472,7 +2493,8 @@ function onStructureDragEnd(): void {
allow-clear
show-search
:disabled="!selectedBrandId"
:loading="questionsQuery.isPending.value"
:filter-option="false"
:loading="questionList.loading.value"
:not-found-content="
selectedBrandId
? t('templates.wizard.hints.questionEmpty')
@@ -2480,8 +2502,9 @@ function onStructureDragEnd(): void {
"
:options="questionOptions"
:placeholder="t('templates.wizard.placeholders.primaryQuestion')"
option-filter-prop="label"
style="width: 100%"
@search="questionList.search"
@popup-scroll="questionList.handlePopupScroll"
/>
</div>
@@ -2495,13 +2518,15 @@ function onStructureDragEnd(): void {
mode="multiple"
show-search
:disabled="!selectedBrandId || !primaryQuestionId"
:loading="questionsQuery.isPending.value"
:filter-option="false"
:loading="questionList.loading.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%"
@search="questionList.search"
@popup-scroll="questionList.handlePopupScroll"
/>
<p class="field-helper">
{{ t('templates.wizard.hints.supplementalQuestions') }}
+506 -118
View File
@@ -1,5 +1,13 @@
<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 {
DesktopAccountInfo,
MonitoringCitedArticle,
@@ -17,7 +25,7 @@ import { useRoute, useRouter } from 'vue-router'
import ArticleDetailDrawer from '@/components/ArticleDetailDrawer.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 { formatDateTime } from '@/lib/display'
import { formatError } from '@/lib/errors'
@@ -26,6 +34,7 @@ import {
normalizeMonitoringPlatformFilter,
normalizeMonitoringPlatformId,
} from '@/lib/monitoring-platforms'
import { usePaginatedBrandQuestions } from '@/lib/use-paginated-brand-questions'
import { useCompanyStore } from '@/stores/company'
const { t } = useI18n()
@@ -113,6 +122,8 @@ const hotQuestionPage = ref(1)
const citedArticlePage = ref(1)
const articleDrawerOpen = ref(false)
const selectedArticleId = ref<number | null>(null)
const missingRequestedQuestionId = ref<number | null>(null)
let questionSelectionSeq = 0
selectedPlatformId.value = normalizeMonitoringPlatformFilter(selectedPlatformId.value)
selectedCitationWindowDays.value = normalizeCitationWindowDays(selectedCitationWindowDays.value)
@@ -128,43 +139,62 @@ const selectedQuestionSetOptionValue = computed<string | number>({
const selectedBrandId = computed(() => companyStore.currentBrandId)
const selectedBrand = computed(() => companyStore.currentBrand)
const questionsQuery = useQuery({
queryKey: computed(() => ['tracking', 'questions', selectedBrandId.value]),
enabled: computed(() => Boolean(selectedBrandId.value)),
queryFn: () => brandsApi.listQuestions(selectedBrandId.value as number),
const questionList = usePaginatedBrandQuestions(selectedBrandId, {
includeAllOption: true,
allOptionLabel: () => t('tracking.allQuestionSets'),
})
const questionSetOptions = computed(() => [
{ label: t('tracking.allQuestionSets'), value: allQuestionOptionValue },
...(questionsQuery.data.value ?? []).map((item) => ({
label: item.question_text,
value: item.id,
})),
])
const questionSetOptions = computed(() => questionList.options.value)
watch(
() => questionsQuery.data.value,
(questions) => {
[selectedBrandId, () => route.query.question_id, selectedQuestionId],
async ([brandId, requestedQuestionValue, questionId]) => {
const requestedQuestionId = parseNumericQuery(requestedQuestionValue)
const targetQuestionId = requestedQuestionId ?? questionId
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
}
},
{ immediate: true },
)
watch(
() => route.query.question_id,
(value) => {
const requestedQuestionId = parseNumericQuery(route.query.question_id)
if (!questions?.length) {
if (!requestedQuestionId) {
selectedQuestionId.value = null
}
if (missingRequestedQuestionId.value && missingRequestedQuestionId.value !== requestedQuestionId) {
missingRequestedQuestionId.value = null
}
if (value || selectedBrandId.value) {
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
},
{ immediate: true },
@@ -198,11 +228,15 @@ watch(
selectedQuestionId,
selectedPlatformId,
selectedBusinessDate,
() => questionsQuery.data.value,
missingRequestedQuestionId,
],
([brandId, questionId, platformId, businessDate, questions]) => {
([brandId, questionId, platformId, businessDate, missingQuestionId]) => {
const requestedQuestionId = parseNumericQuery(route.query.question_id)
if (requestedQuestionId && !questionId && !Array.isArray(questions)) {
if (
requestedQuestionId &&
questionId !== requestedQuestionId &&
missingQuestionId !== requestedQuestionId
) {
return
}
@@ -688,16 +722,6 @@ function openCitedArticle(item: MonitoringCitedArticle): void {
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 {
if (value === null || value === undefined) {
return '--'
@@ -705,6 +729,19 @@ function formatPercent(value: number | null | undefined): string {
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 {
const numeric = Number(value)
return numeric === 30 ? 30 : 7
@@ -870,11 +907,15 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
v-model:value="selectedQuestionSetOptionValue"
show-search
class="tracking-select"
option-filter-prop="label"
:filter-option="filterQuestionOption"
:not-found-content="t('tracking.noQuestions')"
:filter-option="false"
:loading="questionList.loading.value"
:not-found-content="
questionList.loading.value ? t('common.loading') : t('tracking.noQuestions')
"
:placeholder="t('tracking.keywordPlaceholder')"
:options="questionSetOptions"
@search="questionList.search"
@popup-scroll="questionList.handlePopupScroll"
/>
</div>
<div class="tracking-action-item">
@@ -1239,12 +1280,16 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
<h3 class="panel-title">{{ t('tracking.hotQuestionsTitle') }}</h3>
<span class="tracking-panel__helper">{{ t('tracking.hotQuestionsHint') }}</span>
</div>
<a-input-search
<a-input
v-model:value="hotQuestionSearch"
allow-clear
class="tracking-list-search"
:placeholder="t('tracking.questionSearchPlaceholder')"
/>
>
<template #prefix>
<SearchOutlined class="search-icon" />
</template>
</a-input>
</div>
<div v-if="hotQuestions.length" class="question-list">
@@ -1255,15 +1300,38 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
class="question-list__item"
@click="openQuestion(question)"
>
<div>
<strong>{{ question.question_text }}</strong>
<p>
{{ t('tracking.metrics.mentionRate') }} {{ formatPercent(question.mention_rate) }}
</p>
<div class="question-list__item-left">
<div class="question-list__item-icon">
<QuestionCircleOutlined />
</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">
{{ question.last_sampled_at ? formatDateTime(question.last_sampled_at) : '--' }}
</span>
</div>
<div class="question-list__arrow">
<RightOutlined />
</div>
</div>
<span class="question-list__time">
{{ question.last_sampled_at ? formatDateTime(question.last_sampled_at) : '--' }}
</span>
</button>
</div>
@@ -1303,29 +1371,45 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
:key="item.article_id"
class="article-list__item"
>
<div class="article-list__content">
<strong>{{ item.article_title }}</strong>
<p>{{ item.publish_platform }}</p>
<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>
<span class="article-platform-tag">{{ item.publish_platform }}</span>
</div>
</div>
<div class="article-list__side">
<div class="article-list__meta">
<span>{{ item.citation_count }} {{ t('tracking.citations') }}</span>
<span>
{{ t('tracking.columns.share') }} {{ formatPercent(item.source_share) }}
</span>
<strong>{{ formatPercent(item.citation_rate) }}</strong>
<div class="article-metric-group">
<div class="article-metric">
<span class="article-metric-val">{{ item.citation_count }}</span>
<span class="article-metric-lbl">{{ t('tracking.citations') }}</span>
</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-button
type="text"
shape="circle"
class="article-list__preview-button"
:aria-label="t('tracking.viewArticleContent')"
@click="openCitedArticle(item)"
>
<template #icon><EyeOutlined /></template>
</a-button>
</a-tooltip>
</div>
<a-tooltip :title="t('tracking.viewArticleContent')">
<a-button
type="text"
shape="circle"
class="article-list__preview-button"
:aria-label="t('tracking.viewArticleContent')"
@click="openCitedArticle(item)"
>
<template #icon><EyeOutlined /></template>
</a-button>
</a-tooltip>
</div>
</div>
</div>
@@ -1558,9 +1642,9 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
.panel-title {
margin: 0;
font-size: 16px;
font-size: 17px;
font-weight: 700;
color: #1a1a1a;
color: #111827;
display: flex;
align-items: center;
}
@@ -1569,10 +1653,10 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
content: '';
display: inline-block;
width: 4px;
height: 14px;
background: #1f5cff;
border-radius: 2px;
margin-right: 8px;
height: 16px;
background: linear-gradient(180deg, #3b82f6 0%, #1d4ed8 100%);
border-radius: 4px;
margin-right: 10px;
}
.tracking-panel__description {
@@ -1582,13 +1666,54 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
}
.tracking-panel__helper {
color: var(--muted);
color: #6b7280;
font-size: 13px;
margin-top: 4px;
line-height: 1.5;
}
.tracking-list-search {
width: min(100%, 260px);
flex: 0 0 260px;
.tracking-list-search.ant-input-affix-wrapper {
background-color: #f3f4f6;
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 {
@@ -1890,32 +2015,76 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
gap: 16px;
width: 100%;
padding: 16px;
border: 1px solid #f0f3fa;
padding: 16px 20px;
border: 1px solid #f0f2f5;
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;
border-color: #e5eaf2;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
text-align: left;
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 {
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,
.article-list__item strong,
.detail-citation-list__item strong {
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,
@@ -1923,39 +2092,218 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
.detail-citation-list__item p {
margin: 4px 0 0;
color: var(--muted);
font-size: 13px;
}
.question-list__time,
.article-list__meta {
color: var(--muted);
font-size: 12px;
}
.article-list__meta {
/* Mention Rate Badge & Progress Bar */
.mention-rate-container {
display: flex;
flex-direction: column;
align-items: flex-end;
align-items: center;
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;
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;
flex: 1;
}
.article-list__content strong {
overflow-wrap: anywhere;
.article-list__item-icon {
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 {
display: flex;
align-items: center;
gap: 12px;
flex: 0 0 auto;
gap: 16px;
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 {
color: #4b5563;
transition: all 0.2s ease;
}
.article-list__preview-button:hover {
@@ -1966,7 +2314,28 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
.tracking-pagination {
display: flex;
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 {
@@ -2062,13 +2431,32 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
flex-direction: column;
}
.question-list__item-left {
width: 100%;
}
.question-list__right {
width: 100%;
margin-top: 12px;
justify-content: space-between;
}
.question-list__time {
align-self: flex-end;
align-self: center;
}
.article-list__item-left {
width: 100%;
}
.article-list__side {
justify-content: space-between;
width: 100%;
margin-top: 12px;
}
.article-metric-group {
gap: 12px;
}
.tracking-citation-header {