Files
geo/apps/desktop-client/src/renderer/views/AiPlatformsView.vue
T
root 40ef60eae2 style(desktop-client): tighten media/AI/publish view copy and table chrome
Rename the media-platforms nav entry to "媒体账号", switch the Accounts hero eyebrow to MEDIA ACCOUNTS, drop the redundant AI platforms intro paragraph, and trim the publish list to use a generic external-link tooltip plus a slightly wider table to fit the renamed columns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 21:06:12 +08:00

754 lines
19 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, refresh, 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 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 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" 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 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;
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>