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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user