2026-04-12 09:56:18 +08:00
|
|
|
|
<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,
|
2026-04-20 15:40:18 +08:00
|
|
|
|
DesktopAccountInfo,
|
2026-04-12 09:56:18 +08:00
|
|
|
|
MonitoringHotQuestion,
|
2026-04-24 22:21:01 +08:00
|
|
|
|
MonitoringPlatformAuthorizationStatus,
|
2026-04-12 09:56:18 +08:00
|
|
|
|
} from "@geo/shared-types";
|
2026-04-20 15:40:18 +08:00
|
|
|
|
import { aiPlatformCatalog } from "@geo/shared-types";
|
2026-05-01 11:46:35 +08:00
|
|
|
|
import { computed, ref, watch } from "vue";
|
2026-04-12 09:56:18 +08:00
|
|
|
|
import { useI18n } from "vue-i18n";
|
|
|
|
|
|
import { useRoute, useRouter } from "vue-router";
|
|
|
|
|
|
|
|
|
|
|
|
import PageHero from "@/components/PageHero.vue";
|
2026-04-20 15:40:18 +08:00
|
|
|
|
import { brandsApi, monitoringApi, tenantAccountsApi } from "@/lib/api";
|
2026-04-27 21:34:05 +08:00
|
|
|
|
import { resolveAccountCheckedAt, resolveAccountHealth } from "@/lib/desktop-account-runtime";
|
2026-04-12 09:56:18 +08:00
|
|
|
|
import { formatDateTime } from "@/lib/display";
|
|
|
|
|
|
import { formatError } from "@/lib/errors";
|
2026-04-23 09:11:53 +08:00
|
|
|
|
import { isMonitoringPlatformId, normalizeMonitoringPlatformFilter, normalizeMonitoringPlatformId } from "@/lib/monitoring-platforms";
|
2026-04-12 09:56:18 +08:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-04-30 17:12:09 +08:00
|
|
|
|
citation_source_count: number | null;
|
|
|
|
|
|
saas_source_count: number | null;
|
|
|
|
|
|
saas_source_rate: number | null;
|
2026-04-12 09:56:18 +08:00
|
|
|
|
citation_rate: number | null;
|
|
|
|
|
|
share: number;
|
|
|
|
|
|
color: string;
|
|
|
|
|
|
surface: string;
|
|
|
|
|
|
glyph: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-20 15:40:18 +08:00
|
|
|
|
type AIPlatformStatusCard = {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
shortName: string;
|
|
|
|
|
|
accent: string;
|
|
|
|
|
|
description: string;
|
|
|
|
|
|
account: DesktopAccountInfo | null;
|
|
|
|
|
|
sampleStatus: string;
|
|
|
|
|
|
lastSampledAt: string | null;
|
|
|
|
|
|
actualSampleCount: number;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-12 09:56:18 +08:00
|
|
|
|
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" },
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-13 16:08:12 +08:00
|
|
|
|
const trackingPlatformOptions = [
|
|
|
|
|
|
{ label: "全部", value: "all" },
|
2026-04-20 15:40:18 +08:00
|
|
|
|
...aiPlatformCatalog.map((platform) => ({
|
|
|
|
|
|
label: platform.label,
|
|
|
|
|
|
value: platform.id,
|
|
|
|
|
|
})),
|
2026-04-13 16:08:12 +08:00
|
|
|
|
] as const;
|
|
|
|
|
|
|
2026-04-12 09:56:18 +08:00
|
|
|
|
const selectedBrandId = useStorage<number | null>("tracking_selected_brand", null);
|
|
|
|
|
|
const selectedKeywordId = useStorage<number | null>("tracking_selected_keyword", null);
|
2026-04-13 16:08:12 +08:00
|
|
|
|
const selectedPlatformId = useStorage<string>("tracking_selected_ai_platform_id", "all");
|
2026-04-12 09:56:18 +08:00
|
|
|
|
const selectedBusinessDate = useStorage<string>("tracking_selected_business_date", trackingToday);
|
2026-04-30 17:12:09 +08:00
|
|
|
|
const selectedCitationWindowDays = useStorage<number>("tracking_selected_citation_window_days", 7);
|
2026-04-12 09:56:18 +08:00
|
|
|
|
|
2026-04-23 09:11:53 +08:00
|
|
|
|
selectedPlatformId.value = normalizeMonitoringPlatformFilter(selectedPlatformId.value);
|
2026-04-12 09:56:18 +08:00
|
|
|
|
selectedBusinessDate.value = normalizeTrackingBusinessDate(selectedBusinessDate.value) ?? trackingToday;
|
2026-04-30 17:12:09 +08:00
|
|
|
|
selectedCitationWindowDays.value = normalizeCitationWindowDays(selectedCitationWindowDays.value);
|
2026-04-12 09:56:18 +08:00
|
|
|
|
|
|
|
|
|
|
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 },
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-04-13 16:08:12 +08:00
|
|
|
|
watch(
|
|
|
|
|
|
() => route.query.ai_platform_id,
|
|
|
|
|
|
(value) => {
|
2026-04-23 09:11:53 +08:00
|
|
|
|
selectedPlatformId.value = normalizeMonitoringPlatformFilter(value);
|
2026-04-13 16:08:12 +08:00
|
|
|
|
},
|
|
|
|
|
|
{ immediate: true },
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
watch([selectedBrandId, selectedKeywordId, selectedPlatformId, selectedBusinessDate], ([brandId, keywordId, platformId, businessDate]) => {
|
2026-04-12 09:56:18 +08:00
|
|
|
|
const nextBrandId = brandId ? String(brandId) : undefined;
|
|
|
|
|
|
const nextKeywordId = keywordId ? String(keywordId) : undefined;
|
2026-04-13 16:08:12 +08:00
|
|
|
|
const nextPlatformId = platformId !== "all" ? platformId : undefined;
|
2026-04-12 09:56:18 +08:00
|
|
|
|
const nextBusinessDate = normalizeTrackingBusinessDate(businessDate) ?? trackingToday;
|
|
|
|
|
|
const currentBrandId = normalizeQueryValue(route.query.brand_id) || undefined;
|
|
|
|
|
|
const currentKeywordId = normalizeQueryValue(route.query.keyword_id) || undefined;
|
2026-04-23 09:11:53 +08:00
|
|
|
|
const currentPlatformId = normalizeMonitoringPlatformFilter(route.query.ai_platform_id);
|
2026-04-12 09:56:18 +08:00
|
|
|
|
const currentBusinessDate = normalizeTrackingBusinessDate(route.query.business_date) ?? trackingToday;
|
2026-04-30 17:12:09 +08:00
|
|
|
|
const hasLegacyCitationDaysQuery = normalizeQueryValue(route.query.citation_days) !== "";
|
2026-04-12 09:56:18 +08:00
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
|
currentBrandId === nextBrandId &&
|
|
|
|
|
|
currentKeywordId === nextKeywordId &&
|
2026-04-13 16:08:12 +08:00
|
|
|
|
currentPlatformId === (nextPlatformId ?? "all") &&
|
2026-04-30 17:12:09 +08:00
|
|
|
|
currentBusinessDate === nextBusinessDate &&
|
|
|
|
|
|
!hasLegacyCitationDaysQuery
|
2026-04-12 09:56:18 +08:00
|
|
|
|
) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void router.replace({
|
|
|
|
|
|
name: "tracking",
|
|
|
|
|
|
query: {
|
|
|
|
|
|
...(nextBrandId ? { brand_id: nextBrandId } : {}),
|
|
|
|
|
|
...(nextKeywordId ? { keyword_id: nextKeywordId } : {}),
|
2026-04-13 16:08:12 +08:00
|
|
|
|
...(nextPlatformId ? { ai_platform_id: nextPlatformId } : {}),
|
2026-04-12 09:56:18 +08:00
|
|
|
|
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({
|
2026-04-13 16:08:12 +08:00
|
|
|
|
queryKey: computed(() => [
|
|
|
|
|
|
"tracking",
|
|
|
|
|
|
"dashboard",
|
|
|
|
|
|
selectedBrandId.value,
|
|
|
|
|
|
selectedKeywordId.value,
|
|
|
|
|
|
selectedPlatformId.value,
|
|
|
|
|
|
selectedBusinessDate.value,
|
|
|
|
|
|
]),
|
2026-04-12 09:56:18 +08:00
|
|
|
|
enabled: computed(() => Boolean(selectedBrandId.value)),
|
|
|
|
|
|
queryFn: () =>
|
|
|
|
|
|
monitoringApi.dashboardComposite({
|
|
|
|
|
|
brand_id: selectedBrandId.value ?? undefined,
|
|
|
|
|
|
keyword_id: selectedKeywordId.value,
|
|
|
|
|
|
days: trackingMaxHistoryDays,
|
2026-04-13 16:08:12 +08:00
|
|
|
|
ai_platform_id: selectedPlatformId.value !== "all" ? selectedPlatformId.value : undefined,
|
2026-04-12 09:56:18 +08:00
|
|
|
|
business_date: selectedBusinessDate.value,
|
|
|
|
|
|
}),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-30 17:12:09 +08:00
|
|
|
|
const citationSummaryQuery = useQuery({
|
|
|
|
|
|
queryKey: computed(() => [
|
|
|
|
|
|
"tracking",
|
|
|
|
|
|
"citation-summary",
|
2026-05-01 11:46:17 +08:00
|
|
|
|
selectedBrandId.value,
|
|
|
|
|
|
selectedKeywordId.value,
|
|
|
|
|
|
selectedPlatformId.value,
|
|
|
|
|
|
selectedBusinessDate.value,
|
2026-04-30 17:12:09 +08:00
|
|
|
|
selectedCitationWindowDays.value,
|
|
|
|
|
|
]),
|
2026-05-01 11:46:17 +08:00
|
|
|
|
enabled: computed(() => Boolean(selectedBrandId.value)),
|
2026-04-30 17:12:09 +08:00
|
|
|
|
queryFn: () =>
|
|
|
|
|
|
monitoringApi.citationSummary({
|
2026-05-01 11:46:17 +08:00
|
|
|
|
brand_id: selectedBrandId.value ?? undefined,
|
|
|
|
|
|
keyword_id: selectedKeywordId.value,
|
|
|
|
|
|
business_date: selectedBusinessDate.value,
|
|
|
|
|
|
ai_platform_id: selectedPlatformId.value !== "all" ? selectedPlatformId.value : undefined,
|
2026-04-30 17:12:09 +08:00
|
|
|
|
days: selectedCitationWindowDays.value,
|
|
|
|
|
|
}),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-20 15:40:18 +08:00
|
|
|
|
const aiAccountsQuery = useQuery({
|
|
|
|
|
|
queryKey: ["tracking", "ai-platform-accounts"],
|
|
|
|
|
|
queryFn: () => tenantAccountsApi.list(),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-12 09:56:18 +08:00
|
|
|
|
const collectNowMutation = useMutation({
|
|
|
|
|
|
mutationFn: async () => {
|
2026-04-20 19:10:35 +08:00
|
|
|
|
if (!selectedBrandId.value) {
|
|
|
|
|
|
throw new Error("tracking_brand_required");
|
|
|
|
|
|
}
|
2026-04-12 09:56:18 +08:00
|
|
|
|
if (!selectedKeywordId.value) {
|
|
|
|
|
|
throw new Error("tracking_keyword_required");
|
|
|
|
|
|
}
|
2026-04-20 19:10:35 +08:00
|
|
|
|
|
|
|
|
|
|
return monitoringApi.collectNow(selectedBrandId.value, {
|
2026-04-12 09:56:18 +08:00
|
|
|
|
keyword_id: selectedKeywordId.value,
|
2026-04-22 00:24:21 +08:00
|
|
|
|
platform_ids:
|
|
|
|
|
|
selectedPlatformId.value !== "all" ? [selectedPlatformId.value] : undefined,
|
2026-04-12 09:56:18 +08:00
|
|
|
|
});
|
|
|
|
|
|
},
|
2026-04-20 19:10:35 +08:00
|
|
|
|
onSuccess: (collectNowResult) => {
|
2026-04-12 09:56:18 +08:00
|
|
|
|
message.success(collectNowResult.message);
|
|
|
|
|
|
void dashboardQuery.refetch();
|
|
|
|
|
|
},
|
|
|
|
|
|
onError: (error) => {
|
2026-04-20 19:10:35 +08:00
|
|
|
|
if (error instanceof Error && error.message === "tracking_brand_required") {
|
|
|
|
|
|
message.warning(t("tracking.collectBrandRequired"));
|
2026-04-12 09:56:18 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-04-20 19:10:35 +08:00
|
|
|
|
if (error instanceof Error && error.message === "tracking_keyword_required") {
|
|
|
|
|
|
message.warning(t("tracking.collectKeywordRequired"));
|
2026-04-12 09:56:18 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
message.error(formatError(error));
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const selectedBrand = computed<Brand | null>(() => {
|
|
|
|
|
|
return brandsQuery.data.value?.find((item) => item.id === selectedBrandId.value) ?? null;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-20 19:10:35 +08:00
|
|
|
|
const currentUserClientOnline = computed(() => {
|
|
|
|
|
|
return dashboardQuery.data.value?.runtime.current_user_client_online === true;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-24 22:21:01 +08:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-20 19:10:35 +08:00
|
|
|
|
const collectNowDisabledReason = computed(() => {
|
|
|
|
|
|
if (!selectedBrandId.value) {
|
|
|
|
|
|
return t("tracking.collectBrandRequired");
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!selectedKeywordId.value) {
|
|
|
|
|
|
return t("tracking.collectKeywordRequired");
|
|
|
|
|
|
}
|
2026-04-23 09:11:53 +08:00
|
|
|
|
if (selectedBusinessDate.value !== trackingToday) {
|
|
|
|
|
|
return t("tracking.collectTodayOnly");
|
|
|
|
|
|
}
|
2026-04-20 19:10:35 +08:00
|
|
|
|
if (dashboardQuery.isLoading.value && !dashboardQuery.data.value) {
|
|
|
|
|
|
return t("tracking.collectClientChecking");
|
|
|
|
|
|
}
|
2026-04-24 22:21:01 +08:00
|
|
|
|
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");
|
|
|
|
|
|
}
|
2026-04-20 19:10:35 +08:00
|
|
|
|
if (!currentUserClientOnline.value) {
|
|
|
|
|
|
return t("tracking.collectClientOnlineRequired");
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-12 09:56:18 +08:00
|
|
|
|
const selectedDateHasData = computed(() => {
|
|
|
|
|
|
return (dashboardQuery.data.value?.overview.actual_sample_count ?? 0) > 0;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const hotQuestions = computed(() => {
|
|
|
|
|
|
return dashboardQuery.data.value?.hot_questions ?? [];
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-20 15:40:18 +08:00
|
|
|
|
const aiPlatformStatusCards = computed<AIPlatformStatusCard[]>(() => {
|
|
|
|
|
|
const accountGroups = new Map<string, DesktopAccountInfo[]>();
|
|
|
|
|
|
for (const account of aiAccountsQuery.data.value ?? []) {
|
2026-04-23 09:11:53 +08:00
|
|
|
|
const platformId = normalizeMonitoringPlatformId(account.platform);
|
|
|
|
|
|
if (!isMonitoringPlatformId(platformId) || account.deleted_at) {
|
2026-04-20 15:40:18 +08:00
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-04-23 09:11:53 +08:00
|
|
|
|
const list = accountGroups.get(platformId) ?? [];
|
2026-04-20 15:40:18 +08:00
|
|
|
|
list.push(account);
|
2026-04-23 09:11:53 +08:00
|
|
|
|
accountGroups.set(platformId, list);
|
2026-04-20 15:40:18 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const breakdownMap = new Map(
|
2026-04-23 09:11:53 +08:00
|
|
|
|
(dashboardQuery.data.value?.platform_breakdown ?? []).map((item) => [normalizeMonitoringPlatformId(item.ai_platform_id), item]),
|
2026-04-20 15:40:18 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
return aiPlatformCatalog.map((platform) => {
|
|
|
|
|
|
const matchedAccounts = [...(accountGroups.get(platform.id) ?? [])].sort((left, right) => {
|
2026-04-27 21:34:05 +08:00
|
|
|
|
const leftAt = Date.parse(resolveAccountCheckedAt(left) ?? "") || 0;
|
|
|
|
|
|
const rightAt = Date.parse(resolveAccountCheckedAt(right) ?? "") || 0;
|
2026-04-20 15:40:18 +08:00
|
|
|
|
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,
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-12 09:56:18 +08:00
|
|
|
|
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,
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-30 17:12:09 +08:00
|
|
|
|
const citationWindowOptions = computed(() => [
|
|
|
|
|
|
{ label: t("tracking.citationWindow7"), value: 7 },
|
|
|
|
|
|
{ label: t("tracking.citationWindow30"), value: 30 },
|
|
|
|
|
|
]);
|
2026-04-12 09:56:18 +08:00
|
|
|
|
|
2026-04-30 17:12:09 +08:00
|
|
|
|
const citationRankingRows = computed<CitationRankingRow[]>(() => {
|
|
|
|
|
|
const ranking = citationSummaryQuery.data.value?.citation_ranking ?? [];
|
2026-04-12 09:56:18 +08:00
|
|
|
|
const order = new Map<string, number>();
|
|
|
|
|
|
const rows = new Map<string, CitationRankingRow>();
|
|
|
|
|
|
|
2026-04-30 17:12:09 +08:00
|
|
|
|
aiPlatformCatalog.forEach((item, index) => {
|
|
|
|
|
|
const platformId = normalizeMonitoringPlatformId(item.id) || item.id;
|
2026-04-23 09:11:53 +08:00
|
|
|
|
order.set(platformId, index);
|
|
|
|
|
|
rows.set(platformId, {
|
|
|
|
|
|
ai_platform_id: platformId,
|
2026-04-30 17:12:09 +08:00
|
|
|
|
platform_name: item.label,
|
2026-04-12 09:56:18 +08:00
|
|
|
|
cited_answer_count: null,
|
|
|
|
|
|
cited_article_count: null,
|
2026-04-30 17:12:09 +08:00
|
|
|
|
citation_source_count: null,
|
|
|
|
|
|
saas_source_count: null,
|
|
|
|
|
|
saas_source_rate: null,
|
2026-04-12 09:56:18 +08:00
|
|
|
|
citation_rate: null,
|
|
|
|
|
|
share: 0,
|
2026-04-30 17:12:09 +08:00
|
|
|
|
...platformAppearance(platformId, item.label),
|
2026-04-12 09:56:18 +08:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
ranking.forEach((item, index) => {
|
2026-04-23 09:11:53 +08:00
|
|
|
|
const platformId = normalizeMonitoringPlatformId(item.ai_platform_id) || item.ai_platform_id;
|
|
|
|
|
|
if (!order.has(platformId)) {
|
2026-04-30 17:12:09 +08:00
|
|
|
|
order.set(platformId, aiPlatformCatalog.length + index);
|
2026-04-12 09:56:18 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-23 09:11:53 +08:00
|
|
|
|
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),
|
2026-04-30 17:12:09 +08:00
|
|
|
|
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,
|
2026-04-23 09:11:53 +08:00
|
|
|
|
citation_rate: item.citation_rate ?? existing?.citation_rate ?? null,
|
2026-04-12 09:56:18 +08:00
|
|
|
|
share: 0,
|
2026-04-23 09:11:53 +08:00
|
|
|
|
...platformAppearance(platformId, item.platform_name || existing?.platform_name || platformId),
|
2026-04-12 09:56:18 +08:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const list = Array.from(rows.values());
|
2026-04-30 17:12:09 +08:00
|
|
|
|
const totalAppearances = list.reduce((sum, item) => sum + Math.max(item.saas_source_count ?? item.cited_answer_count ?? 0, 0), 0);
|
2026-04-12 09:56:18 +08:00
|
|
|
|
const enriched = list.map((item) => ({
|
|
|
|
|
|
...item,
|
2026-04-30 17:12:09 +08:00
|
|
|
|
share: totalAppearances > 0 ? Math.max(item.saas_source_count ?? item.cited_answer_count ?? 0, 0) / totalAppearances : 0,
|
2026-04-12 09:56:18 +08:00
|
|
|
|
}));
|
2026-04-30 17:12:09 +08:00
|
|
|
|
const hasCitationData = enriched.some((item) => (item.saas_source_count ?? item.cited_answer_count ?? 0) > 0);
|
2026-04-12 09:56:18 +08:00
|
|
|
|
|
|
|
|
|
|
return enriched.sort((left, right) => {
|
|
|
|
|
|
if (hasCitationData) {
|
2026-04-30 17:12:09 +08:00
|
|
|
|
const countDiff = (right.saas_source_count ?? right.cited_answer_count ?? 0) - (left.saas_source_count ?? left.cited_answer_count ?? 0);
|
2026-04-12 09:56:18 +08:00
|
|
|
|
if (countDiff !== 0) {
|
|
|
|
|
|
return countDiff;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return (order.get(left.ai_platform_id) ?? 0) - (order.get(right.ai_platform_id) ?? 0);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const visibleCitedArticles = computed(() => {
|
2026-04-30 17:12:09 +08:00
|
|
|
|
return citationSummaryQuery.data.value?.cited_articles ?? [];
|
2026-04-12 09:56:18 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-01 11:46:35 +08:00
|
|
|
|
const citationPieSlices = computed(() => {
|
2026-04-30 17:12:09 +08:00
|
|
|
|
const segments = citationRankingRows.value.filter((item) => (item.saas_source_count ?? item.cited_answer_count ?? 0) > 0);
|
2026-04-12 09:56:18 +08:00
|
|
|
|
|
|
|
|
|
|
if (!segments.length) {
|
2026-05-01 11:46:35 +08:00
|
|
|
|
return [];
|
2026-04-12 09:56:18 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-01 11:46:35 +08:00
|
|
|
|
const cx = 100;
|
|
|
|
|
|
const cy = 100;
|
|
|
|
|
|
const r = 100;
|
|
|
|
|
|
|
2026-04-12 09:56:18 +08:00
|
|
|
|
let offset = 0;
|
2026-05-01 11:46:35 +08:00
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
};
|
2026-04-12 09:56:18 +08:00
|
|
|
|
});
|
2026-05-01 11:46:35 +08:00
|
|
|
|
});
|
2026-04-12 09:56:18 +08:00
|
|
|
|
|
2026-05-01 11:46:35 +08:00
|
|
|
|
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(() => {
|
2026-04-12 09:56:18 +08:00
|
|
|
|
return {
|
2026-05-01 11:46:35 +08:00
|
|
|
|
background: "linear-gradient(180deg, #eef4ff 0%, #e5ecff 100%)",
|
2026-04-12 09:56:18 +08:00
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2026-04-13 16:08:12 +08:00
|
|
|
|
...(selectedPlatformId.value !== "all" ? { ai_platform_id: selectedPlatformId.value } : {}),
|
2026-04-12 09:56:18 +08:00
|
|
|
|
...(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)}%`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-30 17:12:09 +08:00
|
|
|
|
function normalizeCitationWindowDays(value: unknown): number {
|
|
|
|
|
|
const numeric = Number(value);
|
|
|
|
|
|
return numeric === 30 ? 30 : 7;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 15:40:18 +08:00
|
|
|
|
function accountAuthLabel(account: DesktopAccountInfo | null): string {
|
|
|
|
|
|
if (!account) {
|
|
|
|
|
|
return "未绑定";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-27 21:34:05 +08:00
|
|
|
|
switch (resolveAccountHealth(account)) {
|
2026-04-20 15:40:18 +08:00
|
|
|
|
case "live":
|
|
|
|
|
|
return "授权正常";
|
|
|
|
|
|
case "captcha":
|
|
|
|
|
|
return "待处理";
|
|
|
|
|
|
case "expired":
|
|
|
|
|
|
return "授权过期";
|
|
|
|
|
|
default:
|
|
|
|
|
|
return "风险观察";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function accountAuthColor(account: DesktopAccountInfo | null): string {
|
|
|
|
|
|
if (!account) {
|
|
|
|
|
|
return "default";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-27 21:34:05 +08:00
|
|
|
|
switch (resolveAccountHealth(account)) {
|
2026-04-20 15:40:18 +08:00
|
|
|
|
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 ? "客户端在线" : "客户端离线";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-12 09:56:18 +08:00
|
|
|
|
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) {
|
2026-04-23 09:11:53 +08:00
|
|
|
|
const normalizedId = normalizeMonitoringPlatformId(aiPlatformId) || aiPlatformId.toLowerCase().replace(/[^a-z0-9]/g, "");
|
2026-04-12 09:56:18 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 22:21:01 +08:00
|
|
|
|
const today = dayjs(trackingToday).startOf("day");
|
2026-04-12 09:56:18 +08:00
|
|
|
|
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 {
|
2026-04-24 22:21:01 +08:00
|
|
|
|
const today = dayjs(trackingToday).endOf("day");
|
|
|
|
|
|
const earliestAllowed = dayjs(trackingToday).subtract(trackingMaxHistoryDays - 1, "day").startOf("day");
|
2026-04-12 09:56:18 +08:00
|
|
|
|
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">
|
2026-04-13 16:08:12 +08:00
|
|
|
|
<div class="tracking-action-item">
|
|
|
|
|
|
<span class="tracking-action-label">平台</span>
|
|
|
|
|
|
<a-select
|
|
|
|
|
|
v-model:value="selectedPlatformId"
|
|
|
|
|
|
class="tracking-select"
|
|
|
|
|
|
:options="trackingPlatformOptions"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-04-12 09:56:18 +08:00
|
|
|
|
<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>
|
2026-04-20 19:10:35 +08:00
|
|
|
|
<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>
|
2026-04-12 09:56:18 +08:00
|
|
|
|
</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>
|
|
|
|
|
|
|
2026-04-24 22:21:01 +08:00
|
|
|
|
<div v-if="platformBreakdownRows.length" class="platform-table">
|
2026-04-12 09:56:18 +08:00
|
|
|
|
<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
|
2026-04-24 22:21:01 +08:00
|
|
|
|
v-for="item in platformBreakdownRows"
|
2026-04-12 09:56:18 +08:00
|
|
|
|
: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>
|
2026-04-24 22:21:01 +08:00
|
|
|
|
<a-empty
|
|
|
|
|
|
v-else
|
|
|
|
|
|
:description="t('tracking.noPlatformBreakdown')"
|
|
|
|
|
|
class="tracking-panel__empty"
|
|
|
|
|
|
/>
|
2026-04-12 09:56:18 +08:00
|
|
|
|
</a-card>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-04-20 15:40:18 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-04-12 09:56:18 +08:00
|
|
|
|
<div class="tracking-grid tracking-grid--middle">
|
2026-04-30 17:12:09 +08:00
|
|
|
|
<a-card class="tracking-panel tracking-panel--citation" :loading="citationSummaryQuery.isLoading.value">
|
2026-04-12 09:56:18 +08:00
|
|
|
|
<div class="tracking-citation-section">
|
|
|
|
|
|
<div class="tracking-citation-header">
|
2026-04-30 17:12:09 +08:00
|
|
|
|
<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>
|
2026-04-12 09:56:18 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="citation-ranking-layout">
|
|
|
|
|
|
<div class="citation-ranking-visual">
|
2026-05-01 11:46:35 +08:00
|
|
|
|
<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>
|
2026-04-12 09:56:18 +08:00
|
|
|
|
</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>
|
2026-04-30 17:12:09 +08:00
|
|
|
|
<span>{{ t("tracking.columns.saasSourceRate") }}</span>
|
2026-04-12 09:56:18 +08:00
|
|
|
|
<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>
|
2026-04-30 17:12:09 +08:00
|
|
|
|
<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>
|
2026-04-12 09:56:18 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-04-23 09:11:53 +08:00
|
|
|
|
<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">
|
|
|
|
|
|
|
2026-04-30 17:12:09 +08:00
|
|
|
|
<a-card class="tracking-panel" :loading="citationSummaryQuery.isLoading.value">
|
2026-04-12 09:56:18 +08:00
|
|
|
|
<div class="tracking-panel__header">
|
|
|
|
|
|
<h3 class="panel-title">{{ t("tracking.citedArticlesTitle") }}</h3>
|
2026-04-30 17:12:09 +08:00
|
|
|
|
<span class="tracking-panel__helper">{{ t("tracking.citationWindowLabel", { days: selectedCitationWindowDays }) }}</span>
|
2026-04-12 09:56:18 +08:00
|
|
|
|
</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>
|
2026-04-30 17:12:09 +08:00
|
|
|
|
<span>{{ t("tracking.columns.share") }} {{ formatPercent(item.source_share) }}</span>
|
2026-04-12 09:56:18 +08:00
|
|
|
|
<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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 15:40:18 +08:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-12 09:56:18 +08:00
|
|
|
|
.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 {
|
2026-04-30 17:12:09 +08:00
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 16px;
|
2026-04-12 09:56:18 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-30 17:12:09 +08:00
|
|
|
|
.citation-window-switch {
|
|
|
|
|
|
flex: 0 0 auto;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-12 09:56:18 +08:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 22:21:01 +08:00
|
|
|
|
.tracking-panel__empty {
|
|
|
|
|
|
padding: 28px 0 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-12 09:56:18 +08:00
|
|
|
|
.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 {
|
2026-05-01 11:46:35 +08:00
|
|
|
|
position: relative;
|
2026-04-12 09:56:18 +08:00
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-01 11:46:35 +08:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-12 09:56:18 +08:00
|
|
|
|
.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;
|
2026-04-30 17:12:09 +08:00
|
|
|
|
grid-template-columns: minmax(0, 1.3fr) minmax(0, 0.72fr) minmax(0, 0.72fr) minmax(0, 0.72fr);
|
2026-04-12 09:56:18 +08:00
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 16px;
|
|
|
|
|
|
padding: 20px 32px;
|
2026-04-30 17:12:09 +08:00
|
|
|
|
min-width: 560px;
|
2026-04-12 09:56:18 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.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) {
|
2026-04-30 17:12:09 +08:00
|
|
|
|
.tracking-citation-header {
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-12 09:56:18 +08:00
|
|
|
|
.citation-ranking-sheet__head,
|
|
|
|
|
|
.citation-ranking-sheet__row {
|
2026-04-30 17:12:09 +08:00
|
|
|
|
grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.7fr) minmax(0, 0.7fr) minmax(0, 0.7fr);
|
2026-04-12 09:56:18 +08:00
|
|
|
|
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>
|