feat(monitoring): decouple AI platforms from media_platforms and expand catalog to 6

Add ai_platforms table + shared catalog (yuanbao/kimi/wenxin/deepseek/doubao/qwen),
drop FKs from platform_accounts and desktop_tasks to media_platforms, and wire
target_account_id + platform into DesktopTaskEvent so the desktop runtime no
longer has to infer them from local state. Desktop client gains generic AI page
detection for binding, drops stale-business-date monitor tasks at the edge, and
refactors AccountsView/AiPlatformsView around the shared catalog. Admin tracking
view surfaces per-platform sampling status cards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-20 15:40:18 +08:00
parent 25dad49ed3
commit 09295d11a1
21 changed files with 2073 additions and 843 deletions
+260 -7
View File
@@ -8,15 +8,17 @@ 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, 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 { brandsApi, monitoringApi, tenantAccountsApi } from "@/lib/api";
import { formatDateTime } from "@/lib/display";
import { formatError } from "@/lib/errors";
import { kickoffMonitoringCycle } from "@/lib/monitoring-plugin";
@@ -42,6 +44,18 @@ type CitationRankingRow = {
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,
@@ -74,15 +88,14 @@ const platformAppearanceMap: Record<string, { color: string; surface: string; gl
const trackingPlatformOptions = [
{ label: "全部", value: "all" },
{ label: "DeepSeek", value: "deepseek" },
{ label: "千问", value: "qwen" },
{ label: "豆包", value: "doubao" },
...aiPlatformCatalog.map((platform) => ({
label: platform.label,
value: platform.id,
})),
] as const;
const trackingPlatformIds = new Set<string>(
trackingPlatformOptions
.map((item) => item.value)
.filter((value) => value !== "all"),
aiPlatformCatalog.map((item) => item.id),
);
const selectedBrandId = useStorage<number | null>("tracking_selected_brand", null);
@@ -254,6 +267,11 @@ const dashboardQuery = useQuery({
}),
});
const aiAccountsQuery = useQuery({
queryKey: ["tracking", "ai-platform-accounts"],
queryFn: () => tenantAccountsApi.list(),
});
const collectNowMutation = useMutation({
mutationFn: async () => {
if (!selectedKeywordId.value) {
@@ -320,6 +338,43 @@ 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 ?? [];
@@ -499,6 +554,48 @@ function formatPercent(value: number | null | undefined): string {
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 "--";
@@ -752,6 +849,73 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
</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">
@@ -960,6 +1124,95 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
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;