Files
geo/apps/desktop-client/src/renderer/views/AiPlatformsView.vue
T
root 69fd182755 refactor(desktop): narrow home health board to expired/revoked accounts
Home 看板只保留已过期或已停用的账号,问题计数改用阻塞态 authState;新增 formatVerifiedAtLabel,统一校验通过时间的展示粒度。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 17:25:58 +08:00

688 lines
17 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,
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, refreshAccounts, loading } = useDesktopRuntime();
const bindPendingPlatformId = ref<string | null>(null);
const openPendingAccountId = ref<string | null>(null);
const unbindPendingAccountId = ref<string | null>(null);
const aiAccounts = computed(() =>
(snapshot.value?.accounts ?? []).filter((account) =>
desktopMonitoringMediaCatalog.some((platform) => platform.id === account.platform),
),
);
const overview = computed(() => ({
configuredPlatforms: desktopMonitoringMediaCatalog.length,
bound: aiAccounts.value.length,
live: aiAccounts.value.filter((item) => item.health === "live").length,
onlineClients: snapshot.value?.summary.onlineClients ?? 0,
}));
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 authLabel(account: AccountRow | null) {
if (!account) {
return "未绑定";
}
switch (account.authState) {
case "active":
return "授权正常";
case "challenge_required":
return "需人工验证";
case "expired":
return "授权过期";
case "expiring_soon":
return account.probeState === "network_error" ? "最近校验失败" : "待重新校验";
case "revoked":
return "已停用";
default:
return 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 "default";
}
}
function verificationLabel(account: AccountRow): string {
if (account.lastVerifiedAt) {
return formatVerifiedAtLabel(account.lastVerifiedAt);
}
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 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 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 refreshAccounts();
} 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>
<p class="summary">
首次点击授权会打开前台登录窗口登录完成后绑定到当前 partition后续数据抓取与巡检一律走隐藏窗口用户看不见采集过程
</p>
</div>
<div class="hero-actions">
<a-button type="primary" ghost class="modern-btn" :loading="loading" @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.configuredPlatforms }}</div>
</div>
<div class="stat-card">
<div class="stat-label">已绑定</div>
<div class="stat-value">{{ overview.bound }}</div>
</div>
<div class="stat-card">
<div class="stat-label">授权正常</div>
<div class="stat-value">{{ overview.live }}</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 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="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;
max-width: 1400px;
}
/* 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;
}
.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>