feat(desktop-ui): AI platforms multi-account binding and probe view

Rework the AI platforms view around multiple accounts per platform:
per-account auth-state classification, status/platform filtering, and an
inline health probe (verifyAccount) with pending state. Update the nav
description to reflect multi-account binding, health checks and auto
switching.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 14:17:27 +08:00
parent 2706c07381
commit 16e73438c4
2 changed files with 553 additions and 162 deletions
@@ -88,7 +88,7 @@ const navItems = computed(() => {
{
to: '/ai-platforms',
title: 'AI 平台',
description: 'AI 平台一平台一授权,按卡片查看状态与归属',
description: 'AI 模型多账号绑定、健康检查与自动切换',
count: accounts.filter((item) => monitoringIDs.has(item.platform)).length,
icon: RobotOutlined,
},
@@ -1,10 +1,13 @@
<script setup lang="ts">
import {
ApiOutlined,
ArrowRightOutlined,
DeleteOutlined,
LinkOutlined,
ReloadOutlined,
SafetyCertificateOutlined,
SearchOutlined,
SyncOutlined,
WarningOutlined,
} from '@ant-design/icons-vue'
import { notification } from 'ant-design-vue'
@@ -12,7 +15,7 @@ import { computed, ref } from 'vue'
import { useDesktopRuntime } from '../composables/useDesktopRuntime'
import { showClientActionError } from '../lib/client-errors'
import { formatDateTime, formatRelativeTime, formatVerifiedAtLabel } from '../lib/formatters'
import { formatRelativeTime, formatVerifiedAtLabel } from '../lib/formatters'
import { desktopMonitoringMediaCatalog } from '../lib/media-catalog'
import type { RuntimeAccount } from '../types'
@@ -20,9 +23,13 @@ type AccountRow = Readonly<Omit<RuntimeAccount, 'tags'>> & { tags: readonly stri
const { snapshot, refresh, refreshAccounts, probeAccount, loading } = useDesktopRuntime()
const selectedPlatform = ref('all')
const selectedStatus = ref('all')
const searchQuery = ref('')
const bindPendingPlatformId = ref<string | null>(null)
const openPendingAccountId = ref<string | null>(null)
const unbindPendingAccountId = ref<string | null>(null)
const probePendingAccountIds = ref(new Set<string>())
const batchProbePending = ref(false)
const batchProbeRunningAccountIds = ref<readonly string[]>([])
const batchProbeQueuedAccountIds = ref<readonly string[]>([])
@@ -39,8 +46,9 @@ const platformCards = computed(() =>
const matched = aiAccounts.value.filter((account) => account.platform === platform.id)
return {
...platform,
account: matched[0] ?? null,
duplicateCount: Math.max(0, matched.length - 1),
accounts: matched,
count: matched.length,
latestAccount: matched[0] ?? null,
}
}),
)
@@ -50,19 +58,81 @@ function isLocallyAuthorized(account: AccountRow | null): boolean {
}
const overview = computed(() => ({
authorized: platformCards.value.filter((platform) => isLocallyAuthorized(platform.account))
.length,
pending: platformCards.value.filter(
(platform) => platform.account && !isLocallyAuthorized(platform.account),
).length,
authorized: aiAccounts.value.filter((account) => isLocallyAuthorized(account)).length,
pending: aiAccounts.value.filter((account) => !isLocallyAuthorized(account)).length,
onlineClients: snapshot.value?.summary.onlineClients ?? 0,
}))
const boundAIAccounts = computed(() =>
platformCards.value
.map((platform) => platform.account)
.filter((account): account is AccountRow => Boolean(account)),
)
const boundAIAccounts = computed(() => aiAccounts.value)
type AccountAuthState = 'authorized' | 'expired' | 'attention'
const statusOptions = [
{ value: 'all', label: '全部状态' },
{ value: 'authorized', label: '已授权' },
{ value: 'attention', label: '待处理' },
{ value: 'expired', label: '授权过期' },
] as const
function authState(account: AccountRow): AccountAuthState {
switch (account.authState) {
case 'active':
return 'authorized'
case 'expired':
case 'revoked':
return 'expired'
default:
return 'attention'
}
}
function platformMeta(platform: string) {
return (
desktopMonitoringMediaCatalog.find((item) => item.id === platform) ?? {
id: platform,
label: platform,
shortName: 'AI',
accent: '#64748b',
loginUrl: null,
logoUrl: null,
category: 'monitor',
description: 'AI 模型账号',
}
)
}
const filteredAccounts = computed(() => {
const keyword = searchQuery.value.trim().toLowerCase()
return aiAccounts.value.filter((account) => {
if (selectedPlatform.value !== 'all' && account.platform !== selectedPlatform.value) {
return false
}
if (selectedStatus.value !== 'all') {
if (selectedStatus.value === 'attention' && authState(account) === 'authorized') {
return false
}
if (selectedStatus.value !== 'attention' && authState(account) !== selectedStatus.value) {
return false
}
}
if (!keyword) {
return true
}
return [account.displayName, account.platformUid, platformMeta(account.platform).label]
.join(' ')
.toLowerCase()
.includes(keyword)
})
})
const tableColumns = [
{ title: '账号 / UID', key: 'name', dataIndex: 'name' },
{ title: '模型平台', key: 'platform', dataIndex: 'platform', width: 150 },
{ title: '授权状态', key: 'status', dataIndex: 'status', width: 130 },
{ title: '本地会话', key: 'session', dataIndex: 'session', width: 130 },
{ title: '最近校验', key: 'verified', dataIndex: 'verified', width: 180 },
{ title: '操作', key: 'actions', align: 'right' as const, width: 180 },
]
const batchProbeButtonLabel = computed(() => {
if (!batchProbePending.value) {
@@ -191,6 +261,14 @@ function isReauthorizePending(account: AccountRow): boolean {
return bindPendingPlatformId.value === account.platform
}
function isProbePending(account: AccountRow): boolean {
return (
probePendingAccountIds.value.has(account.id) ||
account.probeState === 'queued' ||
account.probeState === 'probing'
)
}
function onlineLabel(account: AccountRow): string {
return account.online ? '客户端在线' : '客户端离线'
}
@@ -297,6 +375,24 @@ async function bindPlatform(platformId: string) {
}
}
function selectPlatform(platformId: string) {
selectedPlatform.value = platformId
}
async function verifyAccount(account: AccountRow) {
probePendingAccountIds.value = new Set([...probePendingAccountIds.value, account.id])
try {
await probeAccount(account.id)
await refresh()
} catch (error) {
showClientActionError('probe-account', error)
} finally {
const next = new Set(probePendingAccountIds.value)
next.delete(account.id)
probePendingAccountIds.value = next
}
}
async function openPlatform(account: AccountRow) {
openPendingAccountId.value = account.id
@@ -384,154 +480,249 @@ async function unbindPlatform(account: AccountRow) {
<section class="content-section">
<div class="section-header">
<h3 class="section-title">支持的大模型</h3>
<h3 class="section-title">选择平台进行授权</h3>
</div>
<div class="grid-layout">
<article
v-for="platform in platformCards"
:key="platform.id"
class="modern-card"
:class="platform.account ? 'is-bound' : 'is-unbound'"
class="modern-card media-card-clickable"
:class="[
selectedPlatform === platform.id ? 'is-active' : '',
platform.latestAccount ? 'is-bound' : 'is-unbound',
]"
@click="selectPlatform(platform.id)"
>
<div class="card-header">
<div class="brand-info">
<div
<span
class="brand-logo"
:style="{
backgroundColor: `${platform.accent}14`,
color: platform.accent,
border: `1px solid ${platform.accent}24`,
background: '#ffffff',
boxShadow: '0 2px 4px rgba(0,0,0,0.03), inset 0 0 0 1px #e2e8f0',
}"
>
{{ platform.shortName }}
</div>
<img
v-if="platform.logoUrl"
:src="platform.logoUrl"
:alt="platform.label"
class="brand-logo__image"
/>
<span v-else>{{ platform.shortName }}</span>
</span>
<div class="brand-text">
<h4>{{ platform.label }}</h4>
<p>{{ platform.description }}</p>
<p>{{ platform.count > 0 ? `${platform.count} 个账号` : '未绑定' }}</p>
</div>
</div>
<a-tag :color="authColor(platform.account)" class="status-tag">
{{ authLabel(platform.account) }}
</a-tag>
</div>
<div v-if="platform.account" class="card-body">
<div class="info-list">
<div class="info-row">
<span class="info-label">当前账号</span>
<span class="info-value primary-text">{{ platform.account.displayName }}</span>
</div>
<div class="info-row">
<span class="info-label">平台 UID</span>
<span class="info-value mono-text" :title="platform.account.platformUid">
{{ platform.account.platformUid }}
</span>
</div>
<div class="info-row">
<span class="info-label">本地会话</span>
<span class="info-value">{{ sessionLabel(platform.account) }}</span>
</div>
<div class="info-row">
<span class="info-label">最近同步</span>
<span class="info-value">{{ formatDateTime(platform.account.lastSyncAt) }}</span>
</div>
<div class="info-row">
<span class="info-label">校验状态</span>
<span class="info-value">{{ verificationLabel(platform.account) }}</span>
</div>
<div class="info-row">
<span class="info-label">下次巡检</span>
<span class="info-value">{{ nextProbeLabel(platform.account) }}</span>
</div>
<div class="card-footer">
<div class="media-card__status">
<span :class="['status-dot', platform.latestAccount ? 'success' : 'default']"></span>
<span>{{ platform.latestAccount ? '已接入' : '待接入' }}</span>
</div>
<div class="card-footer">
<div class="online-status">
<a-badge
:status="platform.account.online ? 'success' : 'default'"
:text="onlineLabel(platform.account)"
/>
<div class="action-group">
<a-button
v-if="platform.count > 0"
class="action-btn-publish"
:loading="bindPendingPlatformId === platform.id"
@click.stop="bindPlatform(platform.id)"
>
新增账号
<template #icon><ApiOutlined /></template>
</a-button>
<a-button
v-else
type="primary"
class="action-btn-bind modern-btn"
:loading="bindPendingPlatformId === platform.id"
@click.stop="bindPlatform(platform.id)"
>
绑定账号
<template #icon><ArrowRightOutlined /></template>
</a-button>
</div>
</div>
</article>
</div>
</section>
<section class="content-section">
<div class="hero-card account-list-card">
<div class="account-list-toolbar">
<div class="account-list-heading">
<h3 class="section-title">授权账号列表</h3>
</div>
<div class="filter-bar">
<a-select v-model:value="selectedPlatform" class="filter-select">
<a-select-option value="all">所有平台</a-select-option>
<a-select-option
v-for="platform in platformCards"
:key="platform.id"
:value="platform.id"
>
{{ platform.label }}
</a-select-option>
</a-select>
<a-select v-model:value="selectedStatus" class="filter-select">
<a-select-option
v-for="option in statusOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-select-option>
</a-select>
<a-input
v-model:value="searchQuery"
allow-clear
placeholder="搜索昵称、UID..."
class="filter-search"
>
<template #prefix><SearchOutlined class="filter-search__icon" /></template>
</a-input>
</div>
</div>
<a-table
class="modern-table"
:columns="tableColumns"
:data-source="filteredAccounts"
:pagination="false"
row-key="id"
:scroll="{ x: 980 }"
>
<template #emptyText>
<div class="table-empty">
<a-empty description="暂无匹配账号,请重置过滤或绑定新账号" />
</div>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<div class="user-avatar-wrap">
<span
class="avatar-circle"
:style="{ background: platformMeta(record.platform).accent }"
>
<span class="avatar-initial">
{{ record.displayName.slice(0, 1).toUpperCase() }}
</span>
</span>
<div class="info-stack">
<span class="title">{{ record.displayName }}</span>
<span class="subtitle mono-text" :title="record.platformUid">
{{ record.platformUid }}
</span>
<small v-if="accountActionAlert(record)" class="account-warning">
<WarningOutlined />
{{ accountActionAlert(record) }}
</small>
</div>
</div>
<div class="action-group">
</template>
<template v-else-if="column.key === 'platform'">
<div class="platform-display">
<span class="platform-logo-mini">
<img
v-if="platformMeta(record.platform).logoUrl"
:src="platformMeta(record.platform).logoUrl!"
:alt="platformMeta(record.platform).label"
/>
<span v-else :style="{ color: platformMeta(record.platform).accent }">
{{ platformMeta(record.platform).shortName }}
</span>
</span>
<span class="platform-display__name">
{{ platformMeta(record.platform).label }}
</span>
</div>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="authColor(record)" class="status-tag">
{{ authLabel(record) }}
</a-tag>
</template>
<template v-else-if="column.key === 'session'">
<div class="info-stack">
<span class="title">{{ sessionLabel(record) }}</span>
<span class="subtitle">{{ onlineLabel(record) }}</span>
</div>
</template>
<template v-else-if="column.key === 'verified'">
<div class="info-stack">
<span class="title">{{ verificationLabel(record) }}</span>
<span class="subtitle">下次 {{ nextProbeLabel(record) }}</span>
</div>
</template>
<template v-else-if="column.key === 'actions'">
<div class="table-actions">
<a-tooltip title="打开平台" placement="top">
<a-button
type="text"
class="action-btn"
:loading="openPendingAccountId === record.id"
@click="openPlatform(record)"
>
<template #icon><LinkOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip
v-if="platform.account.authState === 'expired'"
title="重新授权"
:title="record.authState === 'expired' ? '重新授权' : '重新校验'"
placement="top"
>
<a-button
type="text"
class="action-btn"
:loading="isReauthorizePending(platform.account)"
@click="bindPlatform(platform.id)"
:loading="
record.authState === 'expired'
? isReauthorizePending(record)
: isProbePending(record)
"
@click="
record.authState === 'expired'
? bindPlatform(record.platform)
: verifyAccount(record)
"
>
<template #icon><ArrowRightOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip title="打开平台" placement="top">
<a-button
type="text"
class="action-btn"
:loading="openPendingAccountId === platform.account.id"
@click="openPlatform(platform.account)"
>
<template #icon><LinkOutlined /></template>
<template #icon>
<ArrowRightOutlined v-if="record.authState === 'expired'" />
<SyncOutlined v-else />
</template>
</a-button>
</a-tooltip>
<a-popconfirm
title="确认解绑这个 AI 账号吗?"
ok-text="解绑"
cancel-text="取消"
@confirm="unbindPlatform(platform.account)"
placement="topRight"
@confirm="unbindPlatform(record)"
>
<a-tooltip title="解绑" placement="top">
<a-button
danger
type="text"
class="action-btn danger-btn"
:loading="unbindPendingAccountId === platform.account.id"
:loading="unbindPendingAccountId === record.id"
>
<template #icon><DeleteOutlined /></template>
</a-button>
</a-tooltip>
</a-popconfirm>
</div>
</div>
</div>
<div v-else class="card-empty-state">
<div class="empty-content">
<h5>当前未绑定账号</h5>
<p>首次授权会打开登录窗口绑定完成后后续监测统一在隐藏窗口里静默执行</p>
</div>
<a-button
type="primary"
class="auth-btn"
:loading="bindPendingPlatformId === platform.id"
@click="bindPlatform(platform.id)"
>
立即授权
<template #icon><ArrowRightOutlined /></template>
</a-button>
</div>
<div v-if="accountActionAlert(platform.account)" class="card-alert">
<a-alert type="warning" show-icon banner>
<template #message>
{{ accountActionAlert(platform.account) }}
</template>
<template #icon><WarningOutlined /></template>
</a-alert>
</div>
<div v-if="platform.duplicateCount > 0" class="card-alert">
<a-alert type="warning" show-icon banner>
<template #message>
发现额外 {{ platform.duplicateCount }} 个同平台账号缓存当前卡片只保留主账号展示
</template>
<template #icon><WarningOutlined /></template>
</a-alert>
</div>
</article>
</template>
</template>
</a-table>
</div>
</section>
</section>
@@ -661,8 +852,8 @@ async function unbindPlatform(account: AccountRow) {
.grid-layout {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 24px;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
}
/* Modern Card Design */
@@ -672,14 +863,17 @@ async function unbindPlatform(account: AccountRow) {
background: #ffffff;
border-radius: 20px;
border: 1px solid #e2e8f0;
padding: 28px;
min-height: 380px;
padding: 24px;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.02);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.modern-card:hover {
.media-card-clickable {
cursor: pointer;
}
.media-card-clickable:hover {
transform: translateY(-4px);
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.05),
@@ -687,12 +881,20 @@ async function unbindPlatform(account: AccountRow) {
border-color: #cbd5e1;
}
.modern-card.is-active {
border-color: #0ea5e9;
box-shadow:
0 0 0 1px #0ea5e9,
0 4px 6px -1px rgba(14, 165, 233, 0.1);
background: #f0f9ff;
}
/* Card Header */
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 32px;
margin-bottom: 8px;
}
.brand-info {
@@ -702,19 +904,25 @@ async function unbindPlatform(account: AccountRow) {
}
.brand-logo {
width: 52px;
height: 52px;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 14px;
font-size: 22px;
font-weight: 700;
border-radius: 12px;
font-size: 20px;
font-weight: 800;
}
.brand-logo__image {
width: 24px;
height: 24px;
object-fit: contain;
}
.brand-text h4 {
margin: 0;
font-size: 18px;
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
@@ -722,13 +930,14 @@ async function unbindPlatform(account: AccountRow) {
.brand-text p {
margin: 4px 0 0;
color: #64748b;
font-size: 14px;
font-size: 13px;
font-weight: 500;
}
.status-tag {
margin: 0;
border-radius: 9999px;
padding: 4px 12px;
padding: 2px 10px;
font-weight: 600;
font-size: 13px;
border: 1px solid transparent; /* adjusted dynamically by Ant normally, but let's reset slightly */
@@ -794,7 +1003,7 @@ async function unbindPlatform(account: AccountRow) {
align-items: center;
justify-content: space-between;
margin-top: auto;
padding-top: 20px;
padding-top: 16px;
border-top: 1px dashed #e2e8f0;
}
@@ -803,6 +1012,55 @@ async function unbindPlatform(account: AccountRow) {
gap: 8px;
}
.media-card__status {
display: flex;
align-items: center;
gap: 8px;
color: #64748b;
font-size: 13px;
font-weight: 500;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.status-dot.success {
background-color: #10b981;
}
.status-dot.default {
background-color: #cbd5e1;
}
.action-btn-publish {
height: 34px;
display: flex;
align-items: center;
gap: 6px;
padding: 0 14px;
border: 1px solid #bae6fd;
border-radius: 8px;
background: #e0f2fe;
color: #0284c7;
font-size: 13px;
font-weight: 600;
transition: all 0.2s ease;
}
.action-btn-publish:hover {
border-color: #7dd3fc;
background: #bae6fd;
color: #0369a1;
}
.action-btn-bind {
height: 34px;
border-radius: 8px;
}
.action-btn {
width: 36px;
height: 36px;
@@ -827,53 +1085,178 @@ async function unbindPlatform(account: AccountRow) {
border-color: #fee2e2;
}
/* Empty State */
.card-empty-state {
display: flex;
flex-direction: column;
justify-content: space-between;
flex: 1;
background: #f8fafc;
border-radius: 16px;
padding: 24px;
border: 1px dashed #cbd5e1;
.account-list-card {
padding-bottom: 0;
}
.empty-content h5 {
margin: 0;
font-size: 16px;
.account-list-toolbar {
display: flex;
align-items: center;
gap: 20px;
padding: 24px 32px 16px;
border-bottom: 1px solid #eef2f6;
}
.account-list-heading {
flex: 1;
min-width: 0;
}
.filter-bar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.filter-select {
width: 140px;
}
.filter-search {
width: 220px;
}
.filter-search__icon {
color: #94a3b8;
}
:deep(.modern-table) {
padding: 0 32px 32px;
}
:deep(.modern-table .ant-table-thead > tr > th) {
padding: 16px 12px;
border-bottom: 1px solid #e2e8f0;
background-color: transparent !important;
color: #64748b;
font-size: 13px;
font-weight: 600;
}
:deep(.modern-table .ant-table-tbody > tr > td) {
padding: 16px 12px;
border-bottom: 1px solid #f1f5f9;
color: #0f172a;
}
.empty-content p {
margin: 12px 0 0;
color: #64748b;
line-height: 1.6;
font-size: 14px;
:deep(.modern-table .ant-table-tbody > tr:last-child > td) {
border-bottom: none;
}
.auth-btn {
margin-top: 24px;
:deep(.modern-table .ant-table-tbody > tr:hover > td) {
background-color: #f8fafc !important;
}
.user-avatar-wrap {
min-width: 260px;
display: flex;
align-items: flex-start;
gap: 16px;
}
.avatar-circle {
width: 44px;
height: 44px;
border-radius: 10px;
font-weight: 600;
font-size: 15px;
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 50%;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.05);
}
.avatar-initial {
color: #ffffff;
font-size: 16px;
font-weight: 700;
}
.info-stack {
min-width: 0;
display: flex;
flex-direction: column;
}
.info-stack .title {
color: #0f172a;
font-size: 14px;
font-weight: 600;
}
.info-stack .subtitle {
max-width: 280px;
margin-top: 4px;
overflow: hidden;
color: #64748b;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.info-stack .mono-text {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.account-warning {
max-width: 460px;
margin-top: 6px;
color: #d97706;
font-size: 12px;
line-height: 1.45;
}
.platform-display {
display: flex;
align-items: center;
gap: 12px;
color: #0f172a;
font-size: 14px;
}
.platform-logo-mini {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 8px;
background: #ffffff;
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.05),
inset 0 0 0 1px #e2e8f0;
font-size: 11px;
font-weight: 700;
}
.platform-logo-mini img {
width: 20px;
height: 20px;
object-fit: contain;
}
.platform-display__name {
color: #0f172a;
font-size: 13px;
font-weight: 600;
}
.table-actions {
min-width: 152px;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
width: 100%;
}
.card-alert {
margin-top: 16px;
}
:deep(.card-alert .ant-alert) {
border-radius: 12px;
border: none;
background: #fffbeb;
.table-empty {
min-height: 180px;
display: flex;
align-items: center;
justify-content: center;
color: #94a3b8;
}
/* Responsiveness */
@@ -886,5 +1269,13 @@ async function unbindPlatform(account: AccountRow) {
flex-wrap: wrap;
gap: 24px;
}
.account-list-toolbar {
align-items: flex-start;
flex-direction: column;
}
.filter-bar {
width: 100%;
justify-content: flex-start;
}
}
</style>