Files
geo/apps/admin-web/src/views/TrackingView.vue
T
root 9727077dfa
Backend CI / Backend (push) Has been cancelled
Frontend CI / Frontend (push) Has been cancelled
Deployment Config CI / Deployment Config (push) Successful in 21s
refactor(admin-web): render citation pie as interactive SVG with hover tooltip
Replace the conic-gradient circle with an SVG path-per-slice chart so
slices can lift on hover, dim siblings, and surface a follow-cursor
tooltip with the platform name and share. Falls back to the original
gradient circle when there are no segments to render.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:46:35 +08:00

1812 lines
51 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 { useMutation, useQuery } from "@tanstack/vue-query";
import { useStorage } from "@vueuse/core";
import { message } from "ant-design-vue";
import dayjs from "dayjs";
import type {
Brand,
DesktopAccountInfo,
MonitoringHotQuestion,
MonitoringPlatformAuthorizationStatus,
} from "@geo/shared-types";
import { aiPlatformCatalog } from "@geo/shared-types";
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";
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
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;
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 selectedBrandId = useStorage<number | null>("tracking_selected_brand", null);
const selectedKeywordId = useStorage<number | null>("tracking_selected_keyword", null);
const selectedPlatformId = useStorage<string>("tracking_selected_ai_platform_id", "all");
const selectedBusinessDate = useStorage<string>("tracking_selected_business_date", trackingToday);
const selectedCitationWindowDays = useStorage<number>("tracking_selected_citation_window_days", 7);
selectedPlatformId.value = normalizeMonitoringPlatformFilter(selectedPlatformId.value);
selectedBusinessDate.value = normalizeTrackingBusinessDate(selectedBusinessDate.value) ?? trackingToday;
selectedCitationWindowDays.value = normalizeCitationWindowDays(selectedCitationWindowDays.value);
const brandsQuery = useQuery({
queryKey: ["tracking", "brands"],
queryFn: () => brandsApi.list(),
});
watch(
() => brandsQuery.data.value,
(brands) => {
if (!brands?.length) {
return;
}
const requestedBrandId = parseNumericQuery(route.query.brand_id);
if (requestedBrandId && brands.some((item) => item.id === requestedBrandId)) {
selectedBrandId.value = requestedBrandId;
return;
}
if (selectedBrandId.value && brands.some((item) => item.id === selectedBrandId.value)) {
return;
}
selectedBrandId.value = brands[0].id;
},
{ immediate: true },
);
const keywordsQuery = useQuery({
queryKey: computed(() => ["tracking", "keywords", selectedBrandId.value]),
enabled: computed(() => Boolean(selectedBrandId.value)),
queryFn: () => brandsApi.listKeywords(selectedBrandId.value as number),
});
watch(
() => keywordsQuery.data.value,
(keywords) => {
if (!keywords?.length) {
return;
}
const requestedKeywordId = parseNumericQuery(route.query.keyword_id);
if (requestedKeywordId && keywords.some((item) => item.id === requestedKeywordId)) {
selectedKeywordId.value = requestedKeywordId;
return;
}
if (selectedKeywordId.value && keywords.some((item) => item.id === selectedKeywordId.value)) {
return;
}
selectedKeywordId.value = keywords[0].id;
},
{ immediate: true },
);
watch(selectedBrandId, (newId, oldId) => {
if (oldId && newId !== oldId) {
selectedKeywordId.value = null;
}
});
watch(
() => route.query.business_date,
(value) => {
const requestedBusinessDate = normalizeTrackingBusinessDate(value);
if (requestedBusinessDate) {
selectedBusinessDate.value = requestedBusinessDate;
}
},
{ immediate: true },
);
watch(
() => route.query.ai_platform_id,
(value) => {
selectedPlatformId.value = normalizeMonitoringPlatformFilter(value);
},
{ immediate: true },
);
watch([selectedBrandId, selectedKeywordId, selectedPlatformId, selectedBusinessDate], ([brandId, keywordId, platformId, businessDate]) => {
const nextBrandId = brandId ? String(brandId) : undefined;
const nextKeywordId = keywordId ? String(keywordId) : undefined;
const nextPlatformId = platformId !== "all" ? platformId : undefined;
const nextBusinessDate = normalizeTrackingBusinessDate(businessDate) ?? trackingToday;
const currentBrandId = normalizeQueryValue(route.query.brand_id) || undefined;
const currentKeywordId = normalizeQueryValue(route.query.keyword_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 &&
currentKeywordId === nextKeywordId &&
currentPlatformId === (nextPlatformId ?? "all") &&
currentBusinessDate === nextBusinessDate &&
!hasLegacyCitationDaysQuery
) {
return;
}
void router.replace({
name: "tracking",
query: {
...(nextBrandId ? { brand_id: nextBrandId } : {}),
...(nextKeywordId ? { keyword_id: nextKeywordId } : {}),
...(nextPlatformId ? { ai_platform_id: nextPlatformId } : {}),
business_date: nextBusinessDate,
},
});
});
const trackingWindow = computed(() => {
const end = dayjs(selectedBusinessDate.value);
const earliestAllowed = dayjs(trackingToday).subtract(trackingMaxHistoryDays - 1, "day").startOf("day");
const candidateFrom = end.subtract(trackingMaxHistoryDays - 1, "day");
const from = candidateFrom.isBefore(earliestAllowed, "day") ? earliestAllowed : candidateFrom;
return {
from: from.format("YYYY-MM-DD"),
to: end.format("YYYY-MM-DD"),
};
});
const dashboardQuery = useQuery({
queryKey: computed(() => [
"tracking",
"dashboard",
selectedBrandId.value,
selectedKeywordId.value,
selectedPlatformId.value,
selectedBusinessDate.value,
]),
enabled: computed(() => Boolean(selectedBrandId.value)),
queryFn: () =>
monitoringApi.dashboardComposite({
brand_id: selectedBrandId.value ?? undefined,
keyword_id: selectedKeywordId.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,
selectedKeywordId.value,
selectedPlatformId.value,
selectedBusinessDate.value,
selectedCitationWindowDays.value,
]),
enabled: computed(() => Boolean(selectedBrandId.value)),
queryFn: () =>
monitoringApi.citationSummary({
brand_id: selectedBrandId.value ?? undefined,
keyword_id: selectedKeywordId.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");
}
if (!selectedKeywordId.value) {
throw new Error("tracking_keyword_required");
}
return monitoringApi.collectNow(selectedBrandId.value, {
keyword_id: selectedKeywordId.value,
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;
}
if (error instanceof Error && error.message === "tracking_keyword_required") {
message.warning(t("tracking.collectKeywordRequired"));
return;
}
message.error(formatError(error));
},
});
const selectedBrand = computed<Brand | null>(() => {
return brandsQuery.data.value?.find((item) => item.id === selectedBrandId.value) ?? null;
});
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 collectNowDisabledReason = computed(() => {
if (!selectedBrandId.value) {
return t("tracking.collectBrandRequired");
}
if (!selectedKeywordId.value) {
return t("tracking.collectKeywordRequired");
}
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");
}
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]),
);
return aiPlatformCatalog.map((platform) => {
const matchedAccounts = [...(accountGroups.get(platform.id) ?? [])].sort((left, right) => {
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,
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: {
question_hash: question.question_hash,
question_text: question.question_text,
business_date: selectedBusinessDate.value,
date_from: selectedBusinessDate.value,
date_to: selectedBusinessDate.value,
...(selectedPlatformId.value !== "all" ? { ai_platform_id: selectedPlatformId.value } : {}),
...(selectedKeywordId.value ? { keyword_id: String(selectedKeywordId.value) } : {}),
},
});
}
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): string {
if (!account?.client_id) {
return "未绑定桌面端";
}
return account.client_online ? "客户端在线" : "客户端离线";
}
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
:eyebrow="t('tracking.eyebrow')"
: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="selectedBrandId"
class="tracking-select"
:placeholder="t('tracking.brandPlaceholder')"
:options="(brandsQuery.data.value ?? []).map((item) => ({ label: item.name, value: item.id }))"
/>
</div>
<div class="tracking-action-item">
<span class="tracking-action-label">关键词</span>
<a-select
v-model:value="selectedKeywordId"
allow-clear
class="tracking-select"
:placeholder="t('tracking.keywordPlaceholder')"
:options="(keywordsQuery.data.value ?? []).map((item) => ({ label: item.name, value: item.id }))"
/>
</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) }}</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>