From 9ed857e15961f37e52b409f0943f3395452e10a3 Mon Sep 17 00:00:00 2001 From: liangxu Date: Mon, 15 Jun 2026 22:01:23 +0800 Subject: [PATCH] feat: paginate and search brand questions across selects 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) --- apps/admin-web/src/lib/api.ts | 10 +- .../src/lib/use-paginated-brand-questions.ts | 214 ++++++ .../src/views/BrandQuestionCreateView.vue | 32 +- apps/admin-web/src/views/BrandsView.vue | 43 +- .../src/views/ImitationGenerateView.vue | 16 +- .../src/views/TemplateWizardView.vue | 73 +- apps/admin-web/src/views/TrackingView.vue | 624 ++++++++++++++---- packages/shared-types/src/index.ts | 7 + .../internal/shared/swagger/descriptions.go | 2 +- server/internal/tenant/app/brand_service.go | 87 ++- server/internal/tenant/app/cache_support.go | 10 +- .../internal/tenant/app/monitoring_service.go | 53 +- .../tenant/transport/brand_handler.go | 48 +- 13 files changed, 1030 insertions(+), 189 deletions(-) create mode 100644 apps/admin-web/src/lib/use-paginated-brand-questions.ts diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index 71607f5..9861990 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -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(`/api/tenant/brands/${brandId}/keywords/${keywordId}`) }, - listQuestions(brandId: number, keywordId?: number | null) { - return apiClient.get(`/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(`/api/tenant/brands/${brandId}/questions`, { + params, }) }, previewQuestionCombination(brandId: number, payload: QuestionCombinationRequest) { diff --git a/apps/admin-web/src/lib/use-paginated-brand-questions.ts b/apps/admin-web/src/lib/use-paginated-brand-questions.ts new file mode 100644 index 0000000..6d98bd1 --- /dev/null +++ b/apps/admin-web/src/lib/use-paginated-brand-questions.ts @@ -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, + options: { + pageSize?: number + includeAllOption?: boolean + allOptionLabel?: () => string + } = {}, +) { + const pageSize = options.pageSize ?? defaultQuestionPageSize + const searchText = ref('') + const items = ref([]) + const selectedItems = ref([]) + const page = ref(1) + const total = ref(0) + const loading = ref(false) + const initialized = ref(false) + const requestSeq = ref(0) + let pendingLoad: Promise | null = null + + const hasMore = computed(() => items.value.length < total.value) + + const questionOptions = computed(() => { + const map = new Map() + 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 { + 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 { + 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 { + 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): 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() + 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()) +} diff --git a/apps/admin-web/src/views/BrandQuestionCreateView.vue b/apps/admin-web/src/views/BrandQuestionCreateView.vue index d9b4377..d2cf056 100644 --- a/apps/admin-web/src/views/BrandQuestionCreateView.vue +++ b/apps/admin-web/src/views/BrandQuestionCreateView.vue @@ -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( @@ -444,6 +449,29 @@ function mergeQuestionCandidateResults( } } +async function listAllBrandQuestions(currentBrandId: number): Promise { + 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) { diff --git a/apps/admin-web/src/views/BrandsView.vue b/apps/admin-web/src/views/BrandsView.vue index a4a62aa..0f60930 100644 --- a/apps/admin-web/src/views/BrandsView.vue +++ b/apps/admin-web/src/views/BrandsView.vue @@ -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 { await questionMutations.createSingle.mutateAsync() } +function handleQuestionTableChange(nextPage: number, nextPageSize: number): void { + questionPage.value = nextPage + questionPageSize.value = nextPageSize +} + async function submitCompetitor(): Promise { if (!competitorForm.name.trim()) { return @@ -551,7 +578,13 @@ async function invalidateBrandQueries(): Promise { :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" > diff --git a/apps/admin-web/src/views/ImitationGenerateView.vue b/apps/admin-web/src/views/ImitationGenerateView.vue index 352363a..18dec9f 100644 --- a/apps/admin-web/src/views/ImitationGenerateView.vue +++ b/apps/admin-web/src/views/ImitationGenerateView.vue @@ -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 { diff --git a/apps/admin-web/src/views/TemplateWizardView.vue b/apps/admin-web/src/views/TemplateWizardView.vue index 0388b73..4d09fc0 100644 --- a/apps/admin-web/src/views/TemplateWizardView.vue +++ b/apps/admin-web/src/views/TemplateWizardView.vue @@ -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 | 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" /> @@ -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" />

{{ t('templates.wizard.hints.supplementalQuestions') }} diff --git a/apps/admin-web/src/views/TrackingView.vue b/apps/admin-web/src/views/TrackingView.vue index f289cec..3abe80e 100644 --- a/apps/admin-web/src/views/TrackingView.vue +++ b/apps/admin-web/src/views/TrackingView.vue @@ -1,5 +1,13 @@