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) <noreply@anthropic.com>
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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') }}
|
||||||
|
|||||||
@@ -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(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)
|
const requestedQuestionId = parseNumericQuery(route.query.question_id)
|
||||||
if (!questions?.length) {
|
if (missingRequestedQuestionId.value && missingRequestedQuestionId.value !== requestedQuestionId) {
|
||||||
if (!requestedQuestionId) {
|
missingRequestedQuestionId.value = null
|
||||||
selectedQuestionId.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) }}
|
</div>
|
||||||
</p>
|
<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>
|
</div>
|
||||||
<span class="question-list__time">
|
|
||||||
{{ question.last_sampled_at ? formatDateTime(question.last_sampled_at) : '--' }}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1303,29 +1371,45 @@ 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">
|
||||||
<strong>{{ item.article_title }}</strong>
|
<div class="article-list__item-icon">
|
||||||
<p>{{ item.publish_platform }}</p>
|
<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>
|
||||||
<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>
|
</div>
|
||||||
<strong>{{ formatPercent(item.citation_rate) }}</strong>
|
<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>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 本地识别地域,并结合品牌信息补全拓词工具的五列词组。"},
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
item.ArticleTitle = alias.ArticleTitle
|
if ok && strings.TrimSpace(alias.ArticleTitle) != "" {
|
||||||
item.PublishPlatform = alias.PublishPlatform
|
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
|
||||||
|
} 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)
|
||||||
keywordID = &id
|
if parseErr != nil || id <= 0 {
|
||||||
|
response.Error(c, response.ErrBadRequest(40001, "invalid_params", "keyword_id must be a positive integer"))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
keywordID = &id
|
||||||
}
|
}
|
||||||
data, err := h.svc.ListQuestions(c.Request.Context(), brandID, keywordID)
|
|
||||||
|
page, err := parsePositiveIntQuery(c, "page", 1)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, response.ErrBadRequest(40001, "invalid_params", "page must be a positive integer"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user