Spaces:
Paused
Paused
File size: 11,669 Bytes
395e9dc 5db48a2 1f4cfcb 5db48a2 1f4cfcb 5db48a2 395e9dc 1f4cfcb 395e9dc 1f4cfcb 395e9dc 1f4cfcb 395e9dc 1f4cfcb 395e9dc 1f4cfcb 395e9dc 1f4cfcb 395e9dc 090976a 395e9dc | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 | // 账号状态/可用性/时间格式化工具集中位
// 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())}`
}
|