feat(admin-web): extract AI account management into its own page

Move the AI-platform account status cards out of TrackingView into a
dedicated AIAccountsView, registered at /tracking/ai-accounts with a nav
entry and route/nav i18n strings. Keeps the tracking dashboard focused
while giving accounts, authorization state and desktop runtime nodes a
first-class page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 14:57:16 +08:00
parent a02ed4e79f
commit a04d41b230
6 changed files with 1723 additions and 298 deletions
File diff suppressed because it is too large Load Diff
+12 -296
View File
@@ -9,7 +9,6 @@ import {
SearchOutlined,
} from '@ant-design/icons-vue'
import type {
DesktopAccountInfo,
MonitoringCitedArticle,
MonitoringHotQuestion,
MonitoringPlatformAuthorizationStatus,
@@ -25,12 +24,10 @@ import { useRoute, useRouter } from 'vue-router'
import ArticleDetailDrawer from '@/components/ArticleDetailDrawer.vue'
import PageHero from '@/components/PageHero.vue'
import { monitoringApi, tenantAccountsApi } from '@/lib/api'
import { resolveAccountCheckedAt, resolveAccountHealth } from '@/lib/desktop-account-runtime'
import { monitoringApi } from '@/lib/api'
import { formatDateTime } from '@/lib/display'
import { formatError } from '@/lib/errors'
import {
isMonitoringPlatformId,
normalizeMonitoringPlatformFilter,
normalizeMonitoringPlatformId,
} from '@/lib/monitoring-platforms'
@@ -62,19 +59,6 @@ type CitationRankingRow = {
glyph: string
}
type AIPlatformStatusCard = {
id: string
label: string
shortName: string
accent: string
description: string
account: DesktopAccountInfo | null
isClientOnline: boolean
sampleStatus: string
lastSampledAt: string | null
actualSampleCount: number
}
function formatTrackingBusinessDate(value: Date): string {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: trackingBusinessTimeZone,
@@ -366,11 +350,6 @@ const citationSummaryQuery = useQuery({
}),
})
const aiAccountsQuery = useQuery({
queryKey: ['tracking', 'ai-platform-accounts'],
queryFn: () => tenantAccountsApi.list(),
})
const collectNowMutation = useMutation({
mutationFn: async () => {
if (!selectedBrandId.value) {
@@ -475,61 +454,6 @@ const hotQuestionsPageInfo = computed(() => {
const hotQuestionsTotal = computed(() => hotQuestionsPageInfo.value?.total ?? 0)
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,
]),
)
const onlineAuthorizedPlatformIDs = new Set(
(dashboardQuery.data.value?.runtime.online_authorized_monitoring_platform_ids ?? [])
.map((item) => normalizeMonitoringPlatformId(item))
.filter((item): item is string => Boolean(item)),
)
return aiPlatformCatalog.map((platform) => {
const matchedAccounts = [...(accountGroups.get(platform.id) ?? [])].sort((left, right) => {
const leftOnline = left.client_online === true ? 1 : 0
const rightOnline = right.client_online === true ? 1 : 0
if (leftOnline !== rightOnline) {
return rightOnline - leftOnline
}
const leftAt = Date.parse(resolveAccountCheckedAt(left) ?? '') || 0
const rightAt = Date.parse(resolveAccountCheckedAt(right) ?? '') || 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,
isClientOnline:
onlineAuthorizedPlatformIDs.has(platform.id) ||
(!dashboardQuery.data.value &&
matchedAccounts.some((account) => account.client_online === true)),
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 [
@@ -820,48 +744,6 @@ function normalizeCitationWindowDays(value: unknown): number {
return numeric === 30 ? 30 : 7
}
function accountAuthLabel(account: DesktopAccountInfo | null): string {
if (!account) {
return '未绑定'
}
switch (resolveAccountHealth(account)) {
case 'live':
return '授权正常'
case 'captcha':
return '待处理'
case 'expired':
return '授权过期'
default:
return '风险观察'
}
}
function accountAuthColor(account: DesktopAccountInfo | null): string {
if (!account) {
return 'default'
}
switch (resolveAccountHealth(account)) {
case 'live':
return 'green'
case 'captcha':
return 'gold'
case 'expired':
return 'red'
default:
return 'orange'
}
}
function desktopStatusLabel(account: DesktopAccountInfo | null, isClientOnline: boolean): string {
if (!account?.client_id) {
return '未绑定桌面端'
}
return isClientOnline ? '客户端在线' : '客户端离线'
}
function formatCount(value: number | null | undefined): string {
if (value === null || value === undefined) {
return '--'
@@ -1075,7 +957,9 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
]"
>
<span v-if="(card.value ?? 0) > (card.previous ?? 0)" class="trend-arrow"></span>
<span v-else-if="(card.value ?? 0) < (card.previous ?? 0)" class="trend-arrow"></span>
<span v-else-if="(card.value ?? 0) < (card.previous ?? 0)" class="trend-arrow">
</span>
{{ (formatDelta(card.value, card.previous) ?? '').replace(/[+-]/, '') }}
</span>
</div>
@@ -1130,80 +1014,6 @@ 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>
</div>
</div>
<div class="ai-platform-status-grid">
<article
v-for="platform in aiPlatformStatusCards"
:key="platform.id"
class="ai-platform-status-card"
:style="{ '--platform-accent': platform.accent }"
>
<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, platform.isClientOnline) }}</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"
@@ -1462,7 +1272,7 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
? t('tracking.manualMarkedArticle')
: item.source_type === 'media_supply_order_item'
? t('tracking.mediaSupplySubmissionCitation')
: item.publish_platform
: item.publish_platform
}}
</span>
</div>
@@ -1579,105 +1389,6 @@ 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 #e2e8f0;
background: #ffffff;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.02);
}
.ai-platform-status-card:hover {
border-color: var(--platform-accent) !important;
box-shadow: 0 10px 20px -8px var(--platform-accent);
transform: translateY(-2px);
}
.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: #0f172a;
font-size: 15px;
font-weight: 600;
line-height: 1.2;
}
.ai-platform-status-card__identity p {
margin: 4px 0 0;
color: #64748b;
line-height: 1.5;
font-size: 12px;
}
.ai-platform-status-card__badge {
width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 10px;
border: 1px solid transparent;
font-size: 14px;
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: 8px 12px;
border-radius: 8px;
background: #f8fafc;
border: 1px solid #f1f5f9;
}
.ai-platform-status-card__row span {
color: #64748b;
font-size: 12px;
}
.ai-platform-status-card__row strong {
color: #0f172a;
font-size: 13px;
text-align: right;
word-break: break-all;
}
.tracking-citation-section {
display: flex;
flex-direction: column;
@@ -1715,7 +1426,10 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
border-radius: 16px;
background: #ffffff;
border: 1px solid rgba(226, 232, 240, 0.8) !important;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.02), 0 10px 15px -3px rgba(0, 0, 0, 0.03), 0 0 0 1px rgba(0, 0, 0, 0.01);
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.02),
0 10px 15px -3px rgba(0, 0, 0, 0.03),
0 0 0 1px rgba(0, 0, 0, 0.01);
}
.tracking-panel--citation {
@@ -1845,7 +1559,9 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
.metric-card:hover {
border-color: #3b82f6;
transform: translateY(-2px);
box-shadow: 0 10px 15px -3px rgba(59, 130, 246, 0.05), 0 4px 6px -2px rgba(59, 130, 246, 0.02);
box-shadow:
0 10px 15px -3px rgba(59, 130, 246, 0.05),
0 4px 6px -2px rgba(59, 130, 246, 0.02);
}
.metric-card__label {