AutoTeam-F / web /src /components /Dashboard.vue
ZRainbow's picture
feat: AutoTeam 大版本升级:安全轮换、并行、IPv6、指纹加固与前端重构
1f4cfcb
Raw
History Blame Contribute Delete
34 kB
<template>
<div v-if="status" class="space-y-6">
<!-- F3 Master health banner -->
<MasterHealthBanner
:master-health="masterHealth"
:min-grace-until="minGraceUntil"
:loading="masterHealthBusy"
@refresh="onRefreshMasterHealth" />
<!-- F6 Pool health card -->
<PoolHealthCard
:accounts="status.accounts || []"
:master-health="masterHealth"
:min-grace-until="minGraceUntil" />
<!-- 状态分布卡片(色彩区分明显) -->
<div class="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-6 gap-3">
<div v-for="card in cards" :key="card.label"
class="glass-soft rounded-lg p-3.5 lift-hover relative overflow-hidden">
<div class="text-[10px] uppercase tracking-widest text-ink-500 font-semibold flex items-center gap-1.5">
<span class="w-1 h-1 rounded-full" :style="{ background: card.dot }"></span>
{{ card.label }}
</div>
<div class="text-3xl font-extrabold mt-1.5 tabular leading-none" :class="card.color">{{ card.value }}</div>
</div>
</div>
<!-- 账号表格 -->
<div class="glass rounded-lg overflow-hidden">
<div class="px-5 py-4 border-b border-hairline flex items-center justify-between gap-3 flex-wrap">
<div>
<h2 class="text-base font-bold text-ink-950 tracking-tight">账号列表</h2>
<p class="text-[11px] text-ink-500 mt-0.5">
{{ totalAccounts }} 个账号 · 每页 {{ ACCOUNT_PAGE_SIZE }} 条 · 实时配额来自 quota_cache
</p>
</div>
<div class="flex items-center gap-2">
<AtButton v-if="selectedDisableEmails.length"
variant="secondary" size="sm" :loading="bulkToggling" :disabled="actionDisabled"
@click="bulkDisableSelected">
禁用自动化 ({{ selectedDisableEmails.length }})
</AtButton>
<AtButton v-if="selectedEnableEmails.length"
variant="primary" size="sm" :loading="bulkToggling" :disabled="actionDisabled"
@click="bulkEnableSelected">
启用自动化 ({{ selectedEnableEmails.length }})
</AtButton>
<AtButton v-if="selectedEmails.length"
variant="danger" size="sm" :loading="batchDeleting" :disabled="actionDisabled"
confirm @click="batchDelete">
<template #icon>
<Trash2 class="w-3.5 h-3.5" :stroke-width="2" />
</template>
{{ batchDeleting ? `批量删除 ${batchProgress}` : `批量删除 (${selectedEmails.length})` }}
</AtButton>
<AtButton v-if="selectedEmails.length" variant="ghost" size="sm" @click="clearSelection">
取消选择
</AtButton>
<AtButton variant="secondary" size="sm" :loading="syncing" @click="syncAccounts">
<template #icon>
<RefreshCw class="w-3.5 h-3.5" :stroke-width="2" />
</template>
{{ syncing ? '同步中…' : '同步账号' }}
</AtButton>
</div>
</div>
<div v-if="message" class="mx-5 mt-4 px-4 py-2.5 rounded-lg text-sm border animate-rise" :class="messageClass">
{{ message }}
</div>
<div v-if="!adminReady"
class="mx-5 mt-4 px-4 py-2.5 rounded-lg text-sm border bg-amber-50 text-amber-800 border-amber-200">
请先在「设置」页完成管理员登录后,才能操作账号。
</div>
<div v-if="hiddenAccountCount"
class="mx-5 mt-4 px-4 py-2.5 rounded-lg text-sm border bg-sky-50 text-sky-800 border-sky-200">
当前仅渲染第 {{ accountPageStart + 1 }}-{{ accountPageEnd }} 个账号,另有 {{ hiddenAccountCount }} 个账号在其它页,以控制页面资源占用。
</div>
<div v-if="totalAccountPages > 1"
class="mx-5 mt-4 px-3 py-2 rounded-lg border border-hairline bg-ink-50 flex items-center justify-between gap-3 flex-wrap">
<div class="text-xs text-ink-600">
显示 {{ accountPageStart + 1 }}-{{ accountPageEnd }} / {{ totalAccounts }}
</div>
<div class="flex items-center gap-2">
<AtButton variant="ghost" size="sm" :disabled="accountPage <= 1" @click="goAccountPage(accountPage - 1)">
<template #icon>
<ChevronLeft class="w-3.5 h-3.5" :stroke-width="2" />
</template>
上一页
</AtButton>
<span class="text-xs text-ink-600 tabular">{{ accountPage }} / {{ totalAccountPages }}</span>
<AtButton variant="ghost" size="sm" :disabled="accountPage >= totalAccountPages" @click="goAccountPage(accountPage + 1)">
<template #icon>
<ChevronRight class="w-3.5 h-3.5" :stroke-width="2" />
</template>
下一页
</AtButton>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-ink-500 text-left border-b border-hairline text-[10px] uppercase tracking-widest">
<th class="px-3 py-3 font-semibold w-8">
<input
type="checkbox"
:checked="allSelectableChecked"
:indeterminate.prop="someSelectableChecked"
@change="toggleSelectAll"
:disabled="!selectableEmails.length"
class="accent-indigo-500 cursor-pointer w-3.5 h-3.5"
title="全选/取消全选本页账号(主号除外)" />
</th>
<th class="px-3 py-3 font-semibold">#</th>
<th class="px-4 py-3 font-semibold">邮箱</th>
<th class="px-4 py-3 font-semibold">状态</th>
<th class="px-4 py-3 font-semibold">实际可用</th>
<th class="px-4 py-3 font-semibold text-right">5h 剩余</th>
<th class="px-4 py-3 font-semibold text-right">周 剩余</th>
<th class="px-4 py-3 font-semibold">5h 重置</th>
<th class="px-4 py-3 font-semibold">周 重置</th>
<th class="px-4 py-3 font-semibold text-right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(acc, i) in visibleAccounts" :key="acc.email"
class="row-hoverable border-b border-hairline hover:bg-ink-50 group"
:class="[isSelected(acc.email) ? 'bg-indigo-50' : '', acc.disabled ? 'opacity-75' : '']">
<td class="px-3 py-3.5">
<input
v-if="!acc.is_main_account"
type="checkbox"
:checked="isSelected(acc.email)"
@change="toggleSelect(acc.email)"
class="accent-indigo-500 cursor-pointer w-3.5 h-3.5" />
</td>
<td class="px-3 py-3.5 text-ink-400 font-mono text-xs">{{ String(accountPageStart + i + 1).padStart(2, '0') }}</td>
<td class="px-4 py-3.5">
<div class="flex items-center gap-2">
<span v-if="acc.is_main_account"
class="text-[9px] uppercase tracking-widest font-bold px-1.5 py-0.5 rounded
bg-indigo-50 text-indigo-700 border border-indigo-200">
Master
</span>
<span class="font-mono text-[12px] text-ink-900">{{ acc.email }}</span>
</div>
</td>
<td class="px-4 py-3.5">
<StatusBadge :status="acc.status" :grace-until="acc.grace_until" />
</td>
<td class="px-4 py-3.5">
<UsabilityCell :account="acc" />
</td>
<td class="px-4 py-3.5 text-right font-mono tabular text-[12px]" :class="pctColor(quota(acc, 'primary'))">
{{ quotaPct(acc, 'primary') }}
</td>
<td class="px-4 py-3.5 text-right font-mono tabular text-[12px]" :class="pctColor(quota(acc, 'weekly'))">
{{ quotaPct(acc, 'weekly') }}
</td>
<td class="px-4 py-3.5 text-ink-500 font-mono text-[11px]">{{ quotaReset(acc, 'primary') }}</td>
<td class="px-4 py-3.5 text-ink-500 font-mono text-[11px]">{{ quotaReset(acc, 'weekly') }}</td>
<td class="px-4 py-3.5 text-right">
<div class="flex items-center justify-end gap-1.5 flex-wrap">
<span v-if="acc.status === 'personal' && !acc.auth_file"
class="inline-block px-1.5 py-0.5 rounded text-[9px] uppercase tracking-wide
bg-amber-50 text-amber-800 border border-amber-200"
title="未拿到 Codex auth_file,请点击补登录">
缺认证
</span>
<AtButton v-if="canLogin(acc)"
variant="primary" size="sm"
:loading="actionEmail === acc.email && actionType === 'login'"
:disabled="actionDisabled || (actionEmail === acc.email && actionType !== 'login')"
@click="loginAccount(acc.email)">
{{ loginLabel(acc) }}
</AtButton>
<!-- Round 11:子号"立即探活"按钮(spec realtime-probe.md v1.0 §4) -->
<AtButton v-if="canProbe(acc)"
variant="secondary" size="sm"
:loading="actionEmail === acc.email && actionType === 'probe'"
:disabled="actionDisabled || (actionEmail === acc.email && actionType !== 'probe')"
@click="probeAccount(acc.email)"
title="立即调 cheap_codex_smoke + check_codex_quota,绕过 30min 节流">
立即探活
</AtButton>
<AtButton v-if="!acc.is_main_account && !acc.disabled && acc.raw_status === 'active'"
variant="secondary" size="sm"
:loading="actionEmail === acc.email && actionType === 'kick'"
:disabled="actionDisabled || (actionEmail === acc.email && actionType !== 'kick')"
@click="kickAccount(acc.email)">
移出
</AtButton>
<AtButton v-if="acc.raw_status === 'active' || acc.raw_status === 'personal' || acc.is_main_account"
variant="ghost" size="sm"
:disabled="actionEmail === acc.email"
@click="exportCodexAuth(acc.email)">
<template #icon>
<Download class="w-3.5 h-3.5" :stroke-width="2" />
</template>
导出
</AtButton>
<AtButton v-if="!acc.is_main_account"
:variant="acc.disabled ? 'primary' : 'secondary'" size="sm"
:loading="actionEmail === acc.email && actionType === (acc.disabled ? 'enable' : 'disable')"
:disabled="actionDisabled || (actionEmail === acc.email && !['enable', 'disable'].includes(actionType))"
@click="toggleAccountDisabled(acc)">
{{ acc.disabled ? '启用' : '禁用' }}
</AtButton>
<AtButton v-if="!acc.is_main_account"
variant="danger" size="sm" confirm
:loading="actionEmail === acc.email && actionType === 'delete'"
:disabled="actionDisabled || (actionEmail === acc.email && actionType !== 'delete')"
@click="removeAccount(acc.email)">
删除
</AtButton>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 注册失败明细 -->
<div class="border-t border-hairline p-5 bg-ink-50">
<div class="flex items-center justify-between mb-3">
<div>
<h2 class="text-sm font-bold text-ink-950 tracking-tight">注册失败明细</h2>
<div class="text-[11px] text-ink-500 mt-0.5">未能入池的注册尝试 (add-phone / duplicate / OAuth 失败 等)</div>
</div>
<AtButton variant="ghost" size="sm" :loading="failuresLoading" @click="loadFailures">
刷新
</AtButton>
</div>
<div v-if="failuresCounts && Object.keys(failuresCounts).length" class="flex flex-wrap gap-1.5 mb-3 text-[11px]">
<span v-for="(cnt, cat) in failuresCounts" :key="cat"
class="px-2 py-0.5 rounded-md border bg-surface border-hairline text-ink-700">
{{ cat }}: <span class="text-rose-700 font-mono ml-0.5 tabular">{{ cnt }}</span>
</span>
</div>
<div class="overflow-x-auto rounded-lg border border-hairline bg-surface">
<div v-if="failuresUnavailable"
class="px-3 py-3 text-xs text-amber-800 bg-amber-50 border-b border-amber-200">
{{ failuresUnavailable }}
</div>
<table class="w-full text-sm">
<thead class="text-[10px] uppercase tracking-widest text-ink-500 border-b border-hairline bg-ink-50">
<tr>
<th class="text-left px-3 py-2 font-semibold">时间</th>
<th class="text-left px-3 py-2 font-semibold">邮箱</th>
<th class="text-left px-3 py-2 font-semibold">类别</th>
<th class="text-left px-3 py-2 font-semibold">原因</th>
<th class="text-left px-3 py-2 font-semibold">附加</th>
</tr>
</thead>
<tbody class="divide-y divide-hairline text-xs">
<tr v-if="!failuresItems.length">
<td class="px-3 py-4 text-ink-400 italic" colspan="5">暂无失败记录</td>
</tr>
<tr v-for="(f, idx) in failuresItems" :key="idx" class="hover:bg-ink-50">
<td class="px-3 py-2 text-ink-500 font-mono">{{ fmtTs(f.timestamp) }}</td>
<td class="px-3 py-2 text-ink-700 font-mono">{{ f.email || '-' }}</td>
<td class="px-3 py-2">
<span class="px-1.5 py-0.5 rounded-md border text-[10px] font-semibold uppercase tracking-wide"
:class="failureCategoryClass(f.category)">{{ f.category }}</span>
</td>
<td class="px-3 py-2 text-ink-600">{{ f.reason }}</td>
<td class="px-3 py-2 text-ink-400 font-mono text-[10px]">{{ fmtFailureExtra(f) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Codex 认证导出弹窗 -->
<div v-if="exportData"
class="fixed inset-0 bg-ink-600/20 z-50 flex items-center justify-center p-4 animate-rise"
@click.self="exportData = null">
<div class="glass rounded-lg w-full max-w-2xl max-h-[80vh] flex flex-col">
<div class="px-5 py-4 border-b border-hairline flex items-center justify-between">
<h3 class="text-ink-950 font-bold tracking-tight">Codex CLI 认证文件</h3>
<button @click="exportData = null" class="text-ink-500 hover:text-ink-950 transition focus-ring rounded-lg p-1">
<X class="w-4 h-4" :stroke-width="2" />
</button>
</div>
<div class="p-5 space-y-3 overflow-y-auto flex-1">
<div class="px-3 py-2.5 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-800 space-y-2">
<div class="font-semibold">使用步骤:</div>
<ol class="list-decimal list-inside space-y-1 text-xs text-amber-700">
<li>退出当前 Codex CLI 会话</li>
<li>删除旧文件:<code class="bg-amber-50 border border-amber-200 px-1 rounded">rm ~/.codex/auth.json</code></li>
<li>将下方内容保存到 <code class="bg-amber-50 border border-amber-200 px-1 rounded">~/.codex/auth.json</code>(Windows: <code class="bg-amber-50 border border-amber-200 px-1 rounded">%APPDATA%\codex\auth.json</code>)</li>
<li>重新启动 Codex CLI</li>
</ol>
<div class="text-xs text-amber-700">导出后 Codex CLI 直连 OpenAI,不走 CPA 代理,响应更快。</div>
</div>
<div class="relative">
<pre class="bg-ink-50 border border-hairline rounded-lg p-4 text-xs font-mono text-ink-800 overflow-x-auto whitespace-pre">{{ exportJson }}</pre>
<AtButton :variant="copied ? 'primary' : 'secondary'" size="sm"
class="absolute top-2 right-2" @click="copyExport">
{{ copied ? '已复制' : '复制' }}
</AtButton>
</div>
</div>
<div class="px-5 py-4 border-t border-hairline flex justify-end gap-2">
<AtButton variant="primary" @click="downloadExport">
<template #icon>
<Download class="w-3.5 h-3.5" :stroke-width="2" />
</template>
下载 auth.json
</AtButton>
<AtButton variant="secondary" @click="exportData = null">关闭</AtButton>
</div>
</div>
</div>
</div>
</div>
<!-- Loading skeleton -->
<div v-else-if="loading" class="space-y-4">
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div v-for="i in 4" :key="i" class="glass-soft rounded-lg p-4 h-20 shimmer-bg"></div>
</div>
<div class="glass-soft rounded-lg h-64 shimmer-bg"></div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { api } from '../api.js'
import StatusBadge from './StatusBadge.vue'
import UsabilityCell from './UsabilityCell.vue'
import MasterHealthBanner from './MasterHealthBanner.vue'
import PoolHealthCard from './PoolHealthCard.vue'
import AtButton from './AtButton.vue'
import { ChevronLeft, ChevronRight, Download, RefreshCw, Trash2, X } from 'lucide-vue-next'
import {
quotaRemainingPct as _qr,
quotaPctText,
formatQuotaReset,
quotaPctColor,
} from '../composables/useStatus.js'
import { useToast } from '../composables/useToast.js'
const toast = useToast()
const props = defineProps({
status: Object,
loading: Boolean,
runningTask: Object,
adminStatus: { type: Object, default: null },
masterHealth: { type: Object, default: null },
registerFailures: { type: Object, default: null },
registerFailuresLoading: { type: Boolean, default: false },
registerFailuresUnavailable: { type: String, default: '' },
})
const emit = defineEmits(['refresh', 'reload-master-health'])
const actionEmail = ref('')
const actionType = ref('')
const syncing = ref(false)
const message = ref('')
const exportData = ref(null)
const copied = ref(false)
const messageClass = ref('')
const masterHealthBusy = ref(false)
const accountPage = ref(1)
const ACCOUNT_PAGE_SIZE = 50
// 批量删除选中态:按邮箱(小写)保存,便于跨刷新复用
const selectedSet = ref(new Set())
const batchDeleting = ref(false)
const batchProgress = ref('')
const bulkToggling = ref(false)
const failuresItems = computed(() => props.registerFailures?.items || [])
const failuresCounts = computed(() => props.registerFailures?.counts || {})
const failuresLoading = computed(() => !!props.registerFailuresLoading)
const failuresUnavailable = computed(() => props.registerFailuresUnavailable || '')
const accounts = computed(() => props.status?.accounts || [])
const totalAccounts = computed(() => accounts.value.length)
const totalAccountPages = computed(() => Math.max(1, Math.ceil(totalAccounts.value / ACCOUNT_PAGE_SIZE)))
const accountPageStart = computed(() => (accountPage.value - 1) * ACCOUNT_PAGE_SIZE)
const accountPageEnd = computed(() => Math.min(accountPageStart.value + ACCOUNT_PAGE_SIZE, totalAccounts.value))
const visibleAccounts = computed(() => accounts.value.slice(accountPageStart.value, accountPageEnd.value))
const hiddenAccountCount = computed(() => Math.max(0, accounts.value.length - visibleAccounts.value.length))
function goAccountPage(page) {
const nextPage = Math.min(Math.max(1, page), totalAccountPages.value)
if (nextPage === accountPage.value) return
accountPage.value = nextPage
clearSelection()
}
watch(totalAccountPages, pages => {
if (accountPage.value > pages) {
accountPage.value = pages
clearSelection()
}
})
function loadFailures() {
emit('refresh')
}
function fmtTs(ts) {
if (!ts) return '-'
const d = new Date(ts * 1000)
const pad = n => String(n).padStart(2, '0')
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
function failureCategoryClass(cat) {
const map = {
phone_blocked: 'bg-rose-50 text-rose-700 border-rose-200',
duplicate_exhausted: 'bg-orange-50 text-orange-700 border-orange-200',
register_failed: 'bg-amber-50 text-amber-700 border-amber-200',
oauth_failed: 'bg-sky-50 text-sky-700 border-sky-200',
kick_failed: 'bg-yellow-50 text-yellow-700 border-yellow-200',
exception: 'bg-red-50 text-red-700 border-red-200',
master_subscription_degraded: 'bg-orange-50 text-orange-700 border-orange-200',
}
return map[cat] || 'bg-ink-50 text-ink-600 border-hairline'
}
function fmtFailureExtra(f) {
const keys = ['step', 'register_attempts', 'duplicate_swaps', 'stage']
const parts = []
for (const k of keys) {
if (f[k] !== undefined && f[k] !== null && f[k] !== '') parts.push(`${k}=${f[k]}`)
}
return parts.join(' ') || '-'
}
const adminReady = computed(() => !!props.adminStatus?.configured)
const actionDisabled = computed(() => !!props.runningTask || !adminReady.value || bulkToggling.value)
const selectableEmails = computed(() =>
visibleAccounts.value.filter(a => !a.is_main_account).map(a => a.email)
)
const selectedEmails = computed(() =>
selectableEmails.value.filter(e => selectedSet.value.has(e.toLowerCase()))
)
const selectedDisableEmails = computed(() =>
visibleAccounts.value
.filter(a => !a.is_main_account && !a.disabled && selectedSet.value.has((a.email || '').toLowerCase()))
.map(a => a.email)
)
const selectedEnableEmails = computed(() =>
visibleAccounts.value
.filter(a => !a.is_main_account && a.disabled && selectedSet.value.has((a.email || '').toLowerCase()))
.map(a => a.email)
)
const allSelectableChecked = computed(() =>
selectableEmails.value.length > 0 && selectedEmails.value.length === selectableEmails.value.length
)
const someSelectableChecked = computed(() =>
selectedEmails.value.length > 0 && selectedEmails.value.length < selectableEmails.value.length
)
function isSelected(email) {
return selectedSet.value.has((email || '').toLowerCase())
}
function toggleSelect(email) {
const key = (email || '').toLowerCase()
const next = new Set(selectedSet.value)
if (next.has(key)) next.delete(key)
else next.add(key)
selectedSet.value = next
}
function toggleSelectAll() {
if (allSelectableChecked.value) selectedSet.value = new Set()
else selectedSet.value = new Set(selectableEmails.value.map(e => e.toLowerCase()))
}
function clearSelection() { selectedSet.value = new Set() }
// Round 9 — 派生 minGraceUntil:从所有 GRACE 子号取最早到期者,banner/PoolHealthCard 共用
const minGraceUntil = computed(() => {
let min = null
for (const acc of accounts.value) {
if (acc.status === 'degraded_grace' && typeof acc.grace_until === 'number') {
if (min === null || acc.grace_until < min) min = acc.grace_until
}
}
return min
})
const cards = computed(() => {
if (!props.status) return []
const s = props.status.summary || {}
const dg = s.degraded_grace ?? accounts.value.filter(a => a.status === 'degraded_grace').length
return [
{ label: 'Active', value: s.active || 0, color: 'text-emerald-700', dot: 'rgb(16, 185, 129)' },
{ label: 'Standby', value: s.standby || 0, color: 'text-amber-700', dot: 'rgb(217, 119, 6)' },
{ label: 'Grace', value: dg, color: 'text-orange-700', dot: 'rgb(234, 88, 12)' },
{ label: 'Personal', value: s.personal || 0, color: 'text-sky-700', dot: 'rgb(2, 132, 199)' },
{ label: 'Disabled', value: s.disabled || 0, color: 'text-stone-700', dot: 'rgb(120, 113, 108)' },
{ label: 'Total', value: s.total || 0, color: 'text-ink-950', dot: 'rgb(79, 70, 229)' },
]
})
function quota(acc, type) {
const qi = props.status?.quota_cache?.[acc.email] || acc.last_quota
if (!qi) return null
return _qr(qi, type)
}
function quotaPct(acc, type) {
const qi = props.status?.quota_cache?.[acc.email] || acc.last_quota
return quotaPctText(qi, type)
}
function quotaReset(acc, type) {
const qi = props.status?.quota_cache?.[acc.email] || acc.last_quota
return formatQuotaReset(qi, type)
}
function pctColor(remain) { return quotaPctColor(remain) }
const exportJson = computed(() => {
if (!exportData.value) return ''
return JSON.stringify(exportData.value.codex_auth, null, 2)
})
async function exportCodexAuth(email) {
try {
exportData.value = await api.getCodexAuth(email)
copied.value = false
} catch (e) {
toast.error('导出失败', e.message)
}
}
async function copyExport() {
try {
await navigator.clipboard.writeText(exportJson.value)
} catch {
const ta = document.createElement('textarea')
ta.value = exportJson.value
ta.style.position = 'fixed'; ta.style.opacity = '0'
document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta)
}
copied.value = true
setTimeout(() => { copied.value = false }, 2400)
}
function downloadExport() {
const blob = new Blob([exportJson.value], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = 'auth.json'; a.click()
URL.revokeObjectURL(url)
}
async function syncAccounts() {
syncing.value = true
try {
const result = await api.postSyncAccounts()
toast.success('同步完成', result.message || '')
emit('refresh')
} catch (e) {
toast.error('同步失败', e.message)
} finally {
syncing.value = false
}
}
function canLogin(acc) {
if (acc.is_main_account) return false
if (acc.disabled) return false
if (acc.status === 'active') return false
if (acc.status === 'personal' && acc.auth_file) return false
if (acc.status === 'degraded_grace') return false // grace 期内仍可用,无需补登录
return true
}
// Round 11 realtime-probe.md v1.0 §4 — 仅对持有 auth_file 的子号显示"立即探活"
// (主号有自己的"立即重测"按钮在 MasterHealthBanner;orphan/pending 无 token 探不动)
function canProbe(acc) {
if (acc.is_main_account) return false
if (acc.disabled) return false
if (!acc.auth_file) return false
if (acc.status === 'pending' || acc.status === 'orphan') return false
return true
}
function loginLabel(acc) {
if (acc.status === 'personal' || acc.status === 'auth_invalid' || acc.status === 'orphan') return '补登录'
return '登录'
}
async function loginAccount(email) {
if (actionDisabled.value) return
actionEmail.value = email; actionType.value = 'login'
try {
const result = await api.loginAccount(email)
toast.success('登录任务已提交', `${email} · ${result.task_id}`)
emit('refresh')
} catch (e) {
toast.error('提交登录任务失败', e.message)
} finally {
actionEmail.value = ''; actionType.value = ''
}
}
// Round 11 realtime-probe.md v1.0 — 子号实时探活
// 调 POST /api/accounts/{email}/probe (force_codex_smoke=true)
// 副作用:后端落 last_quota_check_at,绕过 30min 节流;不修改 status (RT-I1)
async function probeAccount(email) {
if (actionDisabled.value) return
actionEmail.value = email; actionType.value = 'probe'
try {
const result = await api.probeAccount(email, true)
// 后端 spec RT-I2:probe 永不抛 5xx,smoke_result ∈ {alive, auth_invalid, uncertain}
const smoke = result?.smoke_result || result?.last_smoke_result || '-'
const lastCheck = result?.last_quota_check_at
const checkedAt = lastCheck
? new Date(lastCheck * 1000).toLocaleTimeString()
: '刚刚'
if (smoke === 'alive') {
toast.success('探活成功', `${email} · ${checkedAt} · alive`)
} else if (smoke === 'auth_invalid') {
toast.warn('探活完成 · token 失效', `${email} · ${checkedAt} · auth_invalid`)
} else {
toast.info('探活完成', `${email} · ${checkedAt} · ${smoke}`)
}
emit('refresh')
} catch (e) {
toast.error('探活失败', e.message)
} finally {
actionEmail.value = ''; actionType.value = ''
}
}
async function kickAccount(email) {
if (actionDisabled.value) return
const ok = window.confirm(`确认将 ${email} 移出 Team?\n账号会变为 standby 状态,额度恢复后可重新复用。`)
if (!ok) return
actionEmail.value = email; actionType.value = 'kick'
try {
const result = await api.kickAccount(email)
toast.success('已移出 Team', result.message || email)
emit('refresh')
} catch (e) {
toast.error('移出失败', e.message)
} finally {
actionEmail.value = ''; actionType.value = ''
}
}
async function toggleAccountDisabled(acc) {
if (actionDisabled.value) return
const disabling = !acc.disabled
const ok = window.confirm(
disabling
? `确认禁用账号 ${acc.email}?\n禁用后会保留本地记录,但自动巡检、轮转和 CPA 同步会跳过该账号。`
: `确认启用账号 ${acc.email}?\n启用后该账号会重新参与自动巡检、轮转和 CPA 同步。`
)
if (!ok) return
actionEmail.value = acc.email
actionType.value = disabling ? 'disable' : 'enable'
try {
const result = disabling
? await api.disableAccount(acc.email)
: await api.enableAccount(acc.email)
toast.success(disabling ? '账号已禁用' : '账号已启用', result.message || acc.email)
emit('refresh')
} catch (e) {
toast.error(disabling ? '禁用失败' : '启用失败', e.message)
} finally {
actionEmail.value = ''; actionType.value = ''
}
}
async function removeAccount(email) {
if (actionDisabled.value) return
// 二次确认已在 AtButton(confirm) 内做了第一次,这里再用 native confirm 提示破坏性操作细节
const ok = window.confirm(`确认删除账号 ${email}?\n这会清理本地记录、CPA、Team/Invite 和 CloudMail。`)
if (!ok) return
actionEmail.value = email; actionType.value = 'delete'
try {
const result = await api.deleteAccount(email)
toast.success('账号已删除', result.message || email)
emit('refresh')
} catch (e) {
toast.error('删除失败', e.message)
} finally {
actionEmail.value = ''; actionType.value = ''
}
}
async function bulkDisableSelected() {
if (actionDisabled.value || bulkToggling.value) return
const emails = selectedDisableEmails.value
if (!emails.length) return
const preview = emails.slice(0, 8).join('\n')
const more = emails.length > 8 ? `\n…还有 ${emails.length - 8} 个` : ''
const ok = window.confirm(
`确认批量禁用以下 ${emails.length} 个账号?\n禁用后会保留本地记录,但自动巡检、轮转和 CPA 同步会跳过它们。\n\n${preview}${more}`
)
if (!ok) return
bulkToggling.value = true
try {
const result = await api.bulkDisableAccounts(emails)
toast.success('批量禁用完成', result.message || `已禁用 ${emails.length} 个账号`)
clearSelection()
emit('refresh')
} catch (e) {
toast.error('批量禁用失败', e.message)
} finally {
bulkToggling.value = false
}
}
async function bulkEnableSelected() {
if (actionDisabled.value || bulkToggling.value) return
const emails = selectedEnableEmails.value
if (!emails.length) return
const preview = emails.slice(0, 8).join('\n')
const more = emails.length > 8 ? `\n…还有 ${emails.length - 8} 个` : ''
const ok = window.confirm(
`确认批量启用以下 ${emails.length} 个账号?\n启用后它们会重新参与自动巡检、轮转和 CPA 同步。\n\n${preview}${more}`
)
if (!ok) return
bulkToggling.value = true
try {
const result = await api.bulkEnableAccounts(emails)
toast.success('批量启用完成', result.message || `已启用 ${emails.length} 个账号`)
clearSelection()
emit('refresh')
} catch (e) {
toast.error('批量启用失败', e.message)
} finally {
bulkToggling.value = false
}
}
async function batchDelete() {
if (actionDisabled.value || batchDeleting.value) return
const emails = selectedEmails.value
if (!emails.length) return
const preview = emails.slice(0, 8).join('\n')
const more = emails.length > 8 ? `\n…还有 ${emails.length - 8} 个` : ''
const ok = window.confirm(
`确认批量删除以下 ${emails.length} 个账号?这会清理本地记录、CPA、Team/Invite 和 CloudMail。\n\n${preview}${more}`
)
if (!ok) return
batchDeleting.value = true; batchProgress.value = `0/${emails.length}`
try {
const r = await api.deleteAccountsBatch(emails, true)
const s = r?.summary || {}
const failed = (r?.results || []).filter(x => !x.ok)
if (failed.length === 0) {
toast.success('批量删除完成', `成功 ${s.ok}/${s.total}`)
} else {
const head = failed.slice(0, 3).map(x => `${x.email}: ${x.error}`).join('; ')
toast.warn('批量删除部分失败', `成功 ${s.ok}/${s.total}; ${head}${failed.length > 3 ? ' …' : ''}`)
}
clearSelection()
emit('refresh')
} catch (e) {
toast.error('批量删除失败', e.message)
} finally {
batchDeleting.value = false; batchProgress.value = ''
}
}
async function onRefreshMasterHealth() {
masterHealthBusy.value = true
try {
emit('reload-master-health', true)
toast.info('已请求重测', '走 force_refresh,绕过 5min cache')
} finally {
setTimeout(() => { masterHealthBusy.value = false }, 1200)
}
}
</script>