Files
geo/apps/admin-web/src/views/TrackingView.vue
T
root aa96143754 style: format web apps with prettier and sort imports
Apply repo-wide Prettier/lint normalization across admin-web,
desktop-client and ops-web: single quotes, no semicolons, trailing
commas, consistent line wrapping, and import ordering. Also drop an
unused brand-logo import in DesktopShell.vue.

No behavior changes — formatting only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 11:56:18 +08:00

1936 lines
52 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ReloadOutlined } from '@ant-design/icons-vue'
import type {
DesktopAccountInfo,
MonitoringHotQuestion,
MonitoringPlatformAuthorizationStatus,
} from '@geo/shared-types'
import { aiPlatformCatalog } from '@geo/shared-types'
import { useMutation, useQuery } from '@tanstack/vue-query'
import { useStorage } from '@vueuse/core'
import { message } from 'ant-design-vue'
import dayjs from 'dayjs'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import PageHero from '@/components/PageHero.vue'
import { brandsApi, monitoringApi, tenantAccountsApi } from '@/lib/api'
import { resolveAccountCheckedAt, resolveAccountHealth } from '@/lib/desktop-account-runtime'
import { formatDateTime } from '@/lib/display'
import { formatError } from '@/lib/errors'
import {
isMonitoringPlatformId,
normalizeMonitoringPlatformFilter,
normalizeMonitoringPlatformId,
} from '@/lib/monitoring-platforms'
import { useCompanyStore } from '@/stores/company'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const companyStore = useCompanyStore()
const trackingMaxHistoryDays = 30
const trackingBusinessTimeZone = 'Asia/Shanghai'
const trackingToday = formatTrackingBusinessDate(new Date())
type CitationRankingRow = {
ai_platform_id: string
platform_name: string
cited_answer_count: number | null
cited_article_count: number | null
citation_source_count: number | null
saas_source_count: number | null
saas_source_rate: number | null
citation_rate: number | null
share: number
color: string
surface: string
glyph: string
}
type AIPlatformStatusCard = {
id: string
label: string
shortName: string
accent: string
description: string
account: DesktopAccountInfo | null
isClientOnline: boolean
sampleStatus: string
lastSampledAt: string | null
actualSampleCount: number
}
function formatTrackingBusinessDate(value: Date): string {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: trackingBusinessTimeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(value)
const year = parts.find((part) => part.type === 'year')?.value
const month = parts.find((part) => part.type === 'month')?.value
const day = parts.find((part) => part.type === 'day')?.value
if (!year || !month || !day) {
throw new Error('tracking_business_date_format_failed')
}
return `${year}-${month}-${day}`
}
const platformAppearanceMap: Record<string, { color: string; surface: string; glyph: string }> = {
doubao: { color: '#ff9348', surface: '#fff2e8', glyph: '豆' },
deepseek: { color: '#5d72ff', surface: '#eef1ff', glyph: 'D' },
qwen: { color: '#276ef1', surface: '#edf4ff', glyph: '通' },
wenxin: { color: '#20c6d7', surface: '#ebfcff', glyph: '文' },
yuanbao: { color: '#19bf6b', surface: '#eafbf1', glyph: '元' },
kimi: { color: '#111827', surface: '#f3f4f6', glyph: 'K' },
}
const trackingPlatformOptions = [
{ label: '全部', value: 'all' },
...aiPlatformCatalog.map((platform) => ({
label: platform.label,
value: platform.id,
})),
] as const
const allQuestionOptionValue = 'all'
const selectedQuestionId = useStorage<number | null>('tracking_selected_question', null)
const selectedPlatformId = useStorage<string>('tracking_selected_ai_platform_id', 'all')
const selectedBusinessDate = ref<string>(
normalizeTrackingBusinessDate(route.query.business_date) ?? trackingToday,
)
const selectedCitationWindowDays = useStorage<number>('tracking_selected_citation_window_days', 7)
selectedPlatformId.value = normalizeMonitoringPlatformFilter(selectedPlatformId.value)
selectedCitationWindowDays.value = normalizeCitationWindowDays(selectedCitationWindowDays.value)
const selectedQuestionSetOptionValue = computed<string | number>({
get: () => selectedQuestionId.value ?? allQuestionOptionValue,
set: (value) => {
selectedQuestionId.value =
typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null
},
})
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 questionSetOptions = computed(() => [
{ label: t('tracking.allQuestionSets'), value: allQuestionOptionValue },
...(questionsQuery.data.value ?? []).map((item) => ({
label: item.question_text,
value: item.id,
})),
])
watch(
() => questionsQuery.data.value,
(questions) => {
const requestedQuestionId = parseNumericQuery(route.query.question_id)
if (!questions?.length) {
if (!requestedQuestionId) {
selectedQuestionId.value = null
}
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 },
)
watch(selectedBrandId, (newId, oldId) => {
if (oldId && newId !== oldId) {
selectedQuestionId.value = null
}
})
watch(
() => route.query.business_date,
(value) => {
selectedBusinessDate.value = normalizeTrackingBusinessDate(value) ?? trackingToday
},
{ immediate: true },
)
watch(
() => route.query.ai_platform_id,
(value) => {
selectedPlatformId.value = normalizeMonitoringPlatformFilter(value)
},
{ immediate: true },
)
watch(
[
selectedBrandId,
selectedQuestionId,
selectedPlatformId,
selectedBusinessDate,
() => questionsQuery.data.value,
],
([brandId, questionId, platformId, businessDate, questions]) => {
const requestedQuestionId = parseNumericQuery(route.query.question_id)
if (requestedQuestionId && !questionId && !Array.isArray(questions)) {
return
}
const nextBrandId = brandId ? String(brandId) : undefined
const nextQuestionId = questionId ? String(questionId) : undefined
const nextPlatformId = platformId !== 'all' ? platformId : undefined
const nextBusinessDate = normalizeTrackingBusinessDate(businessDate) ?? trackingToday
const currentBrandId = normalizeQueryValue(route.query.brand_id) || undefined
const currentQuestionId = normalizeQueryValue(route.query.question_id) || undefined
const currentPlatformId = normalizeMonitoringPlatformFilter(route.query.ai_platform_id)
const currentBusinessDate =
normalizeTrackingBusinessDate(route.query.business_date) ?? trackingToday
const hasLegacyCitationDaysQuery = normalizeQueryValue(route.query.citation_days) !== ''
if (
currentBrandId === nextBrandId &&
currentQuestionId === nextQuestionId &&
currentPlatformId === (nextPlatformId ?? 'all') &&
currentBusinessDate === nextBusinessDate &&
!hasLegacyCitationDaysQuery
) {
return
}
void router.replace({
name: 'tracking',
query: {
...(nextBrandId ? { brand_id: nextBrandId } : {}),
...(nextQuestionId ? { question_id: nextQuestionId } : {}),
...(nextPlatformId ? { ai_platform_id: nextPlatformId } : {}),
business_date: nextBusinessDate,
},
})
},
)
const dashboardQuery = useQuery({
queryKey: computed(() => [
'tracking',
'dashboard',
selectedBrandId.value,
selectedQuestionId.value,
selectedPlatformId.value,
selectedBusinessDate.value,
]),
enabled: computed(() => Boolean(selectedBrandId.value)),
queryFn: () =>
monitoringApi.dashboardComposite({
brand_id: selectedBrandId.value ?? undefined,
question_id: selectedQuestionId.value,
days: trackingMaxHistoryDays,
ai_platform_id: selectedPlatformId.value !== 'all' ? selectedPlatformId.value : undefined,
business_date: selectedBusinessDate.value,
}),
})
const citationSummaryQuery = useQuery({
queryKey: computed(() => [
'tracking',
'citation-summary',
selectedBrandId.value,
selectedQuestionId.value,
selectedPlatformId.value,
selectedBusinessDate.value,
selectedCitationWindowDays.value,
]),
enabled: computed(() => Boolean(selectedBrandId.value)),
queryFn: () =>
monitoringApi.citationSummary({
brand_id: selectedBrandId.value ?? undefined,
question_id: selectedQuestionId.value,
business_date: selectedBusinessDate.value,
ai_platform_id: selectedPlatformId.value !== 'all' ? selectedPlatformId.value : undefined,
days: selectedCitationWindowDays.value,
}),
})
const aiAccountsQuery = useQuery({
queryKey: ['tracking', 'ai-platform-accounts'],
queryFn: () => tenantAccountsApi.list(),
})
const collectNowMutation = useMutation({
mutationFn: async () => {
if (!selectedBrandId.value) {
throw new Error('tracking_brand_required')
}
return monitoringApi.collectNow(selectedBrandId.value, {
question_id: selectedQuestionId.value ?? undefined,
platform_ids: selectedPlatformId.value !== 'all' ? [selectedPlatformId.value] : undefined,
})
},
onSuccess: (collectNowResult) => {
message.success(collectNowResult.message)
void dashboardQuery.refetch()
},
onError: (error) => {
if (error instanceof Error && error.message === 'tracking_brand_required') {
message.warning(t('tracking.collectBrandRequired'))
return
}
message.error(formatError(error))
},
})
const currentUserClientOnline = computed(() => {
return dashboardQuery.data.value?.runtime.current_user_client_online === true
})
const platformBreakdownRows = computed(() => {
return dashboardQuery.data.value?.platform_breakdown ?? []
})
const platformAuthorizationStatus = computed<MonitoringPlatformAuthorizationStatus>(() => {
return dashboardQuery.data.value?.runtime.platform_authorization_status ?? 'authorized'
})
const selectedPlatformAuthorized = computed(() => {
if (selectedPlatformId.value === 'all') {
return true
}
const authorizedPlatformIDs =
dashboardQuery.data.value?.runtime.authorized_monitoring_platform_ids ?? []
return authorizedPlatformIDs.some(
(item) => normalizeMonitoringPlatformId(item) === selectedPlatformId.value,
)
})
const selectedPlatformOnlineAuthorized = computed(() => {
const onlinePlatformIDs =
dashboardQuery.data.value?.runtime.online_authorized_monitoring_platform_ids ?? []
if (selectedPlatformId.value === 'all') {
return onlinePlatformIDs.length > 0
}
return onlinePlatformIDs.some(
(item) => normalizeMonitoringPlatformId(item) === selectedPlatformId.value,
)
})
const collectNowDisabledReason = computed(() => {
if (!selectedBrandId.value) {
return t('tracking.collectBrandRequired')
}
if (selectedBusinessDate.value !== trackingToday) {
return t('tracking.collectTodayOnly')
}
if (dashboardQuery.isLoading.value && !dashboardQuery.data.value) {
return t('tracking.collectClientChecking')
}
if (platformAuthorizationStatus.value === 'no_desktop_client') {
return t('tracking.noDesktopClientCollectReason')
}
if (platformAuthorizationStatus.value === 'no_authorized_platforms') {
return t('tracking.noAuthorizedPlatformsCollectReason')
}
if (
selectedPlatformId.value !== 'all' &&
dashboardQuery.data.value &&
!selectedPlatformAuthorized.value
) {
return t('tracking.collectPlatformUnauthorized')
}
if (!currentUserClientOnline.value) {
return t('tracking.collectClientOnlineRequired')
}
if (dashboardQuery.data.value && !selectedPlatformOnlineAuthorized.value) {
return t('tracking.collectClientOnlineRequired')
}
return null
})
const selectedDateHasData = computed(() => {
return (dashboardQuery.data.value?.overview.actual_sample_count ?? 0) > 0
})
const hotQuestions = computed(() => {
return dashboardQuery.data.value?.hot_questions ?? []
})
const aiPlatformStatusCards = computed<AIPlatformStatusCard[]>(() => {
const accountGroups = new Map<string, DesktopAccountInfo[]>()
for (const account of aiAccountsQuery.data.value ?? []) {
const platformId = normalizeMonitoringPlatformId(account.platform)
if (!isMonitoringPlatformId(platformId) || account.deleted_at) {
continue
}
const list = accountGroups.get(platformId) ?? []
list.push(account)
accountGroups.set(platformId, list)
}
const breakdownMap = new Map(
(dashboardQuery.data.value?.platform_breakdown ?? []).map((item) => [
normalizeMonitoringPlatformId(item.ai_platform_id),
item,
]),
)
const onlineAuthorizedPlatformIDs = new Set(
(dashboardQuery.data.value?.runtime.online_authorized_monitoring_platform_ids ?? [])
.map((item) => normalizeMonitoringPlatformId(item))
.filter((item): item is string => Boolean(item)),
)
return aiPlatformCatalog.map((platform) => {
const matchedAccounts = [...(accountGroups.get(platform.id) ?? [])].sort((left, right) => {
const leftOnline = left.client_online === true ? 1 : 0
const rightOnline = right.client_online === true ? 1 : 0
if (leftOnline !== rightOnline) {
return rightOnline - leftOnline
}
const leftAt = Date.parse(resolveAccountCheckedAt(left) ?? '') || 0
const rightAt = Date.parse(resolveAccountCheckedAt(right) ?? '') || 0
return rightAt - leftAt
})
const breakdown = breakdownMap.get(platform.id)
return {
id: platform.id,
label: platform.label,
shortName: platform.shortName,
accent: platform.accent,
description: platform.description,
account: matchedAccounts[0] ?? null,
isClientOnline:
onlineAuthorizedPlatformIDs.has(platform.id) ||
(!dashboardQuery.data.value &&
matchedAccounts.some((account) => account.client_online === true)),
sampleStatus: breakdown?.platform_sample_status ?? 'pending',
lastSampledAt: breakdown?.last_sampled_at ?? null,
actualSampleCount: breakdown?.actual_sample_count ?? 0,
}
})
})
const selectedMetricCards = computed(() => {
const overview = dashboardQuery.data.value?.overview
return [
{
key: 'mention',
title: t('tracking.metrics.mentionRate'),
value: overview?.mention_rate ?? null,
previous: overview?.prev_snapshot_mention_rate ?? null,
},
{
key: 'top1',
title: t('tracking.metrics.top1MentionRate'),
value: overview?.top1_mention_rate ?? null,
previous: overview?.prev_snapshot_top1_mention_rate ?? null,
},
{
key: 'first',
title: t('tracking.metrics.firstRecommendRate'),
value: overview?.first_recommend_rate ?? null,
previous: overview?.prev_snapshot_first_recommend_rate ?? null,
},
{
key: 'positive',
title: t('tracking.metrics.positiveMentionRate'),
value: overview?.positive_mention_rate ?? null,
previous: overview?.prev_snapshot_positive_mention_rate ?? null,
},
]
})
const citationWindowOptions = computed(() => [
{ label: t('tracking.citationWindow7'), value: 7 },
{ label: t('tracking.citationWindow30'), value: 30 },
])
const citationRankingRows = computed<CitationRankingRow[]>(() => {
const ranking = citationSummaryQuery.data.value?.citation_ranking ?? []
const order = new Map<string, number>()
const rows = new Map<string, CitationRankingRow>()
aiPlatformCatalog.forEach((item, index) => {
const platformId = normalizeMonitoringPlatformId(item.id) || item.id
order.set(platformId, index)
rows.set(platformId, {
ai_platform_id: platformId,
platform_name: item.label,
cited_answer_count: null,
cited_article_count: null,
citation_source_count: null,
saas_source_count: null,
saas_source_rate: null,
citation_rate: null,
share: 0,
...platformAppearance(platformId, item.label),
})
})
ranking.forEach((item, index) => {
const platformId = normalizeMonitoringPlatformId(item.ai_platform_id) || item.ai_platform_id
if (!order.has(platformId)) {
order.set(platformId, aiPlatformCatalog.length + index)
}
const existing = rows.get(platformId)
rows.set(platformId, {
ai_platform_id: platformId,
platform_name: item.platform_name || existing?.platform_name || platformId,
cited_answer_count: (existing?.cited_answer_count ?? 0) + (item.cited_answer_count ?? 0),
cited_article_count: (existing?.cited_article_count ?? 0) + (item.cited_article_count ?? 0),
citation_source_count:
(existing?.citation_source_count ?? 0) + (item.citation_source_count ?? 0),
saas_source_count: (existing?.saas_source_count ?? 0) + (item.saas_source_count ?? 0),
saas_source_rate: item.saas_source_rate ?? existing?.saas_source_rate ?? null,
citation_rate: item.citation_rate ?? existing?.citation_rate ?? null,
share: 0,
...platformAppearance(
platformId,
item.platform_name || existing?.platform_name || platformId,
),
})
})
const list = Array.from(rows.values())
const totalAppearances = list.reduce(
(sum, item) => sum + Math.max(item.saas_source_count ?? item.cited_answer_count ?? 0, 0),
0,
)
const enriched = list.map((item) => ({
...item,
share:
totalAppearances > 0
? Math.max(item.saas_source_count ?? item.cited_answer_count ?? 0, 0) / totalAppearances
: 0,
}))
const hasCitationData = enriched.some(
(item) => (item.saas_source_count ?? item.cited_answer_count ?? 0) > 0,
)
return enriched.sort((left, right) => {
if (hasCitationData) {
const countDiff =
(right.saas_source_count ?? right.cited_answer_count ?? 0) -
(left.saas_source_count ?? left.cited_answer_count ?? 0)
if (countDiff !== 0) {
return countDiff
}
}
return (order.get(left.ai_platform_id) ?? 0) - (order.get(right.ai_platform_id) ?? 0)
})
})
const visibleCitedArticles = computed(() => {
return citationSummaryQuery.data.value?.cited_articles ?? []
})
const citationPieSlices = computed(() => {
const segments = citationRankingRows.value.filter(
(item) => (item.saas_source_count ?? item.cited_answer_count ?? 0) > 0,
)
if (!segments.length) {
return []
}
const cx = 100
const cy = 100
const r = 100
let offset = 0
return segments.map((item) => {
const share = item.share
if (share === 1) {
return {
...item,
isFull: true,
path: '',
}
}
const startAngle = offset * 2 * Math.PI - Math.PI / 2
const endAngle = (offset + share) * 2 * Math.PI - Math.PI / 2
const x1 = cx + r * Math.cos(startAngle)
const y1 = cy + r * Math.sin(startAngle)
const x2 = cx + r * Math.cos(endAngle)
const y2 = cy + r * Math.sin(endAngle)
const largeArcFlag = share > 0.5 ? 1 : 0
const path = `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArcFlag} 1 ${x2} ${y2} Z`
offset += share
return {
...item,
isFull: false,
path,
}
})
})
const hoveredSlice = ref<string | null>(null)
const tooltipPos = ref({ x: 0, y: 0 })
function onPieMouseMove(e: MouseEvent) {
const target = e.currentTarget as HTMLElement
if (!target) return
const rect = target.getBoundingClientRect()
tooltipPos.value = {
x: e.clientX - rect.left + 15,
y: e.clientY - rect.top + 15,
}
}
const hoveredSliceInfo = computed(() => {
if (!hoveredSlice.value) return null
return citationPieSlices.value.find((s) => s.ai_platform_id === hoveredSlice.value) ?? null
})
const citationChartStyle = computed(() => {
return {
background: 'linear-gradient(180deg, #eef4ff 0%, #e5ecff 100%)',
}
})
const heroDescription = computed(() => {
const overview = dashboardQuery.data.value?.overview
if (!overview?.last_sampled_at) {
return `${selectedBusinessDate.value} 暂无抓取数据`
}
return `最近一次采集数据:${formatDateTime(overview.last_sampled_at)}`
})
function openQuestion(question: MonitoringHotQuestion): void {
if (!selectedBrandId.value) {
return
}
void router.push({
name: 'tracking-question-detail',
params: {
brandId: String(selectedBrandId.value),
questionId: String(question.question_id),
},
query: {
business_date: selectedBusinessDate.value,
...(selectedPlatformId.value !== 'all' ? { ai_platform_id: selectedPlatformId.value } : {}),
},
state: {
tracking_question_hash: question.question_hash,
tracking_question_text: question.question_text,
},
})
}
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 '--'
}
return `${(value * 100).toFixed(value < 0.1 ? 1 : 0)}%`
}
function normalizeCitationWindowDays(value: unknown): number {
const numeric = Number(value)
return numeric === 30 ? 30 : 7
}
function accountAuthLabel(account: DesktopAccountInfo | null): string {
if (!account) {
return '未绑定'
}
switch (resolveAccountHealth(account)) {
case 'live':
return '授权正常'
case 'captcha':
return '待处理'
case 'expired':
return '授权过期'
default:
return '风险观察'
}
}
function accountAuthColor(account: DesktopAccountInfo | null): string {
if (!account) {
return 'default'
}
switch (resolveAccountHealth(account)) {
case 'live':
return 'green'
case 'captcha':
return 'gold'
case 'expired':
return 'red'
default:
return 'orange'
}
}
function desktopStatusLabel(account: DesktopAccountInfo | null, isClientOnline: boolean): string {
if (!account?.client_id) {
return '未绑定桌面端'
}
return isClientOnline ? '客户端在线' : '客户端离线'
}
function formatCount(value: number | null | undefined): string {
if (value === null || value === undefined) {
return '--'
}
return String(value)
}
function formatDelta(
current: number | null | undefined,
previous: number | null | undefined,
): string | null {
if (current === null || current === undefined || previous === null || previous === undefined) {
return null
}
const diff = current - previous
if (Math.abs(diff) < 0.001) {
return t('tracking.noChange')
}
const prefix = diff > 0 ? '+' : ''
return `${prefix}${(diff * 100).toFixed(1)}%`
}
function statusTagColor(status: string): string {
switch (status) {
case 'sampled':
return 'green'
case 'pending':
return 'gold'
case 'not_logged_in':
return 'default'
case 'unavailable':
return 'red'
default:
return 'default'
}
}
function statusLabel(status: string): string {
return t(`tracking.status.${status}`)
}
function platformAppearance(aiPlatformId: string, platformName: string) {
const normalizedId =
normalizeMonitoringPlatformId(aiPlatformId) ||
aiPlatformId.toLowerCase().replace(/[^a-z0-9]/g, '')
const matched = platformAppearanceMap[normalizedId]
if (matched) {
return matched
}
const glyph = platformName.trim().slice(0, 1).toUpperCase() || '?'
return {
color: '#f5b13d',
surface: '#fff7e8',
glyph,
}
}
function parseNumericQuery(value: unknown): number | null {
const raw = Array.isArray(value) ? value[0] : value
const parsed = Number(raw)
if (!Number.isFinite(parsed) || parsed <= 0) {
return null
}
return parsed
}
function normalizeQueryValue(value: unknown): string {
return String(Array.isArray(value) ? (value[0] ?? '') : (value ?? '')).trim()
}
function normalizeTrackingBusinessDate(value: unknown): string | null {
const normalized = normalizeQueryValue(value)
if (!normalized) {
return null
}
const parsed = dayjs(normalized)
if (!parsed.isValid() || parsed.format('YYYY-MM-DD') !== normalized) {
return null
}
const today = dayjs(trackingToday).startOf('day')
const earliestAllowed = today.subtract(trackingMaxHistoryDays - 1, 'day')
if (parsed.isAfter(today, 'day') || parsed.isBefore(earliestAllowed, 'day')) {
return null
}
return normalized
}
function disableTrackingDate(current: dayjs.Dayjs): boolean {
const today = dayjs(trackingToday).endOf('day')
const earliestAllowed = dayjs(trackingToday)
.subtract(trackingMaxHistoryDays - 1, 'day')
.startOf('day')
return current.isAfter(today) || current.isBefore(earliestAllowed)
}
</script>
<template>
<section class="tracking-view">
<PageHero :title="t('route.tracking.title')" :description="heroDescription">
<template #actions>
<a-space wrap align="center" class="tracking-actions-wrap">
<div class="tracking-action-item">
<span class="tracking-action-label">平台</span>
<a-select
v-model:value="selectedPlatformId"
class="tracking-select"
:options="trackingPlatformOptions"
/>
</div>
<div class="tracking-action-item">
<span class="tracking-action-label">关键词库搜索词</span>
<a-select
v-model:value="selectedQuestionSetOptionValue"
show-search
class="tracking-select"
option-filter-prop="label"
:filter-option="filterQuestionOption"
:not-found-content="t('tracking.noQuestions')"
:placeholder="t('tracking.keywordPlaceholder')"
:options="questionSetOptions"
/>
</div>
<div class="tracking-action-item">
<span class="tracking-action-label">日期</span>
<a-date-picker
v-model:value="selectedBusinessDate"
class="tracking-date-picker"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
:allow-clear="false"
:disabled-date="disableTrackingDate"
/>
</div>
<a-tooltip :title="collectNowDisabledReason ?? undefined">
<span>
<a-button
type="primary"
:disabled="Boolean(collectNowDisabledReason)"
:loading="collectNowMutation.isPending.value"
@click="collectNowMutation.mutate()"
>
<template #icon><ReloadOutlined /></template>
{{ t('tracking.collectNow') }}
</a-button>
</span>
</a-tooltip>
</a-space>
</template>
</PageHero>
<a-alert
v-if="dashboardQuery.error.value"
type="error"
show-icon
:message="formatError(dashboardQuery.error.value)"
class="tracking-view__alert"
/>
<div class="tracking-grid tracking-grid--top">
<a-card
class="tracking-panel tracking-panel--overview"
:loading="dashboardQuery.isLoading.value"
>
<div class="tracking-panel__header">
<h3 class="panel-title">{{ selectedBrand?.name ?? t('common.noData') }}</h3>
<a-tag
:color="
selectedDateHasData
? dashboardQuery.data.value?.overview.confidence_level === 'high'
? 'green'
: dashboardQuery.data.value?.overview.confidence_level === 'medium'
? 'gold'
: 'default'
: 'default'
"
>
{{
selectedDateHasData
? t(
`tracking.confidence.${dashboardQuery.data.value?.overview.confidence_level ?? 'low'}`,
)
: '暂无抓取'
}}
</a-tag>
</div>
<div class="metric-grid tracking-metric-grid">
<article v-for="card in selectedMetricCards" :key="card.key" class="metric-card">
<p class="metric-card__label">{{ card.title }}</p>
<div class="metric-card__value-row">
<strong>{{ formatPercent(card.value) }}</strong>
<span
v-if="formatDelta(card.value, card.previous)"
:class="[
'metric-card__delta',
(card.value ?? 0) >= (card.previous ?? 0)
? 'metric-card__delta--up'
: 'metric-card__delta--down',
]"
>
{{ formatDelta(card.value, card.previous) }}
</span>
</div>
</article>
</div>
</a-card>
<a-card class="tracking-panel" :loading="dashboardQuery.isLoading.value">
<div class="tracking-panel__header">
<h3 class="panel-title">{{ t('tracking.platformMatrixTitle') }}</h3>
</div>
<div v-if="platformBreakdownRows.length" class="platform-table">
<div class="platform-table__head">
<span>{{ t('tracking.columns.platform') }}</span>
<span>{{ t('tracking.metrics.mentionRate') }}</span>
<span>{{ t('tracking.metrics.top1MentionRate') }}</span>
<span>{{ t('common.status') }}</span>
</div>
<div
v-for="item in platformBreakdownRows"
:key="item.ai_platform_id"
class="platform-table__row"
>
<div class="platform-table__platform">
<strong>{{ item.platform_name }}</strong>
<span>{{ item.actual_sample_count }} {{ t('tracking.samples') }}</span>
</div>
<span>
{{
item.platform_sample_status === 'sampled' ? formatPercent(item.mention_rate) : '--'
}}
</span>
<span>
{{
item.platform_sample_status === 'sampled'
? formatPercent(item.top1_mention_rate)
: '--'
}}
</span>
<a-tag :color="statusTagColor(item.platform_sample_status)">
{{ statusLabel(item.platform_sample_status) }}
</a-tag>
</div>
</div>
<a-empty
v-else
:description="t('tracking.noPlatformBreakdown')"
class="tracking-panel__empty"
/>
</a-card>
</div>
<a-card
class="tracking-panel tracking-panel--ai-platforms"
:loading="dashboardQuery.isLoading.value || aiAccountsQuery.isLoading.value"
>
<div class="tracking-panel__header">
<div>
<h3 class="panel-title">AI 平台状态</h3>
<span class="tracking-panel__helper">
只读展示 desktop-client
的绑定授权和当前采样状态授权入口放在桌面端抓取过程静默执行
</span>
</div>
</div>
<div class="ai-platform-status-grid">
<article
v-for="platform in aiPlatformStatusCards"
:key="platform.id"
class="ai-platform-status-card"
>
<div class="ai-platform-status-card__head">
<div class="ai-platform-status-card__identity">
<span
class="ai-platform-status-card__badge"
:style="{
backgroundColor: `${platform.accent}14`,
color: platform.accent,
borderColor: `${platform.accent}30`,
}"
>
{{ platform.shortName }}
</span>
<div>
<strong>{{ platform.label }}</strong>
<p>{{ platform.description }}</p>
</div>
</div>
<a-tag :color="accountAuthColor(platform.account)">
{{ accountAuthLabel(platform.account) }}
</a-tag>
</div>
<div class="ai-platform-status-card__meta">
<div class="ai-platform-status-card__row">
<span>绑定状态</span>
<strong>{{ platform.account ? '已绑定' : '未绑定' }}</strong>
</div>
<div class="ai-platform-status-card__row">
<span>客户端</span>
<strong>{{ desktopStatusLabel(platform.account, platform.isClientOnline) }}</strong>
</div>
<div class="ai-platform-status-card__row">
<span>采样状态</span>
<a-tag :color="statusTagColor(platform.sampleStatus)">
{{ statusLabel(platform.sampleStatus) }}
</a-tag>
</div>
<div class="ai-platform-status-card__row">
<span>最近采样</span>
<strong>
{{ platform.lastSampledAt ? formatDateTime(platform.lastSampledAt) : '--' }}
</strong>
</div>
<div class="ai-platform-status-card__row">
<span>样本数</span>
<strong>{{ formatCount(platform.actualSampleCount) }}</strong>
</div>
<div v-if="platform.account" class="ai-platform-status-card__row">
<span>账号</span>
<strong>{{ platform.account.display_name }}</strong>
</div>
</div>
</article>
</div>
</a-card>
<div class="tracking-grid tracking-grid--middle">
<a-card
class="tracking-panel tracking-panel--citation"
:loading="citationSummaryQuery.isLoading.value"
>
<div class="tracking-citation-section">
<div class="tracking-citation-header">
<div>
<h3 class="tracking-citation-title">{{ t('tracking.citationRankingTitle') }}</h3>
<p class="tracking-citation-desc">{{ t('tracking.citationRankingHint') }}</p>
</div>
<a-radio-group
v-model:value="selectedCitationWindowDays"
class="citation-window-switch"
size="small"
button-style="solid"
>
<a-radio-button
v-for="item in citationWindowOptions"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</a-radio-button>
</a-radio-group>
</div>
<div class="citation-ranking-layout">
<div class="citation-ranking-visual">
<div
class="citation-ranking-visual__canvas"
@mouseleave="hoveredSlice = null"
@mousemove="onPieMouseMove"
>
<svg v-if="citationPieSlices.length" viewBox="0 0 200 200" class="citation-pie-svg">
<template v-for="slice in citationPieSlices" :key="slice.ai_platform_id">
<circle
v-if="slice.isFull"
cx="100"
cy="100"
r="100"
:fill="slice.color"
class="citation-pie-slice"
:class="{ 'is-hovered': hoveredSlice === slice.ai_platform_id }"
@mouseenter="hoveredSlice = slice.ai_platform_id"
/>
<path
v-else
:d="slice.path"
:fill="slice.color"
class="citation-pie-slice"
:class="{
'is-hovered': hoveredSlice === slice.ai_platform_id,
'is-dimmed': hoveredSlice && hoveredSlice !== slice.ai_platform_id,
}"
@mouseenter="hoveredSlice = slice.ai_platform_id"
/>
</template>
</svg>
<div v-else class="citation-ranking-visual__circle" :style="citationChartStyle" />
<div
v-if="hoveredSliceInfo"
class="citation-pie-tooltip"
:style="{ left: `${tooltipPos.x}px`, top: `${tooltipPos.y}px` }"
>
<div
class="citation-pie-tooltip__marker"
:style="{ background: hoveredSliceInfo.color }"
></div>
<span class="citation-pie-tooltip__label">
{{ hoveredSliceInfo.platform_name }}
</span>
<strong class="citation-pie-tooltip__value">
{{ formatPercent(hoveredSliceInfo.share) }}
</strong>
</div>
</div>
<div v-if="citationRankingRows.length" class="citation-ranking-legend">
<div
v-for="item in citationRankingRows"
:key="`legend-${item.ai_platform_id}`"
class="citation-ranking-legend__item"
>
<span
class="citation-ranking-legend__swatch"
:style="{ background: item.color }"
/>
<span class="citation-platform__mini">
<span
class="citation-platform__mini-badge"
:style="{ background: item.surface, color: item.color }"
>
{{ item.glyph }}
</span>
<span>{{ item.platform_name }}</span>
</span>
</div>
</div>
<a-empty v-else :description="t('tracking.noCitationRanking')" />
</div>
<div class="citation-ranking-sheet">
<div class="citation-ranking-sheet__head">
<span>{{ t('tracking.columns.platform') }}</span>
<span>{{ t('tracking.columns.appearances') }}</span>
<span>{{ t('tracking.columns.saasSourceRate') }}</span>
<span>{{ t('tracking.columns.citationRate') }}</span>
</div>
<div
v-for="item in citationRankingRows"
:key="item.ai_platform_id"
class="citation-ranking-sheet__row"
>
<div class="citation-platform__mini">
<span
class="citation-platform__mini-badge"
:style="{ background: item.surface, color: item.color }"
>
{{ item.glyph }}
</span>
<strong>{{ item.platform_name }}</strong>
</div>
<span class="citation-ranking-sheet__count">
{{ formatCount(item.saas_source_count ?? item.cited_answer_count) }}
{{ item.saas_source_count != null ? t('tracking.citations') : '' }}
</span>
<strong class="citation-ranking-sheet__rate">
{{ formatPercent(item.saas_source_rate) }}
</strong>
<strong class="citation-ranking-sheet__rate">
{{ formatPercent(item.citation_rate) }}
</strong>
</div>
<a-empty
v-if="!citationRankingRows.length"
:description="t('tracking.noCitationRanking')"
/>
</div>
</div>
</div>
</a-card>
<a-card class="tracking-panel" :loading="dashboardQuery.isLoading.value">
<div class="tracking-panel__header">
<h3 class="panel-title">{{ t('tracking.hotQuestionsTitle') }}</h3>
<span class="tracking-panel__helper">{{ t('tracking.hotQuestionsHint') }}</span>
</div>
<div v-if="hotQuestions.length" class="question-list">
<button
v-for="question in hotQuestions"
:key="`${question.question_id}-${question.question_hash}`"
type="button"
class="question-list__item"
@click="openQuestion(question)"
>
<div>
<strong>{{ question.question_text }}</strong>
<p>
{{ t('tracking.metrics.mentionRate') }} {{ formatPercent(question.mention_rate) }}
</p>
</div>
<span class="question-list__time">
{{ question.last_sampled_at ? formatDateTime(question.last_sampled_at) : '--' }}
</span>
</button>
</div>
<a-empty v-else :description="t('tracking.noQuestions')" />
</a-card>
</div>
<div class="tracking-grid tracking-grid--bottom">
<a-card class="tracking-panel" :loading="citationSummaryQuery.isLoading.value">
<div class="tracking-panel__header">
<h3 class="panel-title">{{ t('tracking.citedArticlesTitle') }}</h3>
<span class="tracking-panel__helper">
{{ t('tracking.citationWindowLabel', { days: selectedCitationWindowDays }) }}
</span>
</div>
<div class="article-list">
<div
v-for="item in visibleCitedArticles"
:key="item.article_id"
class="article-list__item"
>
<div>
<strong>{{ item.article_title }}</strong>
<p>{{ item.publish_platform }}</p>
</div>
<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>
</div>
<a-empty
v-if="!visibleCitedArticles.length"
:description="t('tracking.noCitedArticles')"
/>
</div>
</a-card>
</div>
</section>
</template>
<style scoped>
.tracking-view {
display: flex;
flex-direction: column;
gap: 20px;
}
.tracking-actions-wrap {
align-items: center;
}
.tracking-view__alert {
margin-bottom: 4px;
}
.tracking-action-item {
display: flex;
align-items: center;
gap: 8px;
}
.tracking-action-label {
font-size: 14px;
color: #1f2937;
font-weight: 500;
}
.tracking-select {
min-width: 180px;
}
.tracking-date-picker {
min-width: 168px;
}
.tracking-grid {
display: grid;
gap: 20px;
}
.tracking-grid--top {
grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
}
.tracking-grid--middle {
grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
}
.tracking-grid--bottom {
grid-template-columns: minmax(0, 1fr);
}
.tracking-panel--ai-platforms {
overflow: hidden;
}
.ai-platform-status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
margin-top: 12px;
}
.ai-platform-status-card {
display: flex;
flex-direction: column;
gap: 14px;
padding: 16px;
border-radius: 16px;
border: 1px solid #e6edf5;
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
}
.ai-platform-status-card__head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.ai-platform-status-card__identity {
display: flex;
gap: 12px;
align-items: flex-start;
}
.ai-platform-status-card__identity strong {
display: block;
color: #111827;
font-size: 16px;
line-height: 1.2;
}
.ai-platform-status-card__identity p {
margin: 6px 0 0;
color: #667085;
line-height: 1.5;
font-size: 12px;
}
.ai-platform-status-card__badge {
width: 42px;
height: 42px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 14px;
border: 1px solid transparent;
font-size: 16px;
font-weight: 700;
flex-shrink: 0;
}
.ai-platform-status-card__meta {
display: flex;
flex-direction: column;
gap: 10px;
}
.ai-platform-status-card__row {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
padding: 10px 12px;
border-radius: 12px;
background: #f8fafc;
}
.ai-platform-status-card__row span {
color: #667085;
font-size: 12px;
}
.ai-platform-status-card__row strong {
color: #111827;
font-size: 13px;
text-align: right;
word-break: break-all;
}
.tracking-citation-section {
display: flex;
flex-direction: column;
padding: 0;
margin-top: 10px;
}
.tracking-citation-section.is-loading {
opacity: 0.6;
pointer-events: none;
}
.tracking-citation-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.tracking-citation-title {
margin: 0;
font-size: 16px;
font-weight: 700;
color: #1a1a1a;
}
.tracking-citation-desc {
margin: 4px 0 0;
color: #8c8c8c;
font-size: 13px;
}
.tracking-panel {
border-radius: 12px;
background: #ffffff;
}
.tracking-panel--citation {
overflow: hidden;
}
.citation-window-switch {
flex: 0 0 auto;
}
.tracking-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.tracking-panel__header-left {
display: flex;
flex-direction: column;
}
.panel-title {
margin: 0;
font-size: 16px;
font-weight: 700;
color: #1a1a1a;
display: flex;
align-items: center;
}
.panel-title::before {
content: '';
display: inline-block;
width: 4px;
height: 14px;
background: #1f5cff;
border-radius: 2px;
margin-right: 8px;
}
.tracking-panel__description {
margin: 10px 0 0 12px;
color: #a3a3a3;
font-size: 13px;
}
.tracking-panel__helper {
color: var(--muted);
font-size: 13px;
}
.tracking-panel__empty {
padding: 28px 0 16px;
}
.tracking-metric-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.metric-card {
background: #f7f9fc;
border: 1px solid #f0f3fa;
border-radius: 12px;
padding: 16px;
}
.metric-card__label {
margin: 0;
color: #8c8c8c;
font-size: 13px;
}
.metric-card__value-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-top: 10px;
}
.metric-card__value-row strong {
font-size: 24px;
font-weight: 800;
color: #141414;
}
.metric-card__delta {
font-size: 13px;
font-weight: 700;
}
.metric-card__delta--up {
color: #15803d;
}
.metric-card__delta--down {
color: #dc2626;
}
.platform-table,
.citation-table {
display: flex;
flex-direction: column;
gap: 0;
}
.platform-table__head,
.platform-table__row,
.citation-table__head,
.citation-table__row {
display: grid;
grid-template-columns: minmax(0, 1.5fr) repeat(3, minmax(0, 1fr));
align-items: center;
gap: 12px;
}
.platform-table__head,
.citation-table__head {
background: #fafafa;
color: #8c8c8c;
font-weight: 500;
font-size: 13px;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
}
.platform-table__row,
.citation-table__row {
padding: 16px;
border-bottom: 1px solid #f9f9f9;
background: #ffffff;
}
.platform-table__row:last-child,
.citation-table__row:last-child {
border-bottom: none;
}
.platform-table__platform span {
display: block;
margin-top: 4px;
color: var(--muted);
font-size: 12px;
}
.citation-ranking-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(400px, 1fr);
gap: 40px;
align-items: flex-start;
}
.citation-ranking-visual {
display: flex;
flex-direction: column;
align-items: center;
gap: 32px;
padding: 0;
}
.citation-ranking-visual__canvas {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.citation-pie-svg {
width: min(32vw, 420px);
height: min(32vw, 420px);
min-width: 280px;
min-height: 280px;
border-radius: 999px;
overflow: visible;
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.05));
}
.citation-pie-slice {
transition:
transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1),
opacity 0.25s;
transform-origin: 100px 100px;
cursor: pointer;
stroke: #ffffff;
stroke-width: 1.5px;
stroke-linejoin: round;
}
.citation-pie-slice.is-hovered {
transform: scale(1.05);
z-index: 2;
}
.citation-pie-slice.is-dimmed {
opacity: 0.4;
}
.citation-pie-tooltip {
position: absolute;
pointer-events: none;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(4px);
border: 1px solid #e5e7eb;
padding: 8px 12px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
gap: 8px;
z-index: 10;
font-size: 13px;
white-space: nowrap;
transition: opacity 0.2s;
}
.citation-pie-tooltip__marker {
width: 8px;
height: 8px;
border-radius: 50%;
}
.citation-pie-tooltip__label {
color: #4b5563;
}
.citation-pie-tooltip__value {
color: #111827;
font-weight: 600;
}
.citation-ranking-visual__circle {
width: min(32vw, 420px);
height: min(32vw, 420px);
min-width: 280px;
min-height: 280px;
border-radius: 999px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.85);
}
.citation-ranking-legend {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 16px 24px;
}
.citation-ranking-legend__item {
display: inline-flex;
align-items: center;
gap: 8px;
color: #4b5563;
font-size: 14px;
}
.citation-ranking-legend__swatch {
width: 12px;
height: 12px;
border-radius: 4px;
flex: 0 0 auto;
}
.citation-platform__mini {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.citation-platform__mini-badge {
display: inline-flex;
width: 20px;
height: 20px;
border-radius: 999px;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
flex: 0 0 auto;
}
.citation-ranking-sheet {
border-radius: 12px;
border: 1px solid #f0f3fa;
background: #ffffff;
overflow: hidden;
}
.citation-ranking-sheet__head,
.citation-ranking-sheet__row {
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(0, 0.72fr) minmax(0, 0.72fr) minmax(0, 0.72fr);
align-items: center;
gap: 16px;
padding: 20px 32px;
min-width: 560px;
}
.citation-ranking-sheet__head {
background: #fafafa;
color: #4b5563;
font-size: 13px;
font-weight: 500;
border-bottom: 1px solid #f0f0f0;
}
.citation-ranking-sheet__row {
border-bottom: 1px solid #f9f9f9;
color: #4b5563;
font-size: 14px;
}
.citation-ranking-sheet__row:last-child {
border-bottom: none;
}
.citation-ranking-sheet__row strong {
color: #111827;
font-size: 14px;
}
.citation-ranking-sheet__row > span,
.citation-ranking-sheet__row > strong {
justify-self: start;
}
.citation-ranking-sheet__count {
color: #374151;
}
.citation-ranking-sheet__rate {
color: #111827;
}
.question-list,
.article-list,
.detail-citation-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.question-list__item,
.article-list__item,
.detail-citation-list__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
padding: 16px;
border: 1px solid #f0f3fa;
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);
}
.question-list__item {
cursor: pointer;
}
.question-list__item strong,
.article-list__item strong,
.detail-citation-list__item strong {
display: block;
}
.question-list__item p,
.article-list__item p,
.detail-citation-list__item p {
margin: 4px 0 0;
color: var(--muted);
}
.question-list__time,
.article-list__meta {
color: var(--muted);
font-size: 12px;
}
.article-list__meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.detail-tabs {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 16px;
}
.detail-tabs__item {
padding: 8px 14px;
border: 1px solid #fed7aa;
border-radius: 999px;
background: #fff7ed;
cursor: pointer;
}
.detail-tabs__item--active {
border-color: #f97316;
background: #f97316;
color: #fff;
}
.detail-tabs__item--muted {
opacity: 0.55;
}
.detail-grid {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
gap: 16px;
}
.detail-grid--bottom {
margin-top: 16px;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.detail-meta {
display: flex;
gap: 12px;
margin-top: 16px;
color: var(--muted);
font-size: 12px;
}
@media (max-width: 1080px) {
.tracking-grid--top,
.tracking-grid--middle,
.tracking-grid--bottom,
.detail-grid,
.detail-grid--bottom {
grid-template-columns: minmax(0, 1fr);
}
.citation-ranking-layout {
grid-template-columns: minmax(0, 1fr);
}
.citation-ranking-visual {
min-height: auto;
padding: 0;
}
.citation-ranking-visual__canvas {
min-height: 280px;
}
.citation-ranking-visual__circle {
width: min(72vw, 360px);
height: min(72vw, 360px);
}
.citation-ranking-sheet__head,
.citation-ranking-sheet__row {
padding: 22px 20px;
}
}
@media (max-width: 640px) {
.tracking-citation-header {
flex-direction: column;
}
.citation-ranking-sheet__head,
.citation-ranking-sheet__row {
grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.7fr) minmax(0, 0.7fr) minmax(0, 0.7fr);
gap: 10px;
font-size: 13px;
}
.citation-ranking-sheet__row strong,
.citation-ranking-sheet__row {
font-size: 14px;
}
.citation-ranking-legend {
gap: 12px 18px;
padding-left: 0;
}
}
</style>