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
@@ -82,6 +82,7 @@ const enUS = {
tracking: 'Tracking',
trackingDetail: 'Data Details',
trackingMarkedArticles: 'External Article Marks',
aiAccounts: 'AI Account Management',
contentManagement: 'Content Management',
knowledge: 'Knowledge Base',
images: 'Image Management',
@@ -270,6 +271,10 @@ const enUS = {
description:
'Manage manually marked external article URLs so they count in cited articles and citation rankings.',
},
aiAccounts: {
title: 'AI Account Management',
description: 'Review AI model accounts, authorization state, and desktop runtime nodes.',
},
trackingQuestion: {
title: 'Question Detail',
description:
+9 -2
View File
@@ -92,6 +92,7 @@ const zhCN = {
tracking: '数据追踪',
trackingDetail: '数据详情',
trackingMarkedArticles: '外部文章标记',
aiAccounts: 'AI 账号管理',
contentManagement: '内容管理',
knowledge: '知识库',
images: '图片管理',
@@ -269,6 +270,10 @@ const zhCN = {
title: '外部文章标记',
description: '维护用户手动标记的外部文章 URL,让它们参与引用文章和引用排行统计。',
},
aiAccounts: {
title: 'AI 账号管理',
description: '查看 AI 模型账号、授权状态和桌面运行节点。',
},
trackingQuestion: {
title: '问题详情',
description: '按问题查看大模型回答、引用来源、引用分析和回答引用。',
@@ -564,7 +569,8 @@ const zhCN = {
backToQuestions: '返回问题列表',
citationRanking: 'Citation Ranking',
citationRankingTitle: '引用排行',
citationRankingHint: '按发布外链、投稿外链和手动标记 URL 归因外部文章在模型引用来源中的出现次数与占比。',
citationRankingHint:
'按发布外链、投稿外链和手动标记 URL 归因外部文章在模型引用来源中的出现次数与占比。',
citationWindow7: '近 7 天',
citationWindow30: '近 30 天',
citationWindowLabel: '近 {days} 天',
@@ -697,7 +703,8 @@ const zhCN = {
},
trackingMarkedArticles: {
title: '外部文章标记',
description: '把无法自动对接的官网、媒体或第三方页面标记为外部文章,系统会在 6 个月内用于引用归因。',
description:
'把无法自动对接的官网、媒体或第三方页面标记为外部文章,系统会在 6 个月内用于引用归因。',
createAction: '标记文章',
createTitle: '标记外部文章',
editTitle: '编辑外部文章',
+6
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import {
ApiOutlined,
AppstoreOutlined,
BankOutlined,
BarChartOutlined,
@@ -384,6 +385,11 @@ const navSections = computed<NavSection[]>(() => {
label: t('nav.trackingMarkedArticles'),
icon: FileTextOutlined,
},
{
key: '/tracking/ai-accounts',
label: t('nav.aiAccounts'),
icon: ApiOutlined,
},
],
},
{
+10
View File
@@ -249,6 +249,16 @@ const router = createRouter({
navKey: '/tracking/marked-articles',
},
},
{
path: 'tracking/ai-accounts',
name: 'tracking-ai-accounts',
component: () => import('@/views/AIAccountsView.vue'),
meta: {
titleKey: 'route.aiAccounts.title',
descriptionKey: 'route.aiAccounts.description',
navKey: '/tracking/ai-accounts',
},
},
{
path: 'tracking/questions/:brandId/:questionId',
name: 'tracking-question-detail',
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 {