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
+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>