AutoTeam-F / web /src /composables /useStatus.js
ZRainbow's picture
feat: AutoTeam 大版本升级:安全轮换、并行、IPv6、指纹加固与前端重构
1f4cfcb
Raw
History Blame Contribute Delete
11.7 kB
// 账号状态/可用性/时间格式化工具集中位
// spec 引用:account-state-machine.md v2.0 §2.1 / §3.2 / §5.3
//
// round-12 F3 — 在保留全部 utility 函数 (STATUS_LABELS / computeUsability /
// 时间格式化…) 的前提下,新增 useStatusQuery / useStatusInvalidator 两个
// vue-query 入口,让组件可以渐进迁移而不破坏 21 套现有 import。
import { onUnmounted } from 'vue'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { api } from '../api.js'
import { useRotateStream } from './useRotateStream.js'
// 全局 query key 常量,跨组件共享避免 typo
export const STATUS_QUERY_KEY = ['status']
// useStatusQuery — Dashboard / TaskPanel 使用的复合状态拉取。
//
// 行为:
// - refetchInterval: 30_000 (与 main.js 默认 staleTime 对齐)
// - refetchOnWindowFocus: true (vue-query 全局已开,这里仅显式声明可读性)
// - keepPreviousData: 切页面时保留旧数据,UI 不闪 skeleton
//
// 返回 vue-query 标准 ref ({ data, isFetching, isError, refetch, ... }) +
// 一个 invalidate() 简捷方法,SSE 事件触发后调它即可。
export function useStatusQuery(options = {}) {
const queryClient = useQueryClient()
const query = useQuery({
queryKey: STATUS_QUERY_KEY,
queryFn: () => api.getStatus(),
refetchInterval: options.refetchInterval ?? 30_000,
refetchOnWindowFocus: true,
keepPreviousData: true,
staleTime: 5_000, // 5s 内重复 mount 不重拉
...options,
})
function invalidate() {
queryClient.invalidateQueries({ queryKey: STATUS_QUERY_KEY })
}
return { ...query, invalidate, queryClient }
}
// useStatusInvalidator — 把 rotate SSE 事件桥接到 vue-query。
//
// 在任意组件 setup() 里调用一次,SSE 流上每来一条 transition 就 invalidate
// ['status'],vue-query 自动重拉(refetchOnMount + dedup)。
//
// 返回 useRotateStream 的所有 ref,组件可同时拿来渲染"实时进度"面板。
export function useStatusInvalidator(options = {}) {
const queryClient = useQueryClient()
const stream = options.stream || useRotateStream(options.streamOptions || {})
const offTransition = stream.onTransition(() => {
// invalidateQueries 自身是 promise + dedup,不需要 debounce;
// 若 30s polling 与 SSE 重叠,vue-query 会合并成单次网络请求。
queryClient.invalidateQueries({ queryKey: STATUS_QUERY_KEY })
})
onUnmounted(() => offTransition && offTransition())
return stream
}
export const STATUS_LABELS = {
active: 'Active',
exhausted: 'Used up',
standby: 'Standby',
pending: 'Pending',
personal: 'Personal',
disabled: 'Disabled',
auth_invalid: '认证失效',
orphan: '孤立',
degraded_grace: 'Grace',
}
// F2 — 状态视觉:每个状态唯一的渐变 + dot color + label
// 维度:base(背景), text(文字), border(边框), dot(脉冲点), gradient(badge 背景渐变)
export const STATUS_STYLES = {
active: {
base: 'bg-emerald-500/[0.08]',
text: 'text-emerald-300',
border: 'border-emerald-400/30',
dot: 'bg-emerald-400',
pulse: true,
gradient: 'from-emerald-400/20 via-emerald-500/10 to-teal-500/15',
},
personal: {
base: 'bg-sky-500/[0.08]',
text: 'text-sky-300',
border: 'border-sky-400/30',
dot: 'bg-sky-400',
pulse: false,
gradient: 'from-sky-400/20 via-blue-500/10 to-indigo-500/15',
},
standby: {
base: 'bg-amber-500/[0.08]',
text: 'text-amber-300',
border: 'border-amber-400/30',
dot: 'bg-amber-400',
pulse: false,
gradient: 'from-amber-400/15 via-yellow-500/10 to-amber-600/15',
},
disabled: {
base: 'bg-stone-500/[0.08]',
text: 'text-stone-700',
border: 'border-stone-300',
dot: 'bg-stone-500',
pulse: false,
gradient: 'from-stone-200/70 via-stone-100/50 to-slate-100/60',
},
// GRACE — 橙→红渐变,grace_until 越接近 0 越红
degraded_grace: {
base: 'bg-orange-500/[0.10]',
text: 'text-orange-200',
border: 'border-orange-400/40',
dot: 'bg-orange-400',
pulse: true,
gradient: 'from-amber-500/25 via-orange-500/20 to-rose-500/25',
},
auth_invalid: {
base: 'bg-rose-500/[0.08]',
text: 'text-rose-300',
border: 'border-rose-400/30',
dot: 'bg-rose-400',
pulse: false,
gradient: 'from-rose-500/15 via-red-500/10 to-rose-600/15',
},
pending: {
base: 'bg-slate-500/[0.10]',
text: 'text-slate-300',
border: 'border-slate-400/25',
dot: 'bg-slate-400',
pulse: true,
gradient: 'from-slate-500/15 via-zinc-500/10 to-slate-600/15',
},
exhausted: {
base: 'bg-red-700/[0.12]',
text: 'text-red-300',
border: 'border-red-500/30',
dot: 'bg-red-500',
pulse: false,
gradient: 'from-red-700/20 via-rose-700/15 to-red-800/20',
},
orphan: {
base: 'bg-yellow-600/[0.10]',
text: 'text-yellow-200',
border: 'border-yellow-500/30',
dot: 'bg-yellow-400',
pulse: false,
gradient: 'from-yellow-500/15 via-amber-500/10 to-yellow-600/15',
},
}
export function statusLabel(s) {
return STATUS_LABELS[s] || s || '-'
}
export function statusStyle(s) {
return STATUS_STYLES[s] || STATUS_STYLES.pending
}
// F1 — "实际可用性" 派生四档状态
// 输入:account 对象(含 status, last_quota, grace_until 等)
// 输出:{ kind: 'usable'|'grace'|'standby'|'unusable', label, hint, tone }
export function computeUsability(acc) {
if (!acc) return { kind: 'unknown', label: '—', hint: '', tone: 'neutral' }
const s = acc.status
if (s === 'disabled') {
return { kind: 'standby', label: '禁用', hint: '已从自动化流程排除', tone: 'neutral' }
}
const q = acc.last_quota || {}
const noQuota = q.primary_total === 0 || q.no_quota === true
const hasError = q.error || q.status === 'auth_error'
if (s === 'auth_invalid' || s === 'orphan') {
return { kind: 'unusable', label: '不可用', hint: '认证失效 / 席位异常', tone: 'rose' }
}
if (hasError) {
return { kind: 'unusable', label: '不可用', hint: '配额检测错误', tone: 'rose' }
}
if (s === 'degraded_grace') {
const remain = formatGraceRemain(acc.grace_until)
return {
kind: 'grace',
label: 'Grace',
hint: remain ? `剩 ${remain}` : '母号已 cancel,grace 期内仍可用',
tone: 'orange',
remainMs: graceRemainMs(acc.grace_until),
}
}
if (s === 'standby' || s === 'pending') {
return { kind: 'standby', label: '待机', hint: s === 'pending' ? '注册中' : '等待 quota 恢复', tone: 'amber' }
}
if (s === 'exhausted') {
return { kind: 'standby', label: '待机', hint: '已耗尽,等 5h 重置', tone: 'amber' }
}
if (s === 'personal') {
if (noQuota) return { kind: 'unusable', label: '不可用', hint: '无配额', tone: 'rose' }
return { kind: 'usable', label: '可用', hint: 'Personal free', tone: 'emerald' }
}
if (s === 'active') {
if (noQuota) return { kind: 'unusable', label: '不可用', hint: '无配额', tone: 'rose' }
const primaryRemain = 100 - (q.primary_pct || 0)
if (primaryRemain <= 0) return { kind: 'standby', label: '待机', hint: '5h 配额耗尽', tone: 'amber' }
return { kind: 'usable', label: '可用', hint: `5h 剩余 ${primaryRemain}%`, tone: 'emerald' }
}
return { kind: 'unknown', label: '—', hint: '', tone: 'neutral' }
}
// ── 时间工具 ──
export function nowSec() {
return Date.now() / 1000
}
export function graceRemainMs(graceUntil) {
if (!graceUntil || typeof graceUntil !== 'number') return null
return Math.max(0, graceUntil * 1000 - Date.now())
}
export function formatGraceRemain(graceUntil) {
const ms = graceRemainMs(graceUntil)
if (ms === null) return ''
if (ms <= 0) return '已到期'
const totalMin = Math.floor(ms / 60000)
const days = Math.floor(totalMin / (60 * 24))
const hours = Math.floor((totalMin % (60 * 24)) / 60)
const minutes = totalMin % 60
if (days > 0) return `${days}d ${hours}h`
if (hours > 0) return `${hours}h ${minutes}m`
return `${minutes}m`
}
export function formatGraceDate(graceUntil) {
if (!graceUntil) return ''
const d = new Date(graceUntil * 1000)
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
// GRACE 倒计时颜色:< 24h 红 / < 7d 橙 / 其它 amber
export function graceUrgencyClass(graceUntil) {
const ms = graceRemainMs(graceUntil)
if (ms === null) return 'text-amber-300'
const days = ms / (1000 * 60 * 60 * 24)
if (days < 1) return 'text-rose-300'
if (days < 7) return 'text-orange-300'
return 'text-amber-300'
}
// ── master health reason → severity 映射 (Round 11) ──
// spec:master-subscription-health.md v1.2 §14
//
// 输入:
// reason — master_health endpoint 返回的 reason 字段
// masterHealth — 完整 masterHealth 对象 (可选,提供 healthy / evidence.grace_until)
// minGraceUntil — 任意子号最早的 grace_until epoch (Round 9 派生,旧路径兜底)
// 输出:
// 'hidden' — 不渲染 banner (active / subscription_grace healthy 时通常 hidden 或自定义)
// 'warning' — 黄色提示 (subscription_grace 母号 healthy=True 但 grace 期内,见 M-I14)
// 'info' — 灰色 (network_error / stale)
// 'critical' — 红色 (subscription_cancelled / workspace_missing / role_not_owner / auth_invalid)
//
// Round 11 关键改动:
// - 新增 reason='subscription_grace' → 'warning' (healthy=True 但仍提示倒计时)
// - 旧路径 (subscription_cancelled + minGraceUntil 仍未过期) 继续兼容显示 warning
export function masterHealthSeverity(masterHealth, minGraceUntil = null) {
const m = masterHealth
if (!m) return 'hidden'
// Round 11:subscription_grace 是 healthy=True 的新状态,但 banner 仍显 warning + 倒计时
if (m.reason === 'subscription_grace') return 'warning'
// active / 其它 healthy 状态隐藏 banner
if (m.healthy === true || m.reason === 'active') return 'hidden'
const r = m.reason
if (r === 'subscription_cancelled') {
// 旧兼容路径:cancelled + 子号有 grace_until 仍显 warning
const evGrace = m.evidence?.grace_until
if (evGrace && evGrace * 1000 > Date.now()) return 'warning'
if (minGraceUntil && minGraceUntil * 1000 > Date.now()) return 'warning'
return 'critical'
}
if (r === 'workspace_missing' || r === 'role_not_owner' || r === 'auth_invalid') {
return 'critical'
}
if (r === 'network_error') return 'info'
return 'critical'
}
// 配额数值与颜色 (Dashboard 复用)
export function quotaRemainingPct(qi, type = 'primary') {
if (!qi) return null
const pct = type === 'primary' ? qi.primary_pct : qi.weekly_pct
return 100 - (pct || 0)
}
export function quotaPctText(qi, type = 'primary') {
if (!qi) return '-'
if (type === 'primary' && (qi.primary_total === 0 || qi.no_quota === true)) return '无配额'
const v = quotaRemainingPct(qi, type)
return v === null ? '-' : `${v}%`
}
export function quotaPctColor(remain) {
if (remain === null || remain === undefined) return 'text-gray-500'
if (remain > 30) return 'text-emerald-300'
if (remain > 0) return 'text-amber-300'
return 'text-rose-300'
}
export function formatQuotaReset(qi, type = 'primary') {
if (!qi) return '-'
const ts = type === 'primary' ? qi.primary_resets_at : qi.weekly_resets_at
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())}`
}