Files
geo/apps/admin-web/src/views/TrackingView.vue
T
root 4f4f063200 refactor(monitoring): simplify brand dashboard and harden citation rendering
- drop brand_time_buckets and sparkline/coverage UI; dashboard now returns a single day
- compute mention rate from total actual samples instead of averaging per-platform rates
- canonicalize ai_platform_id across services and aggregate platform-overview snapshots
- strip mojibake and non-answer noise from callback payloads and question detail answers
- keep Kimi citations panel-only; still store raw search_results for audit
- render linked [n] inline citations for qwen/yuanbao answers with scroll-to-source behavior
- restrict立即采集 to today's business date with a clear i18n hint
- refresh content-citations into an ant-design table with truncation and hover styling

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:11:53 +08:00

1565 lines
43 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import {
ReloadOutlined,
} from "@ant-design/icons-vue";
import { useMutation, useQuery } from "@tanstack/vue-query";
import { useStorage } from "@vueuse/core";
import { message } from "ant-design-vue";
import dayjs from "dayjs";
import type {
Brand,
DesktopAccountInfo,
MonitoringHotQuestion,
} from "@geo/shared-types";
import { aiPlatformCatalog } from "@geo/shared-types";
import { computed, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
import PageHero from "@/components/PageHero.vue";
import { brandsApi, monitoringApi, tenantAccountsApi } from "@/lib/api";
import { formatDateTime } from "@/lib/display";
import { formatError } from "@/lib/errors";
import { isMonitoringPlatformId, normalizeMonitoringPlatformFilter, normalizeMonitoringPlatformId } from "@/lib/monitoring-platforms";
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const trackingMaxHistoryDays = 30;
const trackingBusinessTimeZone = "Asia/Shanghai";
const trackingToday = formatTrackingBusinessDate(new Date());
type CitationRankingRow = {
ai_platform_id: string;
platform_name: string;
cited_answer_count: number | null;
cited_article_count: number | null;
citation_rate: number | null;
share: number;
color: string;
surface: string;
glyph: string;
};
type AIPlatformStatusCard = {
id: string;
label: string;
shortName: string;
accent: string;
description: string;
account: DesktopAccountInfo | null;
sampleStatus: string;
lastSampledAt: string | null;
actualSampleCount: number;
};
function formatTrackingBusinessDate(value: Date): string {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: trackingBusinessTimeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(value);
const year = parts.find((part) => part.type === "year")?.value;
const month = parts.find((part) => part.type === "month")?.value;
const day = parts.find((part) => part.type === "day")?.value;
if (!year || !month || !day) {
throw new Error("tracking_business_date_format_failed");
}
return `${year}-${month}-${day}`;
}
const platformAppearanceMap: Record<string, { color: string; surface: string; glyph: string }> = {
doubao: { color: "#ff9348", surface: "#fff2e8", glyph: "豆" },
deepseek: { color: "#5d72ff", surface: "#eef1ff", glyph: "D" },
qwen: { color: "#276ef1", surface: "#edf4ff", glyph: "通" },
wenxin: { color: "#20c6d7", surface: "#ebfcff", glyph: "文" },
yuanbao: { color: "#19bf6b", surface: "#eafbf1", glyph: "元" },
kimi: { color: "#111827", surface: "#f3f4f6", glyph: "K" },
};
const trackingPlatformOptions = [
{ label: "全部", value: "all" },
...aiPlatformCatalog.map((platform) => ({
label: platform.label,
value: platform.id,
})),
] as const;
const selectedBrandId = useStorage<number | null>("tracking_selected_brand", null);
const selectedKeywordId = useStorage<number | null>("tracking_selected_keyword", null);
const selectedPlatformId = useStorage<string>("tracking_selected_ai_platform_id", "all");
const selectedBusinessDate = useStorage<string>("tracking_selected_business_date", trackingToday);
selectedPlatformId.value = normalizeMonitoringPlatformFilter(selectedPlatformId.value);
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 = normalizeMonitoringPlatformFilter(value);
},
{ immediate: true },
);
watch([selectedBrandId, selectedKeywordId, selectedPlatformId, selectedBusinessDate], ([brandId, keywordId, platformId, businessDate]) => {
const nextBrandId = brandId ? String(brandId) : undefined;
const nextKeywordId = keywordId ? String(keywordId) : undefined;
const nextPlatformId = platformId !== "all" ? platformId : undefined;
const nextBusinessDate = normalizeTrackingBusinessDate(businessDate) ?? trackingToday;
const currentBrandId = normalizeQueryValue(route.query.brand_id) || undefined;
const currentKeywordId = normalizeQueryValue(route.query.keyword_id) || undefined;
const currentPlatformId = normalizeMonitoringPlatformFilter(route.query.ai_platform_id);
const currentBusinessDate = normalizeTrackingBusinessDate(route.query.business_date) ?? trackingToday;
if (
currentBrandId === nextBrandId &&
currentKeywordId === nextKeywordId &&
currentPlatformId === (nextPlatformId ?? "all") &&
currentBusinessDate === nextBusinessDate
) {
return;
}
void router.replace({
name: "tracking",
query: {
...(nextBrandId ? { brand_id: nextBrandId } : {}),
...(nextKeywordId ? { keyword_id: nextKeywordId } : {}),
...(nextPlatformId ? { ai_platform_id: nextPlatformId } : {}),
business_date: nextBusinessDate,
},
});
});
const trackingWindow = computed(() => {
const end = dayjs(selectedBusinessDate.value);
const earliestAllowed = dayjs(trackingToday).subtract(trackingMaxHistoryDays - 1, "day").startOf("day");
const candidateFrom = end.subtract(trackingMaxHistoryDays - 1, "day");
const from = candidateFrom.isBefore(earliestAllowed, "day") ? earliestAllowed : candidateFrom;
return {
from: from.format("YYYY-MM-DD"),
to: end.format("YYYY-MM-DD"),
};
});
const dashboardQuery = useQuery({
queryKey: computed(() => [
"tracking",
"dashboard",
selectedBrandId.value,
selectedKeywordId.value,
selectedPlatformId.value,
selectedBusinessDate.value,
]),
enabled: computed(() => Boolean(selectedBrandId.value)),
queryFn: () =>
monitoringApi.dashboardComposite({
brand_id: selectedBrandId.value ?? undefined,
keyword_id: selectedKeywordId.value,
days: trackingMaxHistoryDays,
ai_platform_id: selectedPlatformId.value !== "all" ? selectedPlatformId.value : undefined,
business_date: selectedBusinessDate.value,
}),
});
const aiAccountsQuery = useQuery({
queryKey: ["tracking", "ai-platform-accounts"],
queryFn: () => tenantAccountsApi.list(),
});
const collectNowMutation = useMutation({
mutationFn: async () => {
if (!selectedBrandId.value) {
throw new Error("tracking_brand_required");
}
if (!selectedKeywordId.value) {
throw new Error("tracking_keyword_required");
}
return monitoringApi.collectNow(selectedBrandId.value, {
keyword_id: selectedKeywordId.value,
platform_ids:
selectedPlatformId.value !== "all" ? [selectedPlatformId.value] : undefined,
});
},
onSuccess: (collectNowResult) => {
message.success(collectNowResult.message);
void dashboardQuery.refetch();
},
onError: (error) => {
if (error instanceof Error && error.message === "tracking_brand_required") {
message.warning(t("tracking.collectBrandRequired"));
return;
}
if (error instanceof Error && error.message === "tracking_keyword_required") {
message.warning(t("tracking.collectKeywordRequired"));
return;
}
message.error(formatError(error));
},
});
const selectedBrand = computed<Brand | null>(() => {
return brandsQuery.data.value?.find((item) => item.id === selectedBrandId.value) ?? null;
});
const currentUserClientOnline = computed(() => {
return dashboardQuery.data.value?.runtime.current_user_client_online === true;
});
const collectNowDisabledReason = computed(() => {
if (!selectedBrandId.value) {
return t("tracking.collectBrandRequired");
}
if (!selectedKeywordId.value) {
return t("tracking.collectKeywordRequired");
}
if (selectedBusinessDate.value !== trackingToday) {
return t("tracking.collectTodayOnly");
}
if (dashboardQuery.isLoading.value && !dashboardQuery.data.value) {
return t("tracking.collectClientChecking");
}
if (!currentUserClientOnline.value) {
return t("tracking.collectClientOnlineRequired");
}
return null;
});
const selectedDateHasData = computed(() => {
return (dashboardQuery.data.value?.overview.actual_sample_count ?? 0) > 0;
});
const hotQuestions = computed(() => {
return dashboardQuery.data.value?.hot_questions ?? [];
});
const aiPlatformStatusCards = computed<AIPlatformStatusCard[]>(() => {
const accountGroups = new Map<string, DesktopAccountInfo[]>();
for (const account of aiAccountsQuery.data.value ?? []) {
const platformId = normalizeMonitoringPlatformId(account.platform);
if (!isMonitoringPlatformId(platformId) || account.deleted_at) {
continue;
}
const list = accountGroups.get(platformId) ?? [];
list.push(account);
accountGroups.set(platformId, list);
}
const breakdownMap = new Map(
(dashboardQuery.data.value?.platform_breakdown ?? []).map((item) => [normalizeMonitoringPlatformId(item.ai_platform_id), item]),
);
return aiPlatformCatalog.map((platform) => {
const matchedAccounts = [...(accountGroups.get(platform.id) ?? [])].sort((left, right) => {
const leftAt = Date.parse(left.verified_at ?? "") || 0;
const rightAt = Date.parse(right.verified_at ?? "") || 0;
return rightAt - leftAt;
});
const breakdown = breakdownMap.get(platform.id);
return {
id: platform.id,
label: platform.label,
shortName: platform.shortName,
accent: platform.accent,
description: platform.description,
account: matchedAccounts[0] ?? null,
sampleStatus: breakdown?.platform_sample_status ?? "pending",
lastSampledAt: breakdown?.last_sampled_at ?? null,
actualSampleCount: breakdown?.actual_sample_count ?? 0,
};
});
});
const selectedMetricCards = computed(() => {
const overview = dashboardQuery.data.value?.overview;
return [
{
key: "mention",
title: t("tracking.metrics.mentionRate"),
value: overview?.mention_rate ?? null,
previous: overview?.prev_snapshot_mention_rate ?? null,
},
{
key: "top1",
title: t("tracking.metrics.top1MentionRate"),
value: overview?.top1_mention_rate ?? null,
previous: overview?.prev_snapshot_top1_mention_rate ?? null,
},
{
key: "first",
title: t("tracking.metrics.firstRecommendRate"),
value: overview?.first_recommend_rate ?? null,
previous: overview?.prev_snapshot_first_recommend_rate ?? null,
},
{
key: "positive",
title: t("tracking.metrics.positiveMentionRate"),
value: overview?.positive_mention_rate ?? null,
previous: overview?.prev_snapshot_positive_mention_rate ?? null,
},
];
});
const 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) => {
const platformId = normalizeMonitoringPlatformId(item.ai_platform_id) || item.ai_platform_id;
order.set(platformId, index);
rows.set(platformId, {
ai_platform_id: platformId,
platform_name: item.platform_name,
cited_answer_count: null,
cited_article_count: null,
citation_rate: null,
share: 0,
...platformAppearance(platformId, item.platform_name),
});
});
ranking.forEach((item, index) => {
const platformId = normalizeMonitoringPlatformId(item.ai_platform_id) || item.ai_platform_id;
if (!order.has(platformId)) {
order.set(platformId, breakdown.length + index);
}
const existing = rows.get(platformId);
rows.set(platformId, {
ai_platform_id: platformId,
platform_name: item.platform_name || existing?.platform_name || platformId,
cited_answer_count: (existing?.cited_answer_count ?? 0) + (item.cited_answer_count ?? 0),
cited_article_count: (existing?.cited_article_count ?? 0) + (item.cited_article_count ?? 0),
citation_rate: item.citation_rate ?? existing?.citation_rate ?? null,
share: 0,
...platformAppearance(platformId, item.platform_name || existing?.platform_name || platformId),
});
});
const list = Array.from(rows.values());
const totalAppearances = list.reduce((sum, item) => sum + Math.max(item.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(", ")})`,
};
});
const heroDescription = computed(() => {
const overview = dashboardQuery.data.value?.overview;
if (!overview?.last_sampled_at) {
return `${selectedBusinessDate.value} 暂无抓取数据`;
}
return `最近一次采集数据:${formatDateTime(overview.last_sampled_at)}`;
});
function openQuestion(question: MonitoringHotQuestion): void {
if (!selectedBrandId.value) {
return;
}
void router.push({
name: "tracking-question-detail",
params: {
brandId: String(selectedBrandId.value),
questionId: String(question.question_id),
},
query: {
question_hash: question.question_hash,
question_text: question.question_text,
business_date: selectedBusinessDate.value,
date_from: selectedBusinessDate.value,
date_to: selectedBusinessDate.value,
...(selectedPlatformId.value !== "all" ? { ai_platform_id: selectedPlatformId.value } : {}),
...(selectedKeywordId.value ? { keyword_id: String(selectedKeywordId.value) } : {}),
},
});
}
function formatPercent(value: number | null | undefined): string {
if (value === null || value === undefined) {
return "--";
}
return `${(value * 100).toFixed(value < 0.1 ? 1 : 0)}%`;
}
function accountAuthLabel(account: DesktopAccountInfo | null): string {
if (!account) {
return "未绑定";
}
switch (account.health) {
case "live":
return "授权正常";
case "captcha":
return "待处理";
case "expired":
return "授权过期";
default:
return "风险观察";
}
}
function accountAuthColor(account: DesktopAccountInfo | null): string {
if (!account) {
return "default";
}
switch (account.health) {
case "live":
return "green";
case "captcha":
return "gold";
case "expired":
return "red";
default:
return "orange";
}
}
function desktopStatusLabel(account: DesktopAccountInfo | null): string {
if (!account?.client_id) {
return "未绑定桌面端";
}
return account.client_online ? "客户端在线" : "客户端离线";
}
function formatCount(value: number | null | undefined): string {
if (value === null || value === undefined) {
return "--";
}
return String(value);
}
function formatDelta(current: number | null | undefined, previous: number | null | undefined): string | null {
if (current === null || current === undefined || previous === null || previous === undefined) {
return null;
}
const diff = current - previous;
if (Math.abs(diff) < 0.001) {
return t("tracking.noChange");
}
const prefix = diff > 0 ? "+" : "";
return `${prefix}${(diff * 100).toFixed(1)}%`;
}
function statusTagColor(status: string): string {
switch (status) {
case "sampled":
return "green";
case "pending":
return "gold";
case "not_logged_in":
return "default";
case "unavailable":
return "red";
default:
return "default";
}
}
function statusLabel(status: string): string {
return t(`tracking.status.${status}`);
}
function platformAppearance(aiPlatformId: string, platformName: string) {
const normalizedId = normalizeMonitoringPlatformId(aiPlatformId) || aiPlatformId.toLowerCase().replace(/[^a-z0-9]/g, "");
const matched = platformAppearanceMap[normalizedId];
if (matched) {
return matched;
}
const glyph = platformName.trim().slice(0, 1).toUpperCase() || "?";
return {
color: "#f5b13d",
surface: "#fff7e8",
glyph,
};
}
function parseNumericQuery(value: unknown): number | null {
const raw = Array.isArray(value) ? value[0] : value;
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return parsed;
}
function normalizeQueryValue(value: unknown): string {
return String(Array.isArray(value) ? value[0] ?? "" : value ?? "").trim();
}
function normalizeTrackingBusinessDate(value: unknown): string | null {
const normalized = normalizeQueryValue(value);
if (!normalized) {
return null;
}
const parsed = dayjs(normalized);
if (!parsed.isValid() || parsed.format("YYYY-MM-DD") !== normalized) {
return null;
}
const today = dayjs().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>
<div class="tracking-action-item">
<span class="tracking-action-label">品牌</span>
<a-select
v-model:value="selectedBrandId"
class="tracking-select"
:placeholder="t('tracking.brandPlaceholder')"
:options="(brandsQuery.data.value ?? []).map((item) => ({ label: item.name, value: item.id }))"
/>
</div>
<div class="tracking-action-item">
<span class="tracking-action-label">关键词</span>
<a-select
v-model:value="selectedKeywordId"
allow-clear
class="tracking-select"
:placeholder="t('tracking.keywordPlaceholder')"
:options="(keywordsQuery.data.value ?? []).map((item) => ({ label: item.name, value: item.id }))"
/>
</div>
<div class="tracking-action-item">
<span class="tracking-action-label">日期</span>
<a-date-picker
v-model:value="selectedBusinessDate"
class="tracking-date-picker"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
:allow-clear="false"
:disabled-date="disableTrackingDate"
/>
</div>
<a-tooltip :title="collectNowDisabledReason ?? undefined">
<span>
<a-button
type="primary"
:disabled="Boolean(collectNowDisabledReason)"
:loading="collectNowMutation.isPending.value"
@click="collectNowMutation.mutate()"
>
<template #icon><ReloadOutlined /></template>
{{ t("tracking.collectNow") }}
</a-button>
</span>
</a-tooltip>
</a-space>
</template>
</PageHero>
<a-alert
v-if="dashboardQuery.error.value"
type="error"
show-icon
:message="formatError(dashboardQuery.error.value)"
class="tracking-view__alert"
/>
<div class="tracking-grid tracking-grid--top">
<a-card class="tracking-panel tracking-panel--overview" :loading="dashboardQuery.isLoading.value">
<div class="tracking-panel__header">
<h3 class="panel-title">{{ selectedBrand?.name ?? t("common.noData") }}</h3>
<a-tag :color="selectedDateHasData ? (dashboardQuery.data.value?.overview.confidence_level === 'high' ? 'green' : dashboardQuery.data.value?.overview.confidence_level === 'medium' ? 'gold' : 'default') : 'default'">
{{ selectedDateHasData ? t(`tracking.confidence.${dashboardQuery.data.value?.overview.confidence_level ?? 'low'}`) : '暂无抓取' }}
</a-tag>
</div>
<div class="metric-grid tracking-metric-grid">
<article v-for="card in selectedMetricCards" :key="card.key" class="metric-card">
<p class="metric-card__label">{{ card.title }}</p>
<div class="metric-card__value-row">
<strong>{{ formatPercent(card.value) }}</strong>
<span v-if="formatDelta(card.value, card.previous)" :class="['metric-card__delta', (card.value ?? 0) >= (card.previous ?? 0) ? 'metric-card__delta--up' : 'metric-card__delta--down']">
{{ formatDelta(card.value, card.previous) }}
</span>
</div>
</article>
</div>
</a-card>
<a-card class="tracking-panel" :loading="dashboardQuery.isLoading.value">
<div class="tracking-panel__header">
<h3 class="panel-title">{{ t("tracking.platformMatrixTitle") }}</h3>
</div>
<div 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>
<a-card class="tracking-panel tracking-panel--ai-platforms" :loading="dashboardQuery.isLoading.value || aiAccountsQuery.isLoading.value">
<div class="tracking-panel__header">
<div>
<h3 class="panel-title">AI 平台状态</h3>
<span class="tracking-panel__helper">
只读展示 desktop-client 的绑定授权和当前采样状态授权入口放在桌面端抓取过程静默执行
</span>
</div>
</div>
<div class="ai-platform-status-grid">
<article
v-for="platform in aiPlatformStatusCards"
:key="platform.id"
class="ai-platform-status-card"
>
<div class="ai-platform-status-card__head">
<div class="ai-platform-status-card__identity">
<span
class="ai-platform-status-card__badge"
:style="{ backgroundColor: `${platform.accent}14`, color: platform.accent, borderColor: `${platform.accent}30` }"
>
{{ platform.shortName }}
</span>
<div>
<strong>{{ platform.label }}</strong>
<p>{{ platform.description }}</p>
</div>
</div>
<a-tag :color="accountAuthColor(platform.account)">
{{ accountAuthLabel(platform.account) }}
</a-tag>
</div>
<div class="ai-platform-status-card__meta">
<div class="ai-platform-status-card__row">
<span>绑定状态</span>
<strong>{{ platform.account ? "已绑定" : "未绑定" }}</strong>
</div>
<div class="ai-platform-status-card__row">
<span>客户端</span>
<strong>{{ desktopStatusLabel(platform.account) }}</strong>
</div>
<div class="ai-platform-status-card__row">
<span>采样状态</span>
<a-tag :color="statusTagColor(platform.sampleStatus)">
{{ statusLabel(platform.sampleStatus) }}
</a-tag>
</div>
<div class="ai-platform-status-card__row">
<span>最近采样</span>
<strong>{{ platform.lastSampledAt ? formatDateTime(platform.lastSampledAt) : "--" }}</strong>
</div>
<div class="ai-platform-status-card__row">
<span>样本数</span>
<strong>{{ formatCount(platform.actualSampleCount) }}</strong>
</div>
<div v-if="platform.account" class="ai-platform-status-card__row">
<span>账号</span>
<strong>{{ platform.account.display_name }}</strong>
</div>
</div>
</article>
</div>
</a-card>
<div class="tracking-grid tracking-grid--middle">
<a-card class="tracking-panel tracking-panel--citation" :loading="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.hotQuestionsTitle") }}</h3>
<span class="tracking-panel__helper">{{ t("tracking.hotQuestionsHint") }}</span>
</div>
<div v-if="hotQuestions.length" class="question-list">
<button
v-for="question in hotQuestions"
:key="`${question.question_id}-${question.question_hash}`"
type="button"
class="question-list__item"
@click="openQuestion(question)"
>
<div>
<strong>{{ question.question_text }}</strong>
<p>{{ t("tracking.metrics.mentionRate") }} {{ formatPercent(question.mention_rate) }}</p>
</div>
<span class="question-list__time">{{ question.last_sampled_at ? formatDateTime(question.last_sampled_at) : '--' }}</span>
</button>
</div>
<a-empty v-else :description="t('tracking.noQuestions')" />
</a-card>
</div>
<div class="tracking-grid tracking-grid--bottom">
<a-card class="tracking-panel" :loading="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-panel--ai-platforms {
overflow: hidden;
}
.ai-platform-status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
margin-top: 12px;
}
.ai-platform-status-card {
display: flex;
flex-direction: column;
gap: 14px;
padding: 16px;
border-radius: 16px;
border: 1px solid #e6edf5;
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
}
.ai-platform-status-card__head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.ai-platform-status-card__identity {
display: flex;
gap: 12px;
align-items: flex-start;
}
.ai-platform-status-card__identity strong {
display: block;
color: #111827;
font-size: 16px;
line-height: 1.2;
}
.ai-platform-status-card__identity p {
margin: 6px 0 0;
color: #667085;
line-height: 1.5;
font-size: 12px;
}
.ai-platform-status-card__badge {
width: 42px;
height: 42px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 14px;
border: 1px solid transparent;
font-size: 16px;
font-weight: 700;
flex-shrink: 0;
}
.ai-platform-status-card__meta {
display: flex;
flex-direction: column;
gap: 10px;
}
.ai-platform-status-card__row {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
padding: 10px 12px;
border-radius: 12px;
background: #f8fafc;
}
.ai-platform-status-card__row span {
color: #667085;
font-size: 12px;
}
.ai-platform-status-card__row strong {
color: #111827;
font-size: 13px;
text-align: right;
word-break: break-all;
}
.tracking-citation-section {
display: flex;
flex-direction: column;
padding: 0;
margin-top: 10px;
}
.tracking-citation-section.is-loading {
opacity: 0.6;
pointer-events: none;
}
.tracking-citation-header {
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;
}
.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;
}
.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>