Spaces:
Paused
Paused
File size: 9,451 Bytes
395e9dc 1f4cfcb 395e9dc 1f4cfcb 395e9dc 1f4cfcb 395e9dc 1f4cfcb 395e9dc 1f4cfcb 395e9dc 090976a 1f4cfcb 395e9dc 090976a 395e9dc 090976a 395e9dc 090976a 395e9dc 090976a 395e9dc 090976a 395e9dc 090976a 395e9dc 090976a 395e9dc 1f4cfcb 395e9dc 1f4cfcb 395e9dc 1f4cfcb 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 | <!--
MasterHealthBanner — F3 母号订阅健康度横幅,4 状态视觉
spec:master-subscription-health.md v1.1 §6 (UI banner) + §13 (endpoint 守恒)
Props:
masterHealth: {healthy: bool, reason: string, evidence: object}
minGraceUntil: Optional<number> 集中显示"最近一个子号 grace 到期" 的 epoch
从父级 status.accounts 派生最早 grace_until
Emits:
refresh: 立即重测按钮触发
-->
<template>
<div v-if="visible" class="relative overflow-hidden rounded-lg border lift-hover shadow-card"
:class="[tone.border, tone.bg]" role="alert">
<!-- 角标 -->
<div class="absolute top-0 right-0 px-3 py-1 text-[10px] font-mono uppercase tracking-widest rounded-bl-lg"
:class="[tone.tagBg, tone.tagText]">
{{ tone.tag }}
</div>
<div class="relative px-5 py-4">
<div class="flex items-start gap-4">
<!-- 图标 -->
<div class="relative shrink-0 w-11 h-11 rounded-lg flex items-center justify-center"
:class="tone.iconWrap">
<AlertTriangle v-if="severity === 'critical'" class="w-5 h-5" :stroke-width="2" />
<CircleAlert v-else-if="severity === 'warning'" class="w-5 h-5" :stroke-width="2" />
<Info v-else-if="severity === 'info'" class="w-5 h-5" :stroke-width="2" />
<Check v-else class="w-5 h-5" :stroke-width="2" />
<span v-if="severity === 'critical'" class="absolute -top-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-rose-400">
<span class="absolute inset-0 rounded-full bg-rose-400 animate-ping opacity-75"></span>
</span>
</div>
<!-- 文案 -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 flex-wrap">
<h3 class="text-base font-bold" :class="tone.title">{{ title }}</h3>
<span v-if="graceCountdown" class="font-mono text-xs px-2 py-0.5 rounded-md bg-surface border border-hairline"
:class="graceCountdownColor">
grace · {{ graceCountdown }}
</span>
</div>
<p class="text-sm mt-1 leading-relaxed" :class="tone.body">{{ description }}</p>
<div v-if="evidenceLine" class="text-[11px] font-mono mt-2 break-all opacity-70" :class="tone.body">
{{ evidenceLine }}
</div>
</div>
<!-- 操作 -->
<div class="shrink-0 flex flex-col items-end gap-2">
<button
@click="$emit('refresh')"
:disabled="loading"
class="h-8 px-3 rounded-lg text-xs font-semibold border transition lift-hover focus-ring
disabled:opacity-50 disabled:cursor-not-allowed"
:class="tone.btn">
<span v-if="loading" class="inline-block w-3 h-3 mr-1.5 rounded-full border-2 border-current border-t-transparent animate-spin align-[-2px]"></span>
{{ loading ? '检测中…' : '立即重测' }}
</button>
<span v-if="lastProbed" class="text-[10px] font-mono opacity-50" :class="tone.body">
上次探测:{{ lastProbed }}{{ cached ? ' · cache' : '' }}
</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { formatGraceRemain, graceUrgencyClass, masterHealthSeverity } from '../composables/useStatus.js'
import { AlertTriangle, Check, CircleAlert, Info } from 'lucide-vue-next'
const props = defineProps({
masterHealth: { type: Object, default: null },
minGraceUntil: { type: [Number, null], default: null },
loading: Boolean,
})
defineEmits(['refresh'])
// reason → severity 映射 (F3 PRD,Round 11 加 subscription_grace)
// spec:master-subscription-health.md v1.2 §14
// subscription_grace → warning (healthy=True,但 grace 期内,黄色 + 倒计时)
// subscription_cancelled (with grace) → warning (旧兼容,子号 grace_until 仍未过期)
// subscription_cancelled (no grace) → critical (red)
// workspace_missing / role_not_owner / auth_invalid → critical
// network_error → info (gray, stale data)
// active → ok (green,微提示;visible=false)
const severity = computed(() => masterHealthSeverity(props.masterHealth, props.minGraceUntil))
const visible = computed(() => severity.value !== 'hidden')
const title = computed(() => {
const r = props.masterHealth?.reason
switch (severity.value) {
case 'warning':
// Round 11:subscription_grace 是 healthy=True 状态,文案区分于旧 subscription_cancelled+grace
if (r === 'subscription_grace') return '母号订阅在 grace 期 · 仍可正常使用'
return '母号订阅 grace 期 · 期限内仍可使用'
case 'critical':
if (r === 'subscription_cancelled') return '母号订阅已 cancel · 请续费或切换母号'
if (r === 'workspace_missing') return 'Workspace 不存在 · account_id 漂移'
if (r === 'role_not_owner') return '权限异常 · 当前角色非 owner/admin'
if (r === 'auth_invalid') return '主号 session 失效 · 请重新登录'
return '母号探针拒绝'
case 'info':
return '母号探针失败 · 数据可能 stale'
default:
return ''
}
})
const description = computed(() => {
const r = props.masterHealth?.reason
switch (severity.value) {
case 'warning':
// Round 11:subscription_grace healthy=True,新 invite 仍能拿 plan_type=team
if (r === 'subscription_grace') {
return 'eligible_for_auto_reactivation=true 但订阅未到期,新 invite 仍能拿 plan_type=team。倒计时到期后自动转 cancelled,请提前续费或切换母号。'
}
return 'master 母号已 cancel_at_period_end,grace 期内子号 wham 仍 200 plan=team。fill 池不增量,但用户可继续消耗已有 quota。倒计时见右上角。'
case 'critical':
if (r === 'subscription_cancelled') return 'eligible_for_auto_reactivation=true,新 invite 必拿 plan_type=free。请续订或更换母号,fill-personal 入口已 503 拒绝。'
if (r === 'workspace_missing') return '/backend-api/accounts items[] 中找不到目标 workspace,可能是 master 已切换。请重新核对 admin_state。'
if (r === 'role_not_owner') return '当前用户角色不是 owner/admin,无法管理 workspace。请用正确账号重新登录。'
if (r === 'auth_invalid') return 'master session 401/403,请到「设置」页清除登录态后重新导入 session_token。'
return '请到「设置」页处理。'
case 'info':
return '/backend-api/accounts 网络抖动或 5xx,本次未拿到最新订阅状态。建议稍后重测。'
default:
return ''
}
})
const evidenceLine = computed(() => {
const ev = props.masterHealth?.evidence
if (!ev) return ''
const parts = []
const accId = ev.account_id || ev.raw_account_item?.id
if (accId) parts.push(`account_id=${accId}`)
if (ev.current_user_role) parts.push(`role=${ev.current_user_role}`)
if (ev.http_status) parts.push(`http=${ev.http_status}`)
if (ev.detail) parts.push(`detail=${ev.detail}`)
return parts.join(' · ')
})
const lastProbed = computed(() => {
const ts = props.masterHealth?.evidence?.probed_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())}:${pad(d.getSeconds())}`
})
const cached = computed(() => props.masterHealth?.evidence?.cache_hit === true)
// Round 11:subscription_grace 的倒计时优先用 evidence.grace_until (master JWT 解析结果),
// 否则 fallback 到 minGraceUntil (子号 grace_until 派生)。
const effectiveGraceUntil = computed(() => {
const evGrace = props.masterHealth?.evidence?.grace_until
if (typeof evGrace === 'number' && evGrace > 0) return evGrace
return props.minGraceUntil
})
const graceCountdown = computed(() => {
if (severity.value !== 'warning') return ''
return formatGraceRemain(effectiveGraceUntil.value) || ''
})
const graceCountdownColor = computed(() => graceUrgencyClass(effectiveGraceUntil.value))
// 配色三档
const tone = computed(() => {
if (severity.value === 'warning') {
return {
tag: 'GRACE',
tagBg: 'bg-amber-100',
tagText: 'text-amber-800',
bg: 'bg-amber-50',
iconWrap: 'bg-amber-100 text-amber-700 border border-amber-200',
title: 'text-amber-950',
body: 'text-amber-800',
border: 'border-amber-200',
btn: 'bg-surface hover:bg-amber-100 text-amber-800 border-amber-200',
}
}
if (severity.value === 'info') {
return {
tag: 'STALE',
tagBg: 'bg-slate-100',
tagText: 'text-slate-700',
bg: 'bg-slate-50',
iconWrap: 'bg-slate-100 text-slate-700 border border-slate-200',
title: 'text-slate-950',
body: 'text-slate-700',
border: 'border-slate-200',
btn: 'bg-surface hover:bg-slate-100 text-slate-700 border-slate-200',
}
}
return {
tag: 'ALERT',
tagBg: 'bg-rose-100',
tagText: 'text-rose-800',
bg: 'bg-rose-50',
iconWrap: 'bg-rose-100 text-rose-700 border border-rose-200',
title: 'text-rose-950',
body: 'text-rose-800',
border: 'border-rose-200',
btn: 'bg-surface hover:bg-rose-100 text-rose-800 border-rose-200',
}
})
</script>
|