Files
geo/apps/desktop-client/src/renderer/views/AiPlatformsView.vue
T
root aa96143754 style: format web apps with prettier and sort imports
Apply repo-wide Prettier/lint normalization across admin-web,
desktop-client and ops-web: single quotes, no semicolons, trailing
commas, consistent line wrapping, and import ordering. Also drop an
unused brand-logo import in DesktopShell.vue.

No behavior changes — formatting only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 11:56:18 +08:00

891 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import {
ArrowRightOutlined,
DeleteOutlined,
LinkOutlined,
ReloadOutlined,
SafetyCertificateOutlined,
WarningOutlined,
} from '@ant-design/icons-vue'
import { notification } from 'ant-design-vue'
import { computed, ref } from 'vue'
import { useDesktopRuntime } from '../composables/useDesktopRuntime'
import { showClientActionError } from '../lib/client-errors'
import { formatDateTime, formatRelativeTime, formatVerifiedAtLabel } from '../lib/formatters'
import { desktopMonitoringMediaCatalog } from '../lib/media-catalog'
import type { RuntimeAccount } from '../types'
type AccountRow = Readonly<Omit<RuntimeAccount, 'tags'>> & { tags: readonly string[] }
const { snapshot, refresh, refreshAccounts, probeAccount, loading } = useDesktopRuntime()
const bindPendingPlatformId = ref<string | null>(null)
const openPendingAccountId = ref<string | null>(null)
const unbindPendingAccountId = ref<string | null>(null)
const batchProbePending = ref(false)
const batchProbeRunningAccountIds = ref<readonly string[]>([])
const batchProbeQueuedAccountIds = ref<readonly string[]>([])
const batchProbeTotalCount = ref(0)
const aiAccounts = computed(() =>
(snapshot.value?.accounts ?? []).filter((account) =>
desktopMonitoringMediaCatalog.some((platform) => platform.id === account.platform),
),
)
const platformCards = computed(() =>
desktopMonitoringMediaCatalog.map((platform) => {
const matched = aiAccounts.value.filter((account) => account.platform === platform.id)
return {
...platform,
account: matched[0] ?? null,
duplicateCount: Math.max(0, matched.length - 1),
}
}),
)
function isLocallyAuthorized(account: AccountRow | null): boolean {
return account?.authState === 'active'
}
const overview = computed(() => ({
authorized: platformCards.value.filter((platform) => isLocallyAuthorized(platform.account))
.length,
pending: platformCards.value.filter(
(platform) => platform.account && !isLocallyAuthorized(platform.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 batchProbeButtonLabel = computed(() => {
if (!batchProbePending.value) {
return '一键检测账号'
}
const finishedCount =
batchProbeTotalCount.value -
batchProbeRunningAccountIds.value.length -
batchProbeQueuedAccountIds.value.length
return `检测中 ${Math.max(0, finishedCount)}/${batchProbeTotalCount.value}`
})
function authLabel(account: AccountRow | null) {
if (!account) {
return '未绑定'
}
switch (account.authState) {
case 'active':
return '授权正常'
case 'challenge_required':
return account.authReason === 'risk_control' ? '触发风控' : '需人工验证'
case 'expired':
return '授权过期'
case 'expiring_soon':
return account.probeState === 'network_error' ? '最近校验失败' : '待重新校验'
case 'revoked':
return '已停用'
default:
return account.probeState === 'queued'
? '等待校验'
: account.probeState === 'probing'
? '校验中'
: '待校验'
}
}
function authColor(account: AccountRow | null) {
if (!account) {
return 'default'
}
switch (account.authState) {
case 'active':
return 'success'
case 'challenge_required':
return 'warning'
case 'expired':
case 'revoked':
return 'error'
case 'expiring_soon':
return account.probeState === 'network_error' ? 'warning' : 'default'
default:
return account.probeState === 'queued' || account.probeState === 'probing'
? 'processing'
: 'default'
}
}
function accountActionAlert(account: AccountRow | null): string | null {
if (!account) {
return null
}
if (account.authState === 'expired') {
return '当前账号授权已过期,相关任务会暂停执行。请点击“重新授权”完成登录后再继续。'
}
if (account.authState !== 'challenge_required') {
return null
}
if (account.authReason === 'risk_control' && account.platform === 'doubao') {
return '豆包账号疑似触发风控,监控已暂停。请点击“打开平台”,在前台完成验证或等待限制解除后,再刷新状态。'
}
if (account.authReason === 'risk_control') {
return '当前账号疑似触发平台限制,相关任务已暂停。请先打开平台处理验证后,再刷新状态。'
}
return '当前账号需要人工验证,相关任务会暂停执行。请先打开平台完成验证后,再刷新状态。'
}
function verificationLabel(account: AccountRow): string {
switch (account.authState) {
case 'expired':
return '需要重新授权'
case 'revoked':
return '已停用'
case 'challenge_required':
return account.authReason === 'risk_control' ? '触发风控,需处理' : '需要人工验证'
}
if (account.lastVerifiedAt) {
return formatVerifiedAtLabel(account.lastVerifiedAt)
}
if (account.probeState === 'queued') {
return '等待首轮校验'
}
if (account.probeState === 'probing') {
return '正在执行首轮校验'
}
return '尚未完成校验'
}
function nextProbeLabel(account: AccountRow): string {
if (!account.nextProbeAt) {
return '未排程'
}
return formatRelativeTime(account.nextProbeAt)
}
function sessionLabel(account: AccountRow): string {
switch (account.sessionState) {
case 'hot':
return '活跃会话'
case 'warm':
return '已缓存'
default:
return '冷启动'
}
}
function isReauthorizePending(account: AccountRow): boolean {
return bindPendingPlatformId.value === account.platform
}
function onlineLabel(account: AccountRow): string {
return account.online ? '客户端在线' : '客户端离线'
}
function showActionNotification(type: 'success' | 'info', title: string, description: string) {
notification[type]({
message: title,
description,
placement: 'topRight',
duration: 3.2,
})
}
async function runLimitedAccountProbes(
accounts: readonly AccountRow[],
concurrency = 2,
): Promise<Error[]> {
const queue = [...accounts]
let cursor = 0
const failures: Error[] = []
const runNext = async () => {
while (cursor < queue.length) {
const account = queue[cursor]
cursor += 1
batchProbeQueuedAccountIds.value = batchProbeQueuedAccountIds.value.filter(
(accountId) => accountId !== account.id,
)
batchProbeRunningAccountIds.value = [...batchProbeRunningAccountIds.value, account.id]
try {
await probeAccount(account.id)
} catch (error) {
failures.push(error instanceof Error ? error : new Error(String(error)))
} finally {
batchProbeRunningAccountIds.value = batchProbeRunningAccountIds.value.filter(
(accountId) => accountId !== account.id,
)
}
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, queue.length) }, () => runNext()))
return failures
}
async function probeAllAIAccounts() {
if (batchProbePending.value) {
return
}
const accounts = boundAIAccounts.value
if (accounts.length === 0) {
showActionNotification('info', '暂无可检测账号', '请先绑定至少一个 AI 平台账号。')
return
}
batchProbePending.value = true
batchProbeRunningAccountIds.value = []
batchProbeQueuedAccountIds.value = accounts.map((account) => account.id)
batchProbeTotalCount.value = accounts.length
try {
const failures = await runLimitedAccountProbes(accounts, 2)
await refresh()
if (failures.length > 0) {
notification.warning({
message: '检测完成,部分失败',
description: `已检测 ${accounts.length} 个 AI 平台账号,其中 ${failures.length} 个检测失败。`,
placement: 'topRight',
duration: 4,
})
return
}
showActionNotification('success', '检测完成', `已检测 ${accounts.length} 个 AI 平台账号。`)
} catch (error) {
showClientActionError('probe-account', error)
await refresh()
} finally {
batchProbePending.value = false
batchProbeRunningAccountIds.value = []
batchProbeQueuedAccountIds.value = []
batchProbeTotalCount.value = 0
}
}
async function bindPlatform(platformId: string) {
bindPendingPlatformId.value = platformId
try {
const account = await window.desktopBridge.app.bindPublishAccount(platformId)
const platformLabel =
desktopMonitoringMediaCatalog.find((item) => item.id === platformId)?.label ?? platformId
showActionNotification(
'success',
'绑定成功',
`${account.display_name} 已绑定到 ${platformLabel}`,
)
await refresh()
} catch (error) {
showClientActionError('bind-account', error)
} finally {
bindPendingPlatformId.value = null
}
}
async function openPlatform(account: AccountRow) {
openPendingAccountId.value = account.id
try {
await window.desktopBridge.app.openPublishAccountConsole({
id: account.id,
platform: account.platform,
platformUid: account.platformUid,
displayName: account.displayName,
})
} catch (error) {
showClientActionError('open-console', error)
} finally {
openPendingAccountId.value = null
}
}
async function unbindPlatform(account: AccountRow) {
unbindPendingAccountId.value = account.id
try {
await window.desktopBridge.app.unbindPublishAccount(account.id, account.syncVersion)
showActionNotification(
'info',
'解绑成功',
`${account.displayName} 已从 ${desktopMonitoringMediaCatalog.find((item) => item.id === account.platform)?.label ?? account.platform} 移除`,
)
await refreshAccounts()
} catch (error) {
showClientActionError('unbind-account', error)
} finally {
unbindPendingAccountId.value = null
}
}
</script>
<template>
<section class="page-container">
<section class="hero-card">
<div class="hero-header">
<div class="hero-title">
<p class="eyebrow">AI PLATFORMS</p>
<h2>AI 平台管理</h2>
</div>
<div class="hero-actions">
<a-button
type="primary"
class="modern-btn"
:loading="batchProbePending"
:disabled="loading || boundAIAccounts.length === 0"
@click="probeAllAIAccounts"
>
<template #icon><SafetyCertificateOutlined /></template>
{{ batchProbeButtonLabel }}
</a-button>
<a-button
type="primary"
ghost
class="modern-btn"
:loading="loading"
:disabled="batchProbePending"
@click="refreshAccounts"
>
<template #icon><ReloadOutlined /></template>
刷新状态
</a-button>
</div>
</div>
<div class="stats-strip">
<div class="stat-card">
<div class="stat-label">已授权</div>
<div class="stat-value">{{ overview.authorized }}</div>
</div>
<div class="stat-card">
<div class="stat-label">待处理</div>
<div class="stat-value">{{ overview.pending }}</div>
</div>
<div class="stat-card">
<div class="stat-label">在线客户端</div>
<div class="stat-value">{{ overview.onlineClients }}</div>
</div>
</div>
</section>
<section class="content-section">
<div class="section-header">
<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'"
>
<div class="card-header">
<div class="brand-info">
<div
class="brand-logo"
:style="{
backgroundColor: `${platform.accent}14`,
color: platform.accent,
border: `1px solid ${platform.accent}24`,
}"
>
{{ platform.shortName }}
</div>
<div class="brand-text">
<h4>{{ platform.label }}</h4>
<p>{{ platform.description }}</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>
<div class="card-footer">
<div class="online-status">
<a-badge
:status="platform.account.online ? 'success' : 'default'"
:text="onlineLabel(platform.account)"
/>
</div>
<div class="action-group">
<a-tooltip
v-if="platform.account.authState === 'expired'"
title="重新授权"
placement="top"
>
<a-button
type="text"
class="action-btn"
:loading="isReauthorizePending(platform.account)"
@click="bindPlatform(platform.id)"
>
<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>
</a-button>
</a-tooltip>
<a-popconfirm
title="确认解绑这个 AI 账号吗?"
ok-text="解绑"
cancel-text="取消"
@confirm="unbindPlatform(platform.account)"
>
<a-tooltip title="解绑" placement="top">
<a-button
type="text"
class="action-btn danger-btn"
:loading="unbindPendingAccountId === platform.account.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>
</div>
</section>
</section>
</template>
<style scoped>
.page-container {
display: flex;
flex-direction: column;
gap: 32px;
padding-bottom: 40px;
width: 100%;
max-width: none;
box-sizing: border-box;
}
/* Hero Section */
.hero-card {
background: #ffffff;
border-radius: 20px;
border: 1px solid #eef2f6;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.02),
0 2px 4px -2px rgba(0, 0, 0, 0.01);
overflow: hidden;
}
.hero-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 36px 40px;
}
.hero-actions {
display: flex;
align-items: center;
gap: 10px;
}
.eyebrow {
margin: 0 0 12px;
color: #64748b;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.hero-title h2 {
margin: 0;
font-size: 32px;
font-weight: 800;
color: #0f172a;
letter-spacing: -0.02em;
}
.summary {
margin: 16px 0 0;
max-width: 800px;
color: #475569;
line-height: 1.6;
font-size: 15px;
}
.modern-btn {
border-radius: 8px;
height: 40px;
padding: 0 20px;
font-weight: 600;
}
/* Stats Strip */
.stats-strip {
display: flex;
padding: 24px 40px;
background: #f8fafc;
border-top: 1px solid #eef2f6;
gap: 40px;
}
.stat-card {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.stat-label {
font-size: 14px;
font-weight: 500;
color: #64748b;
}
.stat-value {
font-size: 40px;
font-weight: 700;
color: #0f172a;
line-height: 1;
font-feature-settings: 'tnum';
}
/* Content Section Elements */
.content-section {
display: flex;
flex-direction: column;
gap: 24px;
}
.section-header {
padding: 0 8px;
}
.section-title {
margin: 0;
font-size: 20px;
font-weight: 700;
color: #0f172a;
}
.section-desc {
margin: 8px 0 0;
color: #64748b;
font-size: 15px;
line-height: 1.5;
}
.grid-layout {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 24px;
}
/* Modern Card Design */
.modern-card {
display: flex;
flex-direction: column;
background: #ffffff;
border-radius: 20px;
border: 1px solid #e2e8f0;
padding: 28px;
min-height: 380px;
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 {
transform: translateY(-4px);
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.05),
0 8px 10px -6px rgba(0, 0, 0, 0.01);
border-color: #cbd5e1;
}
/* Card Header */
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 32px;
}
.brand-info {
display: flex;
gap: 16px;
align-items: center;
}
.brand-logo {
width: 52px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 14px;
font-size: 22px;
font-weight: 700;
}
.brand-text h4 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: #0f172a;
}
.brand-text p {
margin: 4px 0 0;
color: #64748b;
font-size: 14px;
}
.status-tag {
margin: 0;
border-radius: 9999px;
padding: 4px 12px;
font-weight: 600;
font-size: 13px;
border: 1px solid transparent; /* adjusted dynamically by Ant normally, but let's reset slightly */
}
/* Card Body & Info List */
.card-body {
display: flex;
flex-direction: column;
flex: 1;
}
.info-list {
background: #f8fafc;
border-radius: 12px;
padding: 4px 0;
margin-bottom: 24px;
border: 1px solid #f1f5f9;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #f1f5f9;
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
color: #64748b;
font-size: 14px;
}
.info-value {
font-size: 14px;
color: #334155;
font-weight: 500;
max-width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: right;
}
.primary-text {
color: #0f172a;
font-weight: 600;
}
.mono-text {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
color: #475569;
}
/* Card Footer */
.card-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: auto;
padding-top: 20px;
border-top: 1px dashed #e2e8f0;
}
.action-group {
display: flex;
gap: 8px;
}
.action-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: #64748b;
transition: all 0.2s ease;
border: 1px solid transparent;
}
.action-btn:hover {
background: #f1f5f9;
color: #0ea5e9;
border-color: #e2e8f0;
}
.danger-btn:hover {
background: #fef2f2;
color: #ef4444;
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;
}
.empty-content h5 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #0f172a;
}
.empty-content p {
margin: 12px 0 0;
color: #64748b;
line-height: 1.6;
font-size: 14px;
}
.auth-btn {
margin-top: 24px;
height: 44px;
border-radius: 10px;
font-weight: 600;
font-size: 15px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
}
.card-alert {
margin-top: 16px;
}
:deep(.card-alert .ant-alert) {
border-radius: 12px;
border: none;
background: #fffbeb;
}
/* Responsiveness */
@media (max-width: 1024px) {
.hero-header {
flex-direction: column;
gap: 24px;
}
.stats-strip {
flex-wrap: wrap;
gap: 24px;
}
}
</style>