Files
geo/apps/admin-web/src/views/TrackingView.vue
T
root 7abac1e9c4 feat: Implement Qwen adapter and enhance AI platform detection
- Added a new Qwen adapter to facilitate monitoring through Playwright, leveraging internal text/chat managers.
- Updated account detection logic to recognize Qwen sessions based on persisted cookies, improving binding reliability.
- Relaxed authentication requirements for monitor tasks, allowing anonymous execution for certain AI platforms.
- Enhanced the generic AI platform detection to include Qwen-specific logic, ensuring accurate session identification.
- Modified the monitoring dashboard to include runtime state indicating if the current user's desktop client is online.
- Updated various files including `account-binder.ts`, `runtime-controller.ts`, and `monitoring_service.go` to support new features and improvements.
2026-04-20 19:10:35 +08:00

1694 lines
46 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,
MonitoringTimeBucket,
} 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";
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: "通" },
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" },
...aiPlatformCatalog.map((platform) => ({
label: platform.label,
value: platform.id,
})),
] as const;
const trackingPlatformIds = new Set<string>(
aiPlatformCatalog.map((item) => item.id),
);
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 = normalizeTrackingPlatformId(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 = normalizeTrackingPlatformId(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 = normalizeTrackingPlatformId(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,
});
},
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 (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 ?? []) {
if (!trackingPlatformIds.has(account.platform) || account.deleted_at) {
continue;
}
const list = accountGroups.get(account.platform) ?? [];
list.push(account);
accountGroups.set(account.platform, list);
}
const breakdownMap = new Map(
(dashboardQuery.data.value?.platform_breakdown ?? []).map((item) => [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;
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(", ")})`,
};
});
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 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";
}
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>
<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>
<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" :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-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;
}
.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>