AutoTeam-F / web /src /components /PoolPage.vue
ZRainbow's picture
fix: 对齐注册轮转链路和 CloudMail 配置提示
5aff1af
Raw
History Blame Contribute Delete
15.9 kB
<template>
<div class="space-y-5">
<!-- 标题 -->
<div>
<div class="text-[10px] uppercase tracking-[0.3em] text-indigo-700 mb-1">Pool Operations</div>
<h2 class="text-2xl font-extrabold text-ink-950 tracking-tight">账号池操作</h2>
<p class="text-sm text-ink-500 mt-1 max-w-2xl">
集中执行轮转、检查、补满、添加、清理等会直接影响账号池状态的动作。
</p>
</div>
<!-- F3 Master health banner(共享 App 级数据) -->
<MasterHealthBanner
:master-health="masterHealth"
:min-grace-until="minGraceUntil"
:loading="false"
@refresh="$emit('reload-master-health', true)" />
<div class="glass rounded-lg p-4 lg:p-5 space-y-4">
<div class="flex items-start justify-between gap-3 flex-wrap">
<div class="flex items-start gap-3 min-w-0">
<div class="shrink-0 w-10 h-10 rounded-lg border flex items-center justify-center"
:class="ipv6Tone.iconWrap">
<Network class="w-4 h-4" :stroke-width="2" />
</div>
<div class="min-w-0">
<div class="text-[10px] uppercase tracking-[0.3em] text-ink-500 font-semibold">
IPv6 Proxy Pool
</div>
<h3 class="text-base font-bold tracking-tight mt-0.5" :class="ipv6Tone.titleClass">
{{ ipv6Tone.title }}
</h3>
<p class="text-xs text-ink-500 mt-1 max-w-3xl leading-relaxed">
{{ ipv6Subtitle }}
</p>
</div>
</div>
<div class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border text-xs font-semibold"
:class="ipv6Tone.badgeClass">
<span class="w-1.5 h-1.5 rounded-full" :class="ipv6Tone.dotClass"></span>
{{ ipv6BadgeText }}
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-2.5">
<div v-for="item in ipv6Metrics" :key="item.label"
class="rounded-lg border border-hairline bg-ink-50 px-3 py-2.5">
<div class="text-[10px] uppercase tracking-widest text-ink-500 font-semibold">
{{ item.label }}
</div>
<div class="mt-1 text-lg font-bold tabular text-ink-950 truncate">
{{ item.value }}
</div>
</div>
</div>
<div v-if="ipv6Messages.length" class="space-y-2">
<div v-for="message in ipv6Messages" :key="message.kind + ':' + message.text"
class="flex items-start gap-2 rounded-lg border px-3 py-2 text-xs"
:class="message.kind === 'error'
? 'bg-rose-50 border-rose-200 text-rose-800'
: 'bg-amber-50 border-amber-200 text-amber-800'">
<AlertTriangle class="w-3.5 h-3.5 mt-0.5 shrink-0" :stroke-width="2" />
<span class="min-w-0 break-words">{{ message.text }}</span>
</div>
</div>
<div v-if="ipv6Entries.length" class="rounded-lg border border-hairline overflow-hidden">
<div class="px-3 py-2 bg-ink-50 border-b border-hairline flex items-center justify-between gap-3">
<div class="text-[10px] uppercase tracking-widest text-ink-500 font-semibold">
当前分配
</div>
<div v-if="ipv6HiddenEntries > 0" class="text-[11px] text-ink-500">
另有 {{ ipv6HiddenEntries }}
</div>
</div>
<div class="divide-y divide-hairline">
<div v-for="entry in ipv6Entries" :key="entry.email"
class="px-3 py-2.5 grid grid-cols-1 sm:grid-cols-[minmax(0,1fr)_auto_auto] gap-2 sm:items-center text-xs">
<div class="min-w-0">
<div class="font-mono text-ink-900 truncate">{{ entry.email }}</div>
<div class="text-ink-500 font-mono truncate">{{ entry.ipv6_addr }}</div>
</div>
<div class="font-mono text-ink-600 tabular">:{{ entry.port }}</div>
<div class="inline-flex items-center gap-1.5 justify-self-start sm:justify-self-end"
:class="entry.healthy ? 'text-emerald-700' : 'text-rose-700'">
<Activity class="w-3.5 h-3.5" :stroke-width="2" />
{{ entry.healthy ? 'healthy' : 'unhealthy' }}
</div>
</div>
</div>
</div>
</div>
<div class="glass rounded-lg p-4 lg:p-5 space-y-4">
<div class="flex items-start justify-between gap-3 flex-wrap">
<div class="flex items-start gap-3 min-w-0">
<div class="shrink-0 w-10 h-10 rounded-lg border flex items-center justify-center"
:class="cliproxyTone.iconWrap">
<Activity class="w-4 h-4" :stroke-width="2" />
</div>
<div class="min-w-0">
<div class="text-[10px] uppercase tracking-[0.3em] text-ink-500 font-semibold">
CLIProxyAPI Health
</div>
<h3 class="text-base font-bold tracking-tight mt-0.5" :class="cliproxyTone.titleClass">
{{ cliproxyTone.title }}
</h3>
<p class="text-xs text-ink-500 mt-1 max-w-3xl leading-relaxed">
{{ cliproxySubtitle }}
</p>
</div>
</div>
<div class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border text-xs font-semibold"
:class="cliproxyTone.badgeClass">
<span class="w-1.5 h-1.5 rounded-full" :class="cliproxyTone.dotClass"></span>
{{ cliproxyBadgeText }}
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-2.5">
<div v-for="item in cliproxyMetrics" :key="item.label"
class="rounded-lg border border-hairline bg-ink-50 px-3 py-2.5">
<div class="text-[10px] uppercase tracking-widest text-ink-500 font-semibold">
{{ item.label }}
</div>
<div class="mt-1 text-lg font-bold tabular text-ink-950 truncate">
{{ item.value }}
</div>
</div>
</div>
<div v-if="cliproxyMessage" class="flex items-start gap-2 rounded-lg border px-3 py-2 text-xs"
:class="cliproxyHealth?.ok
? 'bg-emerald-50 border-emerald-200 text-emerald-800'
: 'bg-amber-50 border-amber-200 text-amber-800'">
<AlertTriangle v-if="!cliproxyHealth?.ok" class="w-3.5 h-3.5 mt-0.5 shrink-0" :stroke-width="2" />
<Activity v-else class="w-3.5 h-3.5 mt-0.5 shrink-0" :stroke-width="2" />
<span class="min-w-0 break-words">{{ cliproxyMessage }}</span>
</div>
</div>
<TaskPanel
mode="pool"
:running-task="runningTask"
:admin-status="adminStatus"
:master-health="masterHealth"
:rotate-stream="rotateStream"
@task-started="$emit('task-started')"
@refresh="$emit('refresh')" />
</div>
</template>
<script setup>
import { computed } from 'vue'
import TaskPanel from './TaskPanel.vue'
import MasterHealthBanner from './MasterHealthBanner.vue'
import { Activity, AlertTriangle, Network } from 'lucide-vue-next'
const props = defineProps({
runningTask: Object,
adminStatus: Object,
masterHealth: { type: Object, default: null },
status: { type: Object, default: null },
rotateStream: { type: Object, default: null },
})
defineEmits(['task-started', 'refresh', 'reload-master-health'])
const minGraceUntil = computed(() => {
let min = null
for (const acc of props.status?.accounts || []) {
if (acc.status === 'degraded_grace' && typeof acc.grace_until === 'number') {
if (min === null || acc.grace_until < min) min = acc.grace_until
}
}
return min
})
const ipv6Pool = computed(() => props.status?.ipv6_pool || null)
const cliproxyHealth = computed(() => props.status?.cliproxy || null)
const ipv6Preflight = computed(() => ipv6Pool.value?.preflight || {})
const ipv6Entries = computed(() => {
const entries = Array.isArray(ipv6Pool.value?.entries) ? ipv6Pool.value.entries : []
return entries.slice(0, 4)
})
const ipv6HiddenEntries = computed(() => {
const entries = Array.isArray(ipv6Pool.value?.entries) ? ipv6Pool.value.entries.length : 0
return Math.max(0, entries - ipv6Entries.value.length)
})
const ipv6Tone = computed(() => {
const pool = ipv6Pool.value
if (!pool) {
return {
title: '未上报',
titleClass: 'text-slate-700',
iconWrap: 'bg-slate-50 text-slate-700 border-slate-200',
badgeClass: 'bg-slate-50 text-slate-700 border-slate-200',
dotClass: 'bg-slate-500',
}
}
if (!pool.enabled && !pool.required) {
return {
title: '默认关闭',
titleClass: 'text-slate-700',
iconWrap: 'bg-slate-50 text-slate-700 border-slate-200',
badgeClass: 'bg-slate-50 text-slate-700 border-slate-200',
dotClass: 'bg-slate-500',
}
}
if (!pool.ok) {
return {
title: '需要处理',
titleClass: 'text-rose-700',
iconWrap: 'bg-rose-50 text-rose-700 border-rose-200',
badgeClass: 'bg-rose-50 text-rose-700 border-rose-200',
dotClass: 'bg-rose-500',
}
}
if (pool.count > 0) {
return {
title: '运行中',
titleClass: 'text-emerald-700',
iconWrap: 'bg-emerald-50 text-emerald-700 border-emerald-200',
badgeClass: 'bg-emerald-50 text-emerald-700 border-emerald-200',
dotClass: 'bg-emerald-500',
}
}
return {
title: '等待分配',
titleClass: 'text-amber-700',
iconWrap: 'bg-amber-50 text-amber-700 border-amber-200',
badgeClass: 'bg-amber-50 text-amber-700 border-amber-200',
dotClass: 'bg-amber-500',
}
})
const ipv6Subtitle = computed(() => {
const pool = ipv6Pool.value
if (!pool) return '当前 `/api/status` 尚未返回 IPv6 pool 信息。'
if (!pool.enabled && !pool.required) return '默认关闭,不启动本地代理进程,也不会改动本机网络配置。'
if (pool.required && !pool.enabled) return 'Required 已开启,但 pool 未启用;账号网络访问不会静默回退。'
if (!pool.ok) return pool.last_error || firstMessage(ipv6Preflight.value.errors) || '预检或代理健康检查未通过。'
if (pool.count > 0) return `已为 ${pool.count} 个账号分配独立出口;状态会随账号池刷新自动更新。`
return 'IPv6 pool 已启用,等待注册、登录或同步路径为账号分配出口。'
})
const ipv6BadgeText = computed(() => {
const pool = ipv6Pool.value
if (!pool) return 'unknown'
if (!pool.enabled && !pool.required) return 'disabled'
if (pool.required && !pool.ok) return 'required'
return pool.ok ? 'ok' : 'check'
})
const ipv6Metrics = computed(() => {
const pool = ipv6Pool.value || {}
return [
{ label: '分配数', value: String(pool.count ?? 0) },
{ label: '异常', value: String(pool.unhealthy_count ?? 0) },
{ label: '过期', value: String(pool.expired_count ?? 0) },
{ label: '端口使用', value: formatUsage(pool) },
]
})
const ipv6Messages = computed(() => {
const preflight = ipv6Preflight.value || {}
const errors = Array.isArray(preflight.errors) ? preflight.errors : []
const warnings = Array.isArray(preflight.warnings) ? preflight.warnings : []
const messages = errors.map((text) => ({ kind: 'error', text: formatIpv6Message(text) }))
if (ipv6Pool.value?.last_error) {
messages.push({ kind: 'error', text: ipv6Pool.value.last_error })
}
return [
...messages,
...warnings.map((text) => ({ kind: 'warning', text: formatIpv6Message(text) })),
].slice(0, 4)
})
const cliproxyTone = computed(() => {
const health = cliproxyHealth.value
if (!health) {
return {
title: '未上报',
titleClass: 'text-slate-700',
iconWrap: 'bg-slate-50 text-slate-700 border-slate-200',
badgeClass: 'bg-slate-50 text-slate-700 border-slate-200',
dotClass: 'bg-slate-500',
}
}
if (health.ok) {
return {
title: '可用',
titleClass: 'text-emerald-700',
iconWrap: 'bg-emerald-50 text-emerald-700 border-emerald-200',
badgeClass: 'bg-emerald-50 text-emerald-700 border-emerald-200',
dotClass: 'bg-emerald-500',
}
}
return {
title: '需要处理',
titleClass: 'text-amber-700',
iconWrap: 'bg-amber-50 text-amber-700 border-amber-200',
badgeClass: 'bg-amber-50 text-amber-700 border-amber-200',
dotClass: 'bg-amber-500',
}
})
const cliproxyBadgeText = computed(() => {
const health = cliproxyHealth.value
if (!health) return 'unknown'
if (health.cached) return health.ok ? 'cached ok' : 'cached check'
return health.ok ? 'ok' : 'check'
})
const cliproxySubtitle = computed(() => {
const health = cliproxyHealth.value
if (!health) return '当前 `/api/status` 尚未返回 CLIProxyAPI 健康信息。'
if (!health.management_api?.ok) return formatCliproxyReason(health.management_api?.reason || 'management_api_unavailable')
if (!health.provider_auth?.ok) return formatCliproxyReason(health.provider_auth?.reason || 'provider_auth_unavailable')
return `管理接口可读,Codex provider 有 ${health.provider_auth?.available ?? 0} 个可用认证候选。`
})
const cliproxyMetrics = computed(() => {
const health = cliproxyHealth.value || {}
const auth = health.provider_auth || {}
const mgmt = health.management_api || {}
return [
{ label: '认证总数', value: String(auth.total ?? 0) },
{ label: '可用', value: String(auth.available ?? 0) },
{ label: '禁用', value: String(auth.disabled ?? 0) },
{ label: '延迟', value: mgmt.latency_ms == null ? '-' : `${mgmt.latency_ms}ms` },
]
})
const cliproxyMessage = computed(() => {
const health = cliproxyHealth.value
if (!health) return ''
if (health.safe_read_only !== true) return '健康检查必须保持只读,请检查后端实现。'
if (health.error) return health.error
if (!health.management_api?.ok) return formatCliproxyReason(health.management_api?.reason)
if (!health.provider_auth?.ok) return formatCliproxyReason(health.provider_auth?.reason)
return '健康检查仅读取管理元数据,不会上传、删除或刷新远端认证文件。'
})
function firstMessage(values) {
return Array.isArray(values) && values.length ? formatIpv6Message(values[0]) : ''
}
function formatIpv6Message(value) {
const labels = {
ipv6_required_but_disabled: 'Required 已开启,但 IPv6 pool 未启用。',
missing_ipv6_prefix: '缺少 IPV6_PREFIX。',
missing_ipv6_iface: '缺少 IPV6_IFACE。',
missing_ip_command: '当前环境找不到 ip 命令。',
invalid_port_range: 'IPv6 proxy 端口范围无效。',
missing_sudo_command: '配置要求 sudo,但当前环境找不到 sudo。',
status_unavailable: 'IPv6 pool 状态暂不可用。',
}
return labels[value] || String(value || '')
}
function formatCliproxyReason(value) {
const labels = {
missing_config: '缺少 CPA_URL 或 CPA_KEY,无法读取 CLIProxyAPI 管理接口。',
request_failed: 'CLIProxyAPI 管理接口请求失败。',
non_200: 'CLIProxyAPI 管理接口返回非 200 状态。',
non_json: 'CLIProxyAPI 管理接口返回非 JSON 内容。',
invalid_files_payload: 'CLIProxyAPI 管理接口响应缺少 files 列表。',
management_api_unavailable: 'CLIProxyAPI 管理接口不可用。',
no_provider_auth: 'CLIProxyAPI 中没有 Codex provider 认证文件。',
provider_auth_all_unavailable: 'Codex provider 认证文件全部处于禁用、错误或不可用状态。',
provider_auth_has_candidates: 'Codex provider 有可用认证候选。',
health_check_exception: 'CLIProxyAPI 健康检查异常。',
}
return labels[value] || String(value || '状态暂不可用。')
}
function formatUsage(pool) {
const count = Number(pool.used_ports ?? 0)
const capacity = Number(pool.port_capacity ?? 0)
if (!capacity) return `${count}/0`
const pct = Number(pool.port_usage_ratio)
const pctText = Number.isFinite(pct) ? ` · ${(pct * 100).toFixed(0)}%` : ''
return `${count}/${capacity}${pctText}`
}
</script>