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,
|
||||
QuestionCombinationRequest,
|
||||
QuestionDistillRequest,
|
||||
QuestionListResponse,
|
||||
QuestionRequest,
|
||||
QuotaSummary,
|
||||
RecentArticle,
|
||||
@@ -1139,9 +1140,12 @@ export const brandsApi = {
|
||||
removeKeyword(brandId: number, keywordId: number) {
|
||||
return apiClient.remove<null>(`/api/tenant/brands/${brandId}/keywords/${keywordId}`)
|
||||
},
|
||||
listQuestions(brandId: number, keywordId?: number | null) {
|
||||
return apiClient.get<Question[]>(`/api/tenant/brands/${brandId}/questions`, {
|
||||
params: keywordId ? { keyword_id: keywordId } : undefined,
|
||||
listQuestions(
|
||||
brandId: number,
|
||||
params?: { keyword_id?: number | null; page?: number; page_size?: number; q?: string },
|
||||
) {
|
||||
return apiClient.get<QuestionListResponse>(`/api/tenant/brands/${brandId}/questions`, {
|
||||
params,
|
||||
})
|
||||
},
|
||||
previewQuestionCombination(brandId: number, payload: QuestionCombinationRequest) {
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import type { Question } from '@geo/shared-types'
|
||||
import { computed, ref, watch, type Ref } from 'vue'
|
||||
|
||||
import { brandsApi } from './api'
|
||||
|
||||
const defaultQuestionPageSize = 20
|
||||
const questionListAllOptionValue = 'all'
|
||||
|
||||
export type QuestionSelectOption = {
|
||||
label: string
|
||||
value: number | string
|
||||
}
|
||||
|
||||
export function usePaginatedBrandQuestions(
|
||||
brandId: Ref<number | null | undefined>,
|
||||
options: {
|
||||
pageSize?: number
|
||||
includeAllOption?: boolean
|
||||
allOptionLabel?: () => string
|
||||
} = {},
|
||||
) {
|
||||
const pageSize = options.pageSize ?? defaultQuestionPageSize
|
||||
const searchText = ref('')
|
||||
const items = ref<Question[]>([])
|
||||
const selectedItems = ref<Question[]>([])
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const initialized = ref(false)
|
||||
const requestSeq = ref(0)
|
||||
let pendingLoad: Promise<void> | null = null
|
||||
|
||||
const hasMore = computed(() => items.value.length < total.value)
|
||||
|
||||
const questionOptions = computed<QuestionSelectOption[]>(() => {
|
||||
const map = new Map<number | string, QuestionSelectOption>()
|
||||
if (options.includeAllOption) {
|
||||
map.set(questionListAllOptionValue, {
|
||||
label: options.allOptionLabel?.() ?? 'All',
|
||||
value: questionListAllOptionValue,
|
||||
})
|
||||
}
|
||||
for (const item of [...selectedItems.value, ...items.value]) {
|
||||
map.set(item.id, {
|
||||
label: item.question_text,
|
||||
value: item.id,
|
||||
})
|
||||
}
|
||||
return Array.from(map.values())
|
||||
})
|
||||
|
||||
function loadPage(nextPage: number, mode: 'replace' | 'append'): Promise<void> {
|
||||
const currentBrandId = brandId.value
|
||||
if (!currentBrandId) {
|
||||
items.value = []
|
||||
total.value = 0
|
||||
page.value = 1
|
||||
initialized.value = true
|
||||
pendingLoad = null
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
const seq = ++requestSeq.value
|
||||
loading.value = true
|
||||
const loadPromise = (async () => {
|
||||
const result = await brandsApi.listQuestions(currentBrandId, {
|
||||
page: nextPage,
|
||||
page_size: pageSize,
|
||||
q: searchText.value.trim() || undefined,
|
||||
})
|
||||
if (seq !== requestSeq.value) {
|
||||
return
|
||||
}
|
||||
page.value = result.page
|
||||
total.value = result.total
|
||||
items.value = mode === 'append' ? mergeQuestions(items.value, result.items) : result.items
|
||||
initialized.value = true
|
||||
})()
|
||||
.finally(() => {
|
||||
if (seq === requestSeq.value) {
|
||||
loading.value = false
|
||||
}
|
||||
if (pendingLoad === loadPromise) {
|
||||
pendingLoad = null
|
||||
}
|
||||
})
|
||||
pendingLoad = loadPromise
|
||||
return loadPromise
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
void loadPage(1, 'replace')
|
||||
}
|
||||
|
||||
function search(value: string): void {
|
||||
searchText.value = String(value ?? '').trim()
|
||||
reset()
|
||||
}
|
||||
|
||||
function loadNextPage(): Promise<void> {
|
||||
if (loading.value || !hasMore.value) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return loadPage(page.value + 1, 'append')
|
||||
}
|
||||
|
||||
function handlePopupScroll(event: Event): void {
|
||||
const target = event.target as HTMLElement | null
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
if (target.scrollTop + target.clientHeight >= target.scrollHeight - 24) {
|
||||
loadNextPage()
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureQuestionLoadedById(id: number | null | undefined): Promise<Question | null> {
|
||||
if (!id) {
|
||||
return null
|
||||
}
|
||||
let existing = findQuestionById(id)
|
||||
if (existing) {
|
||||
rememberSelectedByIds([id])
|
||||
return existing
|
||||
}
|
||||
|
||||
const currentBrandId = brandId.value
|
||||
if (!currentBrandId) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (pendingLoad) {
|
||||
await pendingLoad
|
||||
existing = findQuestionById(id)
|
||||
if (existing) {
|
||||
rememberSelectedByIds([id])
|
||||
return existing
|
||||
}
|
||||
}
|
||||
|
||||
if (!initialized.value) {
|
||||
await loadPage(1, 'replace')
|
||||
}
|
||||
|
||||
existing = findQuestionById(id)
|
||||
while (!existing && hasMore.value && brandId.value === currentBrandId) {
|
||||
await loadNextPage()
|
||||
existing = findQuestionById(id)
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
rememberSelectedByIds([id])
|
||||
}
|
||||
return existing ?? null
|
||||
}
|
||||
|
||||
function rememberSelectedByIds(ids: Array<number | null | undefined>): void {
|
||||
const wanted = new Set(ids.filter((id): id is number => Number.isFinite(id ?? NaN)))
|
||||
if (!wanted.size) {
|
||||
return
|
||||
}
|
||||
selectedItems.value = mergeQuestions(
|
||||
selectedItems.value,
|
||||
items.value.filter((item) => wanted.has(item.id)),
|
||||
)
|
||||
}
|
||||
|
||||
function findQuestionById(id: number | null | undefined): Question | null {
|
||||
if (!id) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
items.value.find((item) => item.id === id) ??
|
||||
selectedItems.value.find((item) => item.id === id) ??
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => brandId.value,
|
||||
() => {
|
||||
searchText.value = ''
|
||||
selectedItems.value = []
|
||||
reset()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
return {
|
||||
hasMore,
|
||||
initialized,
|
||||
items,
|
||||
loading,
|
||||
options: questionOptions,
|
||||
search,
|
||||
searchText,
|
||||
handlePopupScroll,
|
||||
ensureQuestionLoadedById,
|
||||
findQuestionById,
|
||||
rememberSelectedByIds,
|
||||
refetch: reset,
|
||||
}
|
||||
}
|
||||
|
||||
function mergeQuestions(left: Question[], right: Question[]): Question[] {
|
||||
const map = new Map<number, Question>()
|
||||
for (const item of left) {
|
||||
map.set(item.id, item)
|
||||
}
|
||||
for (const item of right) {
|
||||
map.set(item.id, item)
|
||||
}
|
||||
return Array.from(map.values())
|
||||
}
|
||||
@@ -8,7 +8,12 @@ import {
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { ApiClientError } from '@geo/http-client'
|
||||
import type { BrandLibrarySummary, QuestionCandidate, QuestionSource } from '@geo/shared-types'
|
||||
import type {
|
||||
BrandLibrarySummary,
|
||||
Question,
|
||||
QuestionCandidate,
|
||||
QuestionSource,
|
||||
} from '@geo/shared-types'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { computed, onBeforeUnmount, reactive, ref } from 'vue'
|
||||
@@ -102,7 +107,7 @@ const brandLibrarySummaryQuery = useQuery({
|
||||
const questionsQuery = useQuery({
|
||||
queryKey: computed(() => ['brands', brandId.value, 'questions', 'all']),
|
||||
enabled: computed(() => brandId.value > 0),
|
||||
queryFn: () => brandsApi.listQuestions(brandId.value),
|
||||
queryFn: () => listAllBrandQuestions(brandId.value),
|
||||
})
|
||||
|
||||
const brandLibrarySummary = computed<BrandLibrarySummary | null>(
|
||||
@@ -444,6 +449,29 @@ function mergeQuestionCandidateResults(
|
||||
}
|
||||
}
|
||||
|
||||
async function listAllBrandQuestions(currentBrandId: number): Promise<Question[]> {
|
||||
const pageSize = 100
|
||||
const items: Question[] = []
|
||||
let page = 1
|
||||
let total = 0
|
||||
|
||||
do {
|
||||
const result = await brandsApi.listQuestions(currentBrandId, {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
})
|
||||
items.push(...result.items)
|
||||
total = result.total
|
||||
|
||||
if (items.length >= total || result.items.length === 0) {
|
||||
break
|
||||
}
|
||||
page += 1
|
||||
} while (true)
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
type ColumnKey = 'region' | 'prefix' | 'core' | 'industry' | 'suffix'
|
||||
|
||||
function handleInput(column: ColumnKey, index: number) {
|
||||
|
||||
@@ -63,6 +63,9 @@ const competitorForm = reactive({
|
||||
product_lines: '',
|
||||
})
|
||||
|
||||
const questionPage = ref(1)
|
||||
const questionPageSize = ref(10)
|
||||
|
||||
const brandListQuery = useQuery({
|
||||
queryKey: ['brands', 'list'],
|
||||
queryFn: () => brandsApi.list(),
|
||||
@@ -74,9 +77,19 @@ const brandLibrarySummaryQuery = useQuery({
|
||||
})
|
||||
|
||||
const questionsQuery = useQuery({
|
||||
queryKey: computed(() => ['brands', selectedBrandId.value, 'questions', 'all']),
|
||||
queryKey: computed(() => [
|
||||
'brands',
|
||||
selectedBrandId.value,
|
||||
'questions',
|
||||
questionPage.value,
|
||||
questionPageSize.value,
|
||||
]),
|
||||
enabled: computed(() => Boolean(selectedBrandId.value)),
|
||||
queryFn: () => brandsApi.listQuestions(selectedBrandId.value as number),
|
||||
queryFn: () =>
|
||||
brandsApi.listQuestions(selectedBrandId.value as number, {
|
||||
page: questionPage.value,
|
||||
page_size: questionPageSize.value,
|
||||
}),
|
||||
})
|
||||
|
||||
const competitorsQuery = useQuery({
|
||||
@@ -93,7 +106,8 @@ const selectedBrand = computed(
|
||||
() => brandListQuery.data.value?.find((item) => item.id === selectedBrandId.value) ?? null,
|
||||
)
|
||||
|
||||
const currentQuestions = computed(() => questionsQuery.data.value ?? [])
|
||||
const currentQuestions = computed(() => questionsQuery.data.value?.items ?? [])
|
||||
const questionTotal = computed(() => questionsQuery.data.value?.total ?? 0)
|
||||
|
||||
const maxQuestions = computed(
|
||||
() =>
|
||||
@@ -102,7 +116,7 @@ const maxQuestions = computed(
|
||||
0,
|
||||
)
|
||||
const usedQuestions = computed(
|
||||
() => brandLibrarySummary.value?.used_questions ?? currentQuestions.value.length,
|
||||
() => brandLibrarySummary.value?.used_questions ?? questionTotal.value,
|
||||
)
|
||||
|
||||
const brandLimitReached = computed(() => {
|
||||
@@ -308,6 +322,13 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch([questionTotal, questionPageSize], ([total, pageSize]) => {
|
||||
const maxPage = Math.max(1, Math.ceil(total / pageSize))
|
||||
if (questionPage.value > maxPage) {
|
||||
questionPage.value = maxPage
|
||||
}
|
||||
})
|
||||
|
||||
function parseProductLines(value: string): string[] | undefined {
|
||||
const lines = value
|
||||
.split(',')
|
||||
@@ -344,6 +365,7 @@ function selectBrand(brandId: number): void {
|
||||
return
|
||||
}
|
||||
selectedBrandId.value = brandId
|
||||
questionPage.value = 1
|
||||
void queryClient.invalidateQueries({ queryKey: ['brands'] })
|
||||
}
|
||||
|
||||
@@ -429,6 +451,11 @@ async function submitSingleQuestion(): Promise<void> {
|
||||
await questionMutations.createSingle.mutateAsync()
|
||||
}
|
||||
|
||||
function handleQuestionTableChange(nextPage: number, nextPageSize: number): void {
|
||||
questionPage.value = nextPage
|
||||
questionPageSize.value = nextPageSize
|
||||
}
|
||||
|
||||
async function submitCompetitor(): Promise<void> {
|
||||
if (!competitorForm.name.trim()) {
|
||||
return
|
||||
@@ -551,7 +578,13 @@ async function invalidateBrandQueries(): Promise<void> {
|
||||
:columns="questionColumns"
|
||||
:data-source="currentQuestions"
|
||||
:loading="questionsQuery.isPending.value"
|
||||
:pagination="{ pageSize: 10, showSizeChanger: false }"
|
||||
:pagination="{
|
||||
current: questionPage,
|
||||
pageSize: questionPageSize,
|
||||
total: questionTotal,
|
||||
showSizeChanger: false,
|
||||
onChange: handleQuestionTableChange,
|
||||
}"
|
||||
row-key="id"
|
||||
>
|
||||
<template #emptyText>{{ t('brands.empty.questions') }}</template>
|
||||
|
||||
@@ -7,8 +7,9 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import KnowledgeGroupSelect from '@/components/KnowledgeGroupSelect.vue'
|
||||
import { articlesApi, brandsApi } from '@/lib/api'
|
||||
import { articlesApi } from '@/lib/api'
|
||||
import { formatError } from '@/lib/errors'
|
||||
import { usePaginatedBrandQuestions } from '@/lib/use-paginated-brand-questions'
|
||||
import { useCompanyStore } from '@/stores/company'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -34,14 +35,10 @@ const selectedBrand = computed(() => companyStore.currentBrand)
|
||||
const selectedBrandId = computed(() => selectedBrand.value?.id ?? null)
|
||||
const currentBrandName = computed(() => selectedBrand.value?.name?.trim() || '')
|
||||
|
||||
const questionsQuery = useQuery({
|
||||
queryKey: computed(() => ['brands', selectedBrandId.value, 'questions', 'imitation']),
|
||||
enabled: computed(() => Boolean(selectedBrandId.value)),
|
||||
queryFn: () => brandsApi.listQuestions(selectedBrandId.value as number),
|
||||
})
|
||||
const questionList = usePaginatedBrandQuestions(selectedBrandId)
|
||||
|
||||
const keywordOptions = computed(() =>
|
||||
(questionsQuery.data.value ?? []).map((item) => ({
|
||||
questionList.items.value.map((item) => ({
|
||||
label: item.question_text,
|
||||
value: item.question_text,
|
||||
})),
|
||||
@@ -239,9 +236,14 @@ function submit(): void {
|
||||
<a-select
|
||||
v-model:value="form.keywords"
|
||||
mode="tags"
|
||||
show-search
|
||||
style="width: 100%"
|
||||
:filter-option="false"
|
||||
:loading="questionList.loading.value"
|
||||
:options="keywordOptions"
|
||||
:placeholder="t('imitation.create.keywordsPlaceholder')"
|
||||
@search="questionList.search"
|
||||
@popup-scroll="questionList.handlePopupScroll"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import KnowledgeGroupSelect from '@/components/KnowledgeGroupSelect.vue'
|
||||
import { articlesApi, brandsApi, normalizeInputParams, templatesApi } from '@/lib/api'
|
||||
import { getTemplateMeta } from '@/lib/display'
|
||||
import { formatError } from '@/lib/errors'
|
||||
import { usePaginatedBrandQuestions } from '@/lib/use-paginated-brand-questions'
|
||||
import { useCompanyStore } from '@/stores/company'
|
||||
|
||||
interface DraftCompetitor {
|
||||
@@ -159,6 +160,7 @@ let outlineNodeSeed = 0
|
||||
let allowWizardLeave = false
|
||||
let pendingLeavePromise: Promise<boolean> | null = null
|
||||
let pendingLeaveResolve: ((canLeave: boolean) => void) | null = null
|
||||
let selectedQuestionLoadSeq = 0
|
||||
const CUSTOM_OUTLINE_MAX_LENGTH = 15
|
||||
|
||||
const currentStep = ref(0)
|
||||
@@ -219,11 +221,7 @@ const draftArticleQuery = useQuery({
|
||||
queryFn: () => articlesApi.detail(draftArticleId.value as number),
|
||||
})
|
||||
|
||||
const questionsQuery = useQuery({
|
||||
queryKey: computed(() => ['brands', selectedBrandId.value, 'questions', 'wizard']),
|
||||
enabled: computed(() => enabled.value && Boolean(selectedBrandId.value)),
|
||||
queryFn: () => brandsApi.listQuestions(selectedBrandId.value as number),
|
||||
})
|
||||
const questionList = usePaginatedBrandQuestions(selectedBrandId)
|
||||
|
||||
const competitorsQuery = useQuery({
|
||||
queryKey: computed(() => ['brands', selectedBrandId.value, 'competitors']),
|
||||
@@ -283,18 +281,10 @@ const templateMeta = computed(() => {
|
||||
})
|
||||
|
||||
function findQuestionById(id: number | null): Question | null {
|
||||
if (!id) {
|
||||
return null
|
||||
}
|
||||
return questionsQuery.data.value?.find((item) => item.id === id) ?? null
|
||||
return questionList.findQuestionById(id)
|
||||
}
|
||||
|
||||
const questionOptions = computed(() =>
|
||||
(questionsQuery.data.value ?? []).map((item) => ({
|
||||
label: item.question_text,
|
||||
value: item.id,
|
||||
})),
|
||||
)
|
||||
const questionOptions = computed(() => questionList.options.value)
|
||||
const supplementalQuestionOptions = computed(() =>
|
||||
questionOptions.value.filter((item) => item.value !== primaryQuestionId.value),
|
||||
)
|
||||
@@ -635,15 +625,46 @@ watch(primaryQuestionId, (id) => {
|
||||
supplementalQuestionIds.value = supplementalQuestionIds.value.filter((item) => item !== id)
|
||||
})
|
||||
|
||||
watch(
|
||||
[primaryQuestionId, supplementalQuestionIds, () => questionList.items.value],
|
||||
([primaryId, supplementalIds]) => {
|
||||
questionList.rememberSelectedByIds([primaryId, ...supplementalIds])
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
[selectedBrandId, primaryQuestionId, supplementalQuestionIds],
|
||||
async ([brandId, primaryId, supplementalIds]) => {
|
||||
if (!brandId) {
|
||||
return
|
||||
}
|
||||
|
||||
const ids = [primaryId, ...supplementalIds].filter(
|
||||
(id): id is number => typeof id === 'number' && Number.isFinite(id) && id > 0,
|
||||
)
|
||||
if (!ids.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const seq = ++selectedQuestionLoadSeq
|
||||
for (const id of ids) {
|
||||
await questionList.ensureQuestionLoadedById(id)
|
||||
if (seq !== selectedQuestionLoadSeq || brandId !== selectedBrandId.value) {
|
||||
return
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(supplementalQuestionIds, (ids) => {
|
||||
const optionValues = supplementalQuestionOptions.value.map((item) => item.value)
|
||||
const allowedIds = new Set(optionValues)
|
||||
const shouldCheckOptions = optionValues.length > 0
|
||||
const normalized = ids.filter(
|
||||
(id, index) =>
|
||||
id !== primaryQuestionId.value &&
|
||||
ids.indexOf(id) === index &&
|
||||
(!shouldCheckOptions || allowedIds.has(id)),
|
||||
typeof id === 'number' &&
|
||||
Number.isFinite(id) &&
|
||||
id > 0,
|
||||
)
|
||||
const limited = normalized.slice(0, 3)
|
||||
if (limited.length !== ids.length || limited.some((id, index) => id !== ids[index])) {
|
||||
@@ -676,7 +697,7 @@ watch(
|
||||
|
||||
primaryQuestionId.value = null
|
||||
supplementalQuestionIds.value = []
|
||||
await questionsQuery.refetch()
|
||||
await questionList.refetch()
|
||||
|
||||
if (!showCompetitorsCard.value) {
|
||||
competitorDrafts.value = []
|
||||
@@ -2472,7 +2493,8 @@ function onStructureDragEnd(): void {
|
||||
allow-clear
|
||||
show-search
|
||||
:disabled="!selectedBrandId"
|
||||
:loading="questionsQuery.isPending.value"
|
||||
:filter-option="false"
|
||||
:loading="questionList.loading.value"
|
||||
:not-found-content="
|
||||
selectedBrandId
|
||||
? t('templates.wizard.hints.questionEmpty')
|
||||
@@ -2480,8 +2502,9 @@ function onStructureDragEnd(): void {
|
||||
"
|
||||
:options="questionOptions"
|
||||
:placeholder="t('templates.wizard.placeholders.primaryQuestion')"
|
||||
option-filter-prop="label"
|
||||
style="width: 100%"
|
||||
@search="questionList.search"
|
||||
@popup-scroll="questionList.handlePopupScroll"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2495,13 +2518,15 @@ function onStructureDragEnd(): void {
|
||||
mode="multiple"
|
||||
show-search
|
||||
:disabled="!selectedBrandId || !primaryQuestionId"
|
||||
:loading="questionsQuery.isPending.value"
|
||||
:filter-option="false"
|
||||
:loading="questionList.loading.value"
|
||||
:max-tag-count="3"
|
||||
:not-found-content="t('templates.wizard.hints.questionEmpty')"
|
||||
:options="supplementalQuestionOptions"
|
||||
:placeholder="t('templates.wizard.placeholders.supplementalQuestions')"
|
||||
option-filter-prop="label"
|
||||
style="width: 100%"
|
||||
@search="questionList.search"
|
||||
@popup-scroll="questionList.handlePopupScroll"
|
||||
/>
|
||||
<p class="field-helper">
|
||||
{{ t('templates.wizard.hints.supplementalQuestions') }}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { EyeOutlined, ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import {
|
||||
EyeOutlined,
|
||||
ReloadOutlined,
|
||||
QuestionCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
RightOutlined,
|
||||
SearchOutlined,
|
||||
FileTextOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import type {
|
||||
DesktopAccountInfo,
|
||||
MonitoringCitedArticle,
|
||||
@@ -17,7 +25,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import ArticleDetailDrawer from '@/components/ArticleDetailDrawer.vue'
|
||||
import PageHero from '@/components/PageHero.vue'
|
||||
import { brandsApi, monitoringApi, tenantAccountsApi } from '@/lib/api'
|
||||
import { monitoringApi, tenantAccountsApi } from '@/lib/api'
|
||||
import { resolveAccountCheckedAt, resolveAccountHealth } from '@/lib/desktop-account-runtime'
|
||||
import { formatDateTime } from '@/lib/display'
|
||||
import { formatError } from '@/lib/errors'
|
||||
@@ -26,6 +34,7 @@ import {
|
||||
normalizeMonitoringPlatformFilter,
|
||||
normalizeMonitoringPlatformId,
|
||||
} from '@/lib/monitoring-platforms'
|
||||
import { usePaginatedBrandQuestions } from '@/lib/use-paginated-brand-questions'
|
||||
import { useCompanyStore } from '@/stores/company'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -113,6 +122,8 @@ const hotQuestionPage = ref(1)
|
||||
const citedArticlePage = ref(1)
|
||||
const articleDrawerOpen = ref(false)
|
||||
const selectedArticleId = ref<number | null>(null)
|
||||
const missingRequestedQuestionId = ref<number | null>(null)
|
||||
let questionSelectionSeq = 0
|
||||
|
||||
selectedPlatformId.value = normalizeMonitoringPlatformFilter(selectedPlatformId.value)
|
||||
selectedCitationWindowDays.value = normalizeCitationWindowDays(selectedCitationWindowDays.value)
|
||||
@@ -128,43 +139,62 @@ const selectedQuestionSetOptionValue = computed<string | number>({
|
||||
const selectedBrandId = computed(() => companyStore.currentBrandId)
|
||||
const selectedBrand = computed(() => companyStore.currentBrand)
|
||||
|
||||
const questionsQuery = useQuery({
|
||||
queryKey: computed(() => ['tracking', 'questions', selectedBrandId.value]),
|
||||
enabled: computed(() => Boolean(selectedBrandId.value)),
|
||||
queryFn: () => brandsApi.listQuestions(selectedBrandId.value as number),
|
||||
const questionList = usePaginatedBrandQuestions(selectedBrandId, {
|
||||
includeAllOption: true,
|
||||
allOptionLabel: () => t('tracking.allQuestionSets'),
|
||||
})
|
||||
|
||||
const questionSetOptions = computed(() => [
|
||||
{ label: t('tracking.allQuestionSets'), value: allQuestionOptionValue },
|
||||
...(questionsQuery.data.value ?? []).map((item) => ({
|
||||
label: item.question_text,
|
||||
value: item.id,
|
||||
})),
|
||||
])
|
||||
const questionSetOptions = computed(() => questionList.options.value)
|
||||
|
||||
watch(
|
||||
() => questionsQuery.data.value,
|
||||
(questions) => {
|
||||
[selectedBrandId, () => route.query.question_id, selectedQuestionId],
|
||||
async ([brandId, requestedQuestionValue, questionId]) => {
|
||||
const requestedQuestionId = parseNumericQuery(requestedQuestionValue)
|
||||
const targetQuestionId = requestedQuestionId ?? questionId
|
||||
if (!brandId || !targetQuestionId) {
|
||||
missingRequestedQuestionId.value = null
|
||||
return
|
||||
}
|
||||
|
||||
if (requestedQuestionId && missingRequestedQuestionId.value === requestedQuestionId) {
|
||||
return
|
||||
}
|
||||
|
||||
const seq = ++questionSelectionSeq
|
||||
if (requestedQuestionId) {
|
||||
missingRequestedQuestionId.value = null
|
||||
}
|
||||
|
||||
const question = await questionList.ensureQuestionLoadedById(targetQuestionId)
|
||||
if (seq !== questionSelectionSeq || brandId !== selectedBrandId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (question) {
|
||||
selectedQuestionId.value = targetQuestionId
|
||||
return
|
||||
}
|
||||
|
||||
if (requestedQuestionId === targetQuestionId) {
|
||||
missingRequestedQuestionId.value = targetQuestionId
|
||||
}
|
||||
if (selectedQuestionId.value === targetQuestionId) {
|
||||
selectedQuestionId.value = null
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => route.query.question_id,
|
||||
(value) => {
|
||||
const requestedQuestionId = parseNumericQuery(route.query.question_id)
|
||||
if (!questions?.length) {
|
||||
if (!requestedQuestionId) {
|
||||
selectedQuestionId.value = null
|
||||
}
|
||||
if (missingRequestedQuestionId.value && missingRequestedQuestionId.value !== requestedQuestionId) {
|
||||
missingRequestedQuestionId.value = null
|
||||
}
|
||||
if (value || selectedBrandId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (requestedQuestionId && questions.some((item) => item.id === requestedQuestionId)) {
|
||||
selectedQuestionId.value = requestedQuestionId
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
selectedQuestionId.value &&
|
||||
questions.some((item) => item.id === selectedQuestionId.value)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
selectedQuestionId.value = null
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -198,11 +228,15 @@ watch(
|
||||
selectedQuestionId,
|
||||
selectedPlatformId,
|
||||
selectedBusinessDate,
|
||||
() => questionsQuery.data.value,
|
||||
missingRequestedQuestionId,
|
||||
],
|
||||
([brandId, questionId, platformId, businessDate, questions]) => {
|
||||
([brandId, questionId, platformId, businessDate, missingQuestionId]) => {
|
||||
const requestedQuestionId = parseNumericQuery(route.query.question_id)
|
||||
if (requestedQuestionId && !questionId && !Array.isArray(questions)) {
|
||||
if (
|
||||
requestedQuestionId &&
|
||||
questionId !== requestedQuestionId &&
|
||||
missingQuestionId !== requestedQuestionId
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -688,16 +722,6 @@ function openCitedArticle(item: MonitoringCitedArticle): void {
|
||||
articleDrawerOpen.value = true
|
||||
}
|
||||
|
||||
function filterQuestionOption(input: string, option?: { label?: string }): boolean {
|
||||
const keyword = input.trim().toLowerCase()
|
||||
if (!keyword) {
|
||||
return true
|
||||
}
|
||||
return String(option?.label ?? '')
|
||||
.toLowerCase()
|
||||
.includes(keyword)
|
||||
}
|
||||
|
||||
function formatPercent(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '--'
|
||||
@@ -705,6 +729,19 @@ function formatPercent(value: number | null | undefined): string {
|
||||
return `${(value * 100).toFixed(value < 0.1 ? 1 : 0)}%`
|
||||
}
|
||||
|
||||
function getMentionRateClass(rate: number | null | undefined): string {
|
||||
if (rate === null || rate === undefined || rate === 0) {
|
||||
return 'rate-zero'
|
||||
}
|
||||
if (rate <= 0.3) {
|
||||
return 'rate-low'
|
||||
}
|
||||
if (rate <= 0.7) {
|
||||
return 'rate-mid'
|
||||
}
|
||||
return 'rate-high'
|
||||
}
|
||||
|
||||
function normalizeCitationWindowDays(value: unknown): number {
|
||||
const numeric = Number(value)
|
||||
return numeric === 30 ? 30 : 7
|
||||
@@ -870,11 +907,15 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
v-model:value="selectedQuestionSetOptionValue"
|
||||
show-search
|
||||
class="tracking-select"
|
||||
option-filter-prop="label"
|
||||
:filter-option="filterQuestionOption"
|
||||
:not-found-content="t('tracking.noQuestions')"
|
||||
:filter-option="false"
|
||||
:loading="questionList.loading.value"
|
||||
:not-found-content="
|
||||
questionList.loading.value ? t('common.loading') : t('tracking.noQuestions')
|
||||
"
|
||||
:placeholder="t('tracking.keywordPlaceholder')"
|
||||
:options="questionSetOptions"
|
||||
@search="questionList.search"
|
||||
@popup-scroll="questionList.handlePopupScroll"
|
||||
/>
|
||||
</div>
|
||||
<div class="tracking-action-item">
|
||||
@@ -1239,12 +1280,16 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
<h3 class="panel-title">{{ t('tracking.hotQuestionsTitle') }}</h3>
|
||||
<span class="tracking-panel__helper">{{ t('tracking.hotQuestionsHint') }}</span>
|
||||
</div>
|
||||
<a-input-search
|
||||
<a-input
|
||||
v-model:value="hotQuestionSearch"
|
||||
allow-clear
|
||||
class="tracking-list-search"
|
||||
:placeholder="t('tracking.questionSearchPlaceholder')"
|
||||
/>
|
||||
>
|
||||
<template #prefix>
|
||||
<SearchOutlined class="search-icon" />
|
||||
</template>
|
||||
</a-input>
|
||||
</div>
|
||||
|
||||
<div v-if="hotQuestions.length" class="question-list">
|
||||
@@ -1255,15 +1300,38 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
class="question-list__item"
|
||||
@click="openQuestion(question)"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ question.question_text }}</strong>
|
||||
<p>
|
||||
{{ t('tracking.metrics.mentionRate') }} {{ formatPercent(question.mention_rate) }}
|
||||
</p>
|
||||
<div class="question-list__item-left">
|
||||
<div class="question-list__item-icon">
|
||||
<QuestionCircleOutlined />
|
||||
</div>
|
||||
<div class="question-list__item-content">
|
||||
<strong>{{ question.question_text }}</strong>
|
||||
<div class="mention-rate-container">
|
||||
<span class="mention-rate-label">{{ t('tracking.metrics.mentionRate') }}</span>
|
||||
<span class="mention-rate-badge" :class="getMentionRateClass(question.mention_rate)">
|
||||
{{ formatPercent(question.mention_rate) }}
|
||||
</span>
|
||||
<div class="mention-rate-track">
|
||||
<div
|
||||
class="mention-rate-bar"
|
||||
:style="{ width: formatPercent(question.mention_rate) }"
|
||||
:class="getMentionRateClass(question.mention_rate)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="question-list__right">
|
||||
<div class="question-list__time-wrap">
|
||||
<ClockCircleOutlined class="time-icon" />
|
||||
<span class="question-list__time">
|
||||
{{ question.last_sampled_at ? formatDateTime(question.last_sampled_at) : '--' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="question-list__arrow">
|
||||
<RightOutlined />
|
||||
</div>
|
||||
</div>
|
||||
<span class="question-list__time">
|
||||
{{ question.last_sampled_at ? formatDateTime(question.last_sampled_at) : '--' }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1303,29 +1371,45 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
:key="item.article_id"
|
||||
class="article-list__item"
|
||||
>
|
||||
<div class="article-list__content">
|
||||
<strong>{{ item.article_title }}</strong>
|
||||
<p>{{ item.publish_platform }}</p>
|
||||
<div class="article-list__item-left">
|
||||
<div class="article-list__item-icon">
|
||||
<FileTextOutlined />
|
||||
</div>
|
||||
<div class="article-list__item-content">
|
||||
<strong>{{ item.article_title }}</strong>
|
||||
<span class="article-platform-tag">{{ item.publish_platform }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-list__side">
|
||||
<div class="article-list__meta">
|
||||
<span>{{ item.citation_count }} {{ t('tracking.citations') }}</span>
|
||||
<span>
|
||||
{{ t('tracking.columns.share') }} {{ formatPercent(item.source_share) }}
|
||||
</span>
|
||||
<strong>{{ formatPercent(item.citation_rate) }}</strong>
|
||||
<div class="article-metric-group">
|
||||
<div class="article-metric">
|
||||
<span class="article-metric-val">{{ item.citation_count }}</span>
|
||||
<span class="article-metric-lbl">{{ t('tracking.citations') }}</span>
|
||||
</div>
|
||||
<div class="article-metric-divider"></div>
|
||||
<div class="article-metric">
|
||||
<span class="article-metric-val">{{ formatPercent(item.source_share) }}</span>
|
||||
<span class="article-metric-lbl">{{ t('tracking.columns.share') }}</span>
|
||||
</div>
|
||||
<div class="article-metric-divider"></div>
|
||||
<div class="article-metric accent">
|
||||
<span class="article-metric-val">{{ formatPercent(item.citation_rate) }}</span>
|
||||
<span class="article-metric-lbl">引用率</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-actions">
|
||||
<a-tooltip :title="t('tracking.viewArticleContent')">
|
||||
<a-button
|
||||
type="text"
|
||||
shape="circle"
|
||||
class="article-list__preview-button"
|
||||
:aria-label="t('tracking.viewArticleContent')"
|
||||
@click="openCitedArticle(item)"
|
||||
>
|
||||
<template #icon><EyeOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<a-tooltip :title="t('tracking.viewArticleContent')">
|
||||
<a-button
|
||||
type="text"
|
||||
shape="circle"
|
||||
class="article-list__preview-button"
|
||||
:aria-label="t('tracking.viewArticleContent')"
|
||||
@click="openCitedArticle(item)"
|
||||
>
|
||||
<template #icon><EyeOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1558,9 +1642,9 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
color: #111827;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -1569,10 +1653,10 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
height: 14px;
|
||||
background: #1f5cff;
|
||||
border-radius: 2px;
|
||||
margin-right: 8px;
|
||||
height: 16px;
|
||||
background: linear-gradient(180deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
border-radius: 4px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.tracking-panel__description {
|
||||
@@ -1582,13 +1666,54 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
}
|
||||
|
||||
.tracking-panel__helper {
|
||||
color: var(--muted);
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
margin-top: 4px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.tracking-list-search {
|
||||
width: min(100%, 260px);
|
||||
flex: 0 0 260px;
|
||||
.tracking-list-search.ant-input-affix-wrapper {
|
||||
background-color: #f3f4f6;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 20px;
|
||||
padding: 6px 14px;
|
||||
transition: all 0.25s ease;
|
||||
height: 38px;
|
||||
align-items: center;
|
||||
width: min(100%, 280px);
|
||||
flex: 0 0 280px;
|
||||
}
|
||||
|
||||
.tracking-list-search.ant-input-affix-wrapper:hover {
|
||||
background-color: #e5e7eb;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.tracking-list-search.ant-input-affix-wrapper-focused,
|
||||
.tracking-list-search.ant-input-affix-wrapper:focus {
|
||||
background-color: #ffffff;
|
||||
border-color: #1f5cff;
|
||||
box-shadow: 0 0 0 3px rgba(31, 92, 255, 0.1), 0 4px 12px rgba(31, 92, 255, 0.05);
|
||||
}
|
||||
|
||||
.tracking-list-search :deep(.ant-input) {
|
||||
background-color: transparent !important;
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.tracking-list-search .search-icon {
|
||||
color: #9ca3af;
|
||||
font-size: 15px;
|
||||
margin-right: 6px;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.tracking-list-search.ant-input-affix-wrapper-focused .search-icon {
|
||||
color: #1f5cff;
|
||||
}
|
||||
|
||||
.tracking-panel__empty {
|
||||
@@ -1890,32 +2015,76 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
border: 1px solid #f0f3fa;
|
||||
padding: 16px 20px;
|
||||
border: 1px solid #f0f2f5;
|
||||
border-radius: 12px;
|
||||
background: #f7f9fc;
|
||||
text-align: left;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.question-list__item:hover,
|
||||
.article-list__item:hover,
|
||||
.detail-citation-list__item:hover {
|
||||
background: #ffffff;
|
||||
border-color: #e5eaf2;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
text-align: left;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.02);
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.question-list__item {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.question-list__item:hover,
|
||||
.article-list__item:hover,
|
||||
.detail-citation-list__item:hover {
|
||||
background: #ffffff;
|
||||
border-color: rgba(31, 92, 255, 0.25);
|
||||
box-shadow: 0 10px 25px -5px rgba(31, 92, 255, 0.08), 0 8px 10px -6px rgba(31, 92, 255, 0.04);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.question-list__item-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.question-list__item-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
|
||||
color: #1f5cff;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.question-list__item:hover .question-list__item-icon {
|
||||
background: linear-gradient(135deg, #1f5cff 0%, #104df5 100%);
|
||||
color: #ffffff;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.question-list__item-content {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.question-list__item strong,
|
||||
.article-list__item strong,
|
||||
.detail-citation-list__item strong {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.question-list__item:hover strong {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.question-list__item p,
|
||||
@@ -1923,39 +2092,218 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
.detail-citation-list__item p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.question-list__time,
|
||||
.article-list__meta {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.article-list__meta {
|
||||
/* Mention Rate Badge & Progress Bar */
|
||||
.mention-rate-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.mention-rate-label {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.mention-rate-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.mention-rate-badge.rate-zero {
|
||||
background: #f3f4f6;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.mention-rate-badge.rate-low {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.mention-rate-badge.rate-mid {
|
||||
background: #fdf2f8;
|
||||
color: #be185d;
|
||||
}
|
||||
|
||||
.mention-rate-badge.rate-high {
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.mention-rate-track {
|
||||
width: 60px;
|
||||
height: 4px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mention-rate-bar {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.mention-rate-bar.rate-zero {
|
||||
background: #d1d5db;
|
||||
}
|
||||
|
||||
.mention-rate-bar.rate-low {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.mention-rate-bar.rate-mid {
|
||||
background: #ec4899;
|
||||
}
|
||||
|
||||
.mention-rate-bar.rate-high {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
/* Right-side alignment and arrows */
|
||||
.question-list__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.question-list__time-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 84px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.article-list__content {
|
||||
.time-icon {
|
||||
font-size: 13px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.question-list__time {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.question-list__arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #f3f4f6;
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.question-list__item:hover .question-list__arrow {
|
||||
background: #eff6ff;
|
||||
color: #1f5cff;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.article-list__item-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.article-list__content strong {
|
||||
overflow-wrap: anywhere;
|
||||
.article-list__item-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
|
||||
color: #16a34a;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.article-list__item:hover .article-list__item-icon {
|
||||
background: linear-gradient(135deg, #16a34a 0%, #15803d 100%);
|
||||
color: #ffffff;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.article-list__item-content {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.article-platform-tag {
|
||||
display: inline-block;
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #4b5563;
|
||||
background: #f3f4f6;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.article-list__side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 0 0 auto;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.article-metric-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.article-metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.article-metric-val {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.article-metric-lbl {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.article-metric.accent .article-metric-val {
|
||||
color: #1f5cff;
|
||||
}
|
||||
|
||||
.article-metric-divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.article-actions {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.article-list__preview-button {
|
||||
color: #4b5563;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.article-list__preview-button:hover {
|
||||
@@ -1966,7 +2314,28 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
.tracking-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.tracking-pagination :deep(.ant-pagination-item) {
|
||||
border-radius: 6px;
|
||||
border-color: #e5e7eb;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tracking-pagination :deep(.ant-pagination-item-active) {
|
||||
background-color: #1f5cff;
|
||||
border-color: #1f5cff;
|
||||
}
|
||||
|
||||
.tracking-pagination :deep(.ant-pagination-item-active a) {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.tracking-pagination :deep(.ant-pagination-prev .ant-pagination-item-link),
|
||||
.tracking-pagination :deep(.ant-pagination-next .ant-pagination-item-link) {
|
||||
border-radius: 6px;
|
||||
border-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.detail-tabs {
|
||||
@@ -2062,13 +2431,32 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.question-list__item-left {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.question-list__right {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.question-list__time {
|
||||
align-self: flex-end;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.article-list__item-left {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.article-list__side {
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.article-metric-group {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tracking-citation-header {
|
||||
|
||||
Reference in New Issue
Block a user