Files
geo/apps/admin-web/src/views/TrackingView.vue
T

1465 lines
40 KiB
Vue
Raw Normal View History

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,
MonitoringHotQuestion,
MonitoringTimeBucket,
} 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 } from "@/lib/api";
import { formatDateTime } from "@/lib/display";
import { formatError } from "@/lib/errors";
import { kickoffMonitoringCycle } from "@/lib/monitoring-plugin";
import { loadPublisherRuntimeState } from "@/lib/publisher-runtime";
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_rate: number | null;
share: number;
color: string;
surface: string;
glyph: string;
};
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: "通" },
tongyi: { color: "#6d5efc", surface: "#f1efff", glyph: "通" },
tongyiqianwen: { color: "#6d5efc", surface: "#f1efff", glyph: "通" },
wenxin: { color: "#20c6d7", surface: "#ebfcff", glyph: "文" },
wenxinyiyan: { color: "#20c6d7", surface: "#ebfcff", glyph: "文" },
yuanbao: { color: "#19bf6b", surface: "#eafbf1", glyph: "元" },
kimi: { color: "#111827", surface: "#f3f4f6", glyph: "K" },
};
const trackingPlatformOptions = [
{ label: "全部", value: "all" },
{ label: "DeepSeek", value: "deepseek" },
{ label: "千问", value: "qwen" },
{ label: "豆包", value: "doubao" },
] as const;
const trackingPlatformIds = new Set<string>(
trackingPlatformOptions
.map((item) => item.value)
.filter((value) => value !== "all"),
);
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);
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);
const pluginPreparing = ref(false);
selectedPlatformId.value = normalizeTrackingPlatformId(selectedPlatformId.value);
2026-04-12 09:56:18 +08:00
selectedBusinessDate.value = normalizeTrackingBusinessDate(selectedBusinessDate.value) ?? trackingToday;
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 = normalizeTrackingPlatformId(value);
},
{ 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;
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;
const currentPlatformId = normalizeTrackingPlatformId(route.query.ai_platform_id);
2026-04-12 09:56:18 +08:00
const currentBusinessDate = normalizeTrackingBusinessDate(route.query.business_date) ?? trackingToday;
if (
currentBrandId === nextBrandId &&
currentKeywordId === nextKeywordId &&
currentPlatformId === (nextPlatformId ?? "all") &&
2026-04-12 09:56:18 +08:00
currentBusinessDate === nextBusinessDate
) {
return;
}
void router.replace({
name: "tracking",
query: {
...(nextBrandId ? { brand_id: nextBrandId } : {}),
...(nextKeywordId ? { keyword_id: nextKeywordId } : {}),
...(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"),
};
});
async function ensureMonitoringPluginReady(options?: { silent?: boolean }): Promise<void> {
pluginPreparing.value = true;
try {
const runtime = await loadPublisherRuntimeState();
if (!runtime.ping.installed) {
throw new Error("monitoring_plugin_required");
}
if (!runtime.pluginInstallationId) {
throw new Error("monitoring_plugin_register_required");
}
} catch (error) {
if (!options?.silent) {
throw error;
}
} finally {
pluginPreparing.value = false;
}
}
const dashboardQuery = useQuery({
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,
ai_platform_id: selectedPlatformId.value !== "all" ? selectedPlatformId.value : undefined,
2026-04-12 09:56:18 +08:00
business_date: selectedBusinessDate.value,
}),
});
const collectNowMutation = useMutation({
mutationFn: async () => {
if (!selectedKeywordId.value) {
throw new Error("tracking_keyword_required");
}
await ensureMonitoringPluginReady();
const collectNowResult = await monitoringApi.collectNow(selectedBrandId.value as number, {
keyword_id: selectedKeywordId.value,
});
try {
const kickoffResult = await kickoffMonitoringCycle();
return {
collectNowResult,
kickoffResult,
kickoffError: null,
};
} catch (error) {
return {
collectNowResult,
kickoffResult: null,
kickoffError: formatError(error),
};
}
},
onSuccess: ({ collectNowResult, kickoffResult, kickoffError }) => {
if (kickoffError) {
message.warning(`${collectNowResult.message},但后台采集启动失败:${kickoffError}`);
void dashboardQuery.refetch();
return;
}
if (kickoffResult?.status === "already_running") {
message.success(`${collectNowResult.message},插件后台已继续执行`);
void dashboardQuery.refetch();
return;
}
message.success(collectNowResult.message);
void dashboardQuery.refetch();
},
onError: (error) => {
if (error instanceof Error && error.message === "tracking_keyword_required") {
message.warning(t("tracking.collectKeywordRequired"));
return;
}
if (error instanceof Error && (error.message === "monitoring_plugin_required" || error.message === "monitoring_plugin_register_required")) {
message.warning(formatError(error));
return;
}
message.error(formatError(error));
},
});
const selectedBrand = computed<Brand | null>(() => {
return brandsQuery.data.value?.find((item) => item.id === selectedBrandId.value) ?? null;
});
const selectedDateHasData = computed(() => {
return (dashboardQuery.data.value?.overview.actual_sample_count ?? 0) > 0;
});
const hotQuestions = computed(() => {
return dashboardQuery.data.value?.hot_questions ?? [];
});
const selectedMetricCards = computed(() => {
const overview = dashboardQuery.data.value?.overview;
const buckets = dashboardQuery.data.value?.brand_time_buckets ?? [];
const points = selectedDateHasData.value ? buckets : [];
return [
{
key: "mention",
title: t("tracking.metrics.mentionRate"),
value: overview?.mention_rate ?? null,
previous: overview?.prev_snapshot_mention_rate ?? null,
points: points.map((item) => item.mention_rate),
},
{
key: "top1",
title: t("tracking.metrics.top1MentionRate"),
value: overview?.top1_mention_rate ?? null,
previous: overview?.prev_snapshot_top1_mention_rate ?? null,
points: points.map((item) => item.top1_mention_rate),
},
{
key: "first",
title: t("tracking.metrics.firstRecommendRate"),
value: overview?.first_recommend_rate ?? null,
previous: overview?.prev_snapshot_first_recommend_rate ?? null,
points: points.map((item) => item.first_recommend_rate),
},
{
key: "positive",
title: t("tracking.metrics.positiveMentionRate"),
value: overview?.positive_mention_rate ?? null,
previous: overview?.prev_snapshot_positive_mention_rate ?? null,
points: points.map((item) => item.positive_mention_rate),
},
];
});
const coverageSummary = computed(() => {
const overview = dashboardQuery.data.value?.overview;
if (!overview) {
return "";
}
if (!selectedDateHasData.value) {
return `${selectedBusinessDate.value} 暂无抓取数据`;
}
return t("tracking.coverageSummary", {
desired: overview.desired_sample_count,
actual: overview.actual_sample_count,
coverage: formatPercent(overview.coverage_rate),
});
});
const citationRankingRows = computed<CitationRankingRow[]>(() => {
if (!selectedDateHasData.value) {
return [];
}
const breakdown = dashboardQuery.data.value?.platform_breakdown ?? [];
const ranking = dashboardQuery.data.value?.citation_ranking ?? [];
const order = new Map<string, number>();
const rows = new Map<string, CitationRankingRow>();
breakdown.forEach((item, index) => {
order.set(item.ai_platform_id, index);
rows.set(item.ai_platform_id, {
ai_platform_id: item.ai_platform_id,
platform_name: item.platform_name,
cited_answer_count: null,
cited_article_count: null,
citation_rate: null,
share: 0,
...platformAppearance(item.ai_platform_id, item.platform_name),
});
});
ranking.forEach((item, index) => {
if (!order.has(item.ai_platform_id)) {
order.set(item.ai_platform_id, breakdown.length + index);
}
const existing = rows.get(item.ai_platform_id);
rows.set(item.ai_platform_id, {
ai_platform_id: item.ai_platform_id,
platform_name: item.platform_name || existing?.platform_name || item.ai_platform_id,
cited_answer_count: item.cited_answer_count,
cited_article_count: item.cited_article_count,
citation_rate: item.citation_rate,
share: 0,
...platformAppearance(item.ai_platform_id, item.platform_name || existing?.platform_name || item.ai_platform_id),
});
});
const list = Array.from(rows.values());
const totalAppearances = list.reduce((sum, item) => sum + Math.max(item.cited_answer_count ?? 0, 0), 0);
const enriched = list.map((item) => ({
...item,
share: totalAppearances > 0 ? Math.max(item.cited_answer_count ?? 0, 0) / totalAppearances : 0,
}));
const hasCitationData = enriched.some((item) => (item.cited_answer_count ?? 0) > 0);
return enriched.sort((left, right) => {
if (hasCitationData) {
const countDiff = (right.cited_answer_count ?? 0) - (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(() => {
if (!selectedDateHasData.value) {
return [];
}
return dashboardQuery.data.value?.cited_articles ?? [];
});
const citationChartStyle = computed(() => {
const segments = citationRankingRows.value.filter((item) => (item.cited_answer_count ?? 0) > 0);
if (!segments.length) {
return {
background: "linear-gradient(180deg, #eef4ff 0%, #e5ecff 100%)",
};
}
let offset = 0;
const gradientStops = segments.map((item) => {
const next = offset + item.share * 360;
const stop = `${item.color} ${offset.toFixed(2)}deg ${next.toFixed(2)}deg`;
offset = next;
return stop;
});
return {
background: `conic-gradient(${gradientStops.join(", ")})`,
};
});
void ensureMonitoringPluginReady({ silent: true });
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 } : {}),
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)}%`;
}
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 bucketPointClass(bucket: MonitoringTimeBucket, point: number | null): string {
if (point === null || point === undefined) {
return "metric-card__point metric-card__point--empty";
}
if (bucket.sample_status === "partial") {
return "metric-card__point metric-card__point--partial";
}
return "metric-card__point metric-card__point--solid";
}
function dayDotClass(bucket: MonitoringTimeBucket): string {
if (bucket.sample_status === "sampled") {
return "tracking-day-dot tracking-day-dot--sampled";
}
if (bucket.sample_status === "partial") {
return "tracking-day-dot tracking-day-dot--partial";
}
return "tracking-day-dot";
}
function formatTriggerSource(value: string): string {
return value === "manual" ? t("tracking.manual") : t("tracking.automatic");
}
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 = 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 normalizeTrackingPlatformId(value: unknown): string {
const normalized = normalizeQueryValue(value).toLowerCase();
if (!normalized) {
return "all";
}
return trackingPlatformIds.has(normalized) ? normalized : "all";
}
2026-04-12 09:56:18 +08:00
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().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().endOf("day");
const earliestAllowed = dayjs().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>
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>
<a-button
type="primary"
:disabled="!selectedBrandId || !selectedKeywordId"
:loading="collectNowMutation.isPending.value || pluginPreparing"
@click="collectNowMutation.mutate()"
>
<template #icon><ReloadOutlined /></template>
{{ t("tracking.collectNow") }}
</a-button>
</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>
<div class="metric-card__sparkline">
<span
v-for="(bucket, index) in dashboardQuery.data.value?.brand_time_buckets ?? []"
:key="`${card.key}-${bucket.date}`"
:class="bucketPointClass(bucket, card.points[index])"
/>
</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 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 dashboardQuery.data.value?.platform_breakdown ?? []"
: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-card>
</div>
<div class="tracking-grid tracking-grid--middle">
<a-card class="tracking-panel" :loading="dashboardQuery.isLoading.value">
<div class="tracking-panel__header">
<h3 class="panel-title">{{ t("tracking.coverageTitle") }}</h3>
<span class="tracking-panel__helper">{{ coverageSummary }}</span>
</div>
<a-progress
:percent="Math.round((dashboardQuery.data.value?.overview.coverage_rate ?? 0) * 100)"
:show-info="false"
stroke-color="#f97316"
trail-color="#fff1e6"
/>
<div class="tracking-day-row">
<a-tooltip
v-for="bucket in dashboardQuery.data.value?.brand_time_buckets ?? []"
:key="bucket.date"
:title="`${bucket.date} · ${statusLabel(bucket.sample_status)} · ${bucket.actual_sample_count}/${bucket.planned_sample_count}`"
>
<span :class="dayDotClass(bucket)" />
</a-tooltip>
</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 tracking-panel--citation" :loading="dashboardQuery.isLoading.value">
<div class="tracking-citation-section">
<div class="tracking-citation-header">
<h3 class="tracking-citation-title">{{ t("tracking.citationRankingTitle") }}</h3>
<p class="tracking-citation-desc">{{ t("tracking.citationRankingHint") }}</p>
</div>
<div class="citation-ranking-layout">
<div class="citation-ranking-visual">
<div class="citation-ranking-visual__canvas">
<div class="citation-ranking-visual__circle" :style="citationChartStyle" />
</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.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.cited_answer_count) }} {{ item.cited_answer_count != null ? '条' : '' }}</span>
<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.citedArticlesTitle") }}</h3>
</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>
<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-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 {
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;
}
.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-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;
}
.metric-card__sparkline {
display: flex;
align-items: center;
gap: 8px;
min-height: 22px;
margin-top: 14px;
}
.metric-card__point {
width: 8px;
height: 8px;
border-radius: 999px;
background: #1677ff;
}
.metric-card__point--partial {
opacity: 0.4;
}
.metric-card__point--empty {
background: transparent;
border: 1px solid #1677ff;
}
.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 {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.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.4fr) minmax(0, 0.8fr) minmax(0, 0.8fr);
align-items: center;
gap: 16px;
padding: 20px 32px;
}
.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;
}
.tracking-day-row {
display: flex;
align-items: center;
gap: 10px;
margin-top: 18px;
}
.tracking-day-dot {
width: 12px;
height: 12px;
border-radius: 999px;
border: 1px solid #fdba74;
background: transparent;
}
.tracking-day-dot--sampled {
background: #f97316;
border-color: #f97316;
}
.tracking-day-dot--partial {
background: linear-gradient(90deg, #f97316 50%, transparent 50%);
}
.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) {
.citation-ranking-sheet__head,
.citation-ranking-sheet__row {
grid-template-columns: minmax(0, 1.3fr) 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>