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())}`
}