feat: api-stats页面查询专属账号会话窗口

This commit is contained in:
shaw
2025-09-28 14:36:38 +08:00
parent 90dce32cfc
commit b123cc35c1
4 changed files with 598 additions and 1 deletions

View File

@@ -157,6 +157,200 @@
永不过期
</div>
</div>
<div
v-if="boundAccountList.length > 0"
class="mt-4 rounded-2xl border border-indigo-100/60 bg-indigo-50/60 p-4 dark:border-indigo-500/40 dark:bg-indigo-500/10"
>
<div class="mb-4 flex items-center justify-between">
<div class="flex items-center gap-2">
<i class="fas fa-link text-sm text-indigo-500 md:text-base" />
<span class="text-sm font-semibold text-indigo-900 dark:text-indigo-200 md:text-base"
>专属账号运行状态</span
>
</div>
<span
class="rounded-full bg-white/70 px-2 py-0.5 text-xs font-medium text-indigo-500 shadow-sm dark:bg-slate-900/40 dark:text-indigo-200"
>实时速览</span
>
</div>
<div class="space-y-3 md:space-y-4">
<div
v-for="account in boundAccountList"
:key="account.id"
class="rounded-xl bg-white/80 p-3 shadow-sm backdrop-blur dark:bg-slate-900/50 md:p-4"
>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<div
:class="[
'flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full text-white shadow-inner',
account.platform === 'claude'
? 'bg-gradient-to-br from-purple-500 to-purple-600'
: 'bg-gradient-to-br from-sky-500 to-indigo-500'
]"
>
<i :class="account.platform === 'claude' ? 'fas fa-meteor' : 'fas fa-robot'" />
</div>
<div>
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ account.name || '未命名账号' }}
</div>
<div class="mt-1 flex items-center gap-2 text-[11px]">
<span
v-if="account.platform === 'claude'"
class="inline-flex items-center rounded-full bg-purple-100 px-2 py-0.5 font-medium text-purple-700 dark:bg-purple-500/20 dark:text-purple-200"
>Claude 专属</span
>
<span
v-else
class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 font-medium text-blue-700 dark:bg-blue-500/20 dark:text-blue-200"
>OpenAI 专属</span
>
</div>
</div>
</div>
<div
v-if="getRateLimitDisplay(account.rateLimitStatus)"
class="text-xs font-semibold"
:class="getRateLimitDisplay(account.rateLimitStatus).class"
>
<i class="fas fa-tachometer-alt mr-1" />
{{ getRateLimitDisplay(account.rateLimitStatus).text }}
</div>
</div>
<div v-if="account.platform === 'claude'" class="mt-3 space-y-3">
<div v-if="account.sessionWindow?.hasActiveWindow" class="space-y-2">
<div class="flex items-center gap-2">
<div class="h-2 w-32 rounded-full bg-gray-200 dark:bg-gray-700">
<div
:class="[
'h-2 rounded-full transition-all duration-300',
getSessionProgressBarClass(
account.sessionWindow?.sessionWindowStatus,
account
)
]"
:style="{
width: `${Math.min(
100,
Math.max(0, account.sessionWindow?.progress || 0)
)}%`
}"
/>
</div>
<span class="text-xs font-semibold text-gray-700 dark:text-gray-200">
{{
Math.min(
100,
Math.max(0, Math.round(account.sessionWindow?.progress || 0))
)
}}%
</span>
</div>
<div
class="flex flex-wrap items-center gap-3 text-[11px] text-gray-600 dark:text-gray-300"
>
<span>
{{
formatSessionWindowRange(
account.sessionWindow?.windowStart,
account.sessionWindow?.windowEnd
)
}}
</span>
<span
v-if="account.sessionWindow?.remainingTime > 0"
class="font-medium text-indigo-600 dark:text-indigo-400"
>
剩余 {{ formatSessionRemaining(account.sessionWindow.remainingTime) }}
</span>
</div>
</div>
<div
v-else
class="rounded-lg bg-white/60 px-3 py-2 text-xs text-gray-500 dark:bg-slate-800/60 dark:text-gray-400"
>
暂无活跃会话窗口
</div>
</div>
<div v-else-if="account.platform === 'openai'" class="mt-3 space-y-3">
<div v-if="account.codexUsage" class="space-y-3">
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-700/70">
<div class="flex items-center gap-2">
<span
class="inline-flex min-w-[34px] justify-center rounded-full bg-indigo-100 px-2 py-0.5 text-[11px] font-medium text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-300"
>
{{ getCodexWindowLabel('primary') }}
</span>
<div class="flex-1">
<div class="flex items-center gap-2">
<div class="h-2 flex-1 rounded-full bg-gray-200 dark:bg-gray-600">
<div
:class="[
'h-2 rounded-full transition-all duration-300',
getCodexUsageBarClass(account.codexUsage.primary)
]"
:style="{ width: getCodexUsageWidth(account.codexUsage.primary) }"
/>
</div>
<span
class="w-12 text-right text-xs font-semibold text-gray-800 dark:text-gray-100"
>
{{ formatCodexUsagePercent(account.codexUsage.primary) }}
</span>
</div>
</div>
</div>
<div class="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
重置剩余 {{ formatCodexRemaining(account.codexUsage.primary) }}
</div>
</div>
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-700/70">
<div class="flex items-center gap-2">
<span
class="inline-flex min-w-[34px] justify-center rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-medium text-blue-600 dark:bg-blue-500/20 dark:text-blue-200"
>
{{ getCodexWindowLabel('secondary') }}
</span>
<div class="flex-1">
<div class="flex items-center gap-2">
<div class="h-2 flex-1 rounded-full bg-gray-200 dark:bg-gray-600">
<div
:class="[
'h-2 rounded-full transition-all duration-300',
getCodexUsageBarClass(account.codexUsage.secondary)
]"
:style="{ width: getCodexUsageWidth(account.codexUsage.secondary) }"
/>
</div>
<span
class="w-12 text-right text-xs font-semibold text-gray-800 dark:text-gray-100"
>
{{ formatCodexUsagePercent(account.codexUsage.secondary) }}
</span>
</div>
</div>
</div>
<div class="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
重置剩余 {{ formatCodexRemaining(account.codexUsage.secondary) }}
</div>
</div>
</div>
<div
v-else
class="rounded-lg bg-white/60 px-3 py-2 text-xs text-gray-500 dark:bg-slate-800/60 dark:text-gray-400"
>
暂无额度使用数据
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -313,6 +507,261 @@ const formatPermissions = (permissions) => {
return permissionMap[permissions] || permissions || '未知'
}
// 绑定的专属账号列表(仅保留专属类型)
const boundAccountList = computed(() => {
const accounts = statsData.value?.accounts?.details
if (!accounts) {
return []
}
const result = []
if (accounts.claude && accounts.claude.accountType === 'dedicated') {
result.push({ key: 'claude', ...accounts.claude })
}
if (accounts.openai && accounts.openai.accountType === 'dedicated') {
result.push({ key: 'openai', ...accounts.openai })
}
return result
})
// 将分钟格式化为易读文本
const formatRateLimitTime = (minutes) => {
if (!minutes || minutes <= 0) {
return ''
}
const totalMinutes = Math.floor(minutes)
const days = Math.floor(totalMinutes / 1440)
const hours = Math.floor((totalMinutes % 1440) / 60)
const mins = totalMinutes % 60
if (days > 0) {
if (hours > 0) {
return `${days}${hours}小时`
}
return `${days}`
}
if (hours > 0) {
if (mins > 0) {
return `${hours}小时${mins}分钟`
}
return `${hours}小时`
}
return `${mins}分钟`
}
// 生成限流状态的展示信息
const getRateLimitDisplay = (status) => {
if (!status) {
return {
text: '状态未知',
class: 'text-gray-400'
}
}
if (status.isRateLimited) {
const remaining = formatRateLimitTime(status.minutesRemaining)
const suffix = remaining ? ` · 剩余约 ${remaining}` : ''
return {
text: `限流中${suffix}`,
class: 'text-red-500 dark:text-red-400'
}
}
return {
text: '未限流',
class: 'text-green-600 dark:text-green-400'
}
}
// 格式化会话窗口的时间范围
const formatSessionWindowRange = (start, end) => {
if (!start || !end) {
return '暂无时间窗口信息'
}
const startDate = new Date(start)
const endDate = new Date(end)
const formatPart = (date) => {
const hours = `${date.getHours()}`.padStart(2, '0')
const minutes = `${date.getMinutes()}`.padStart(2, '0')
return `${hours}:${minutes}`
}
return `${formatPart(startDate)} - ${formatPart(endDate)}`
}
// 格式化会话窗口剩余时间
const formatSessionRemaining = (minutes) => {
if (!minutes || minutes <= 0) {
return ''
}
const hours = Math.floor(minutes / 60)
const mins = minutes % 60
if (hours > 0) {
return `${hours}小时${mins}分钟`
}
return `${mins}分钟`
}
// 会话窗口进度条颜色
const getSessionProgressBarClass = (status, account) => {
if (!status) {
return 'bg-gradient-to-r from-blue-500 to-indigo-600'
}
const isRateLimited = account?.rateLimitStatus?.isRateLimited
if (isRateLimited) {
return 'bg-gradient-to-r from-red-500 to-red-600'
}
const normalized = String(status).toLowerCase()
if (normalized === 'rejected') {
return 'bg-gradient-to-r from-red-500 to-red-600'
}
if (normalized === 'allowed_warning') {
return 'bg-gradient-to-r from-yellow-500 to-orange-500'
}
return 'bg-gradient-to-r from-blue-500 to-indigo-600'
}
// 归一化 OpenAI 额度使用百分比
const normalizeCodexUsagePercent = (usageItem) => {
if (!usageItem) {
return null
}
const percent =
typeof usageItem.usedPercent === 'number' && !Number.isNaN(usageItem.usedPercent)
? usageItem.usedPercent
: null
const resetAfterSeconds =
typeof usageItem.resetAfterSeconds === 'number' && !Number.isNaN(usageItem.resetAfterSeconds)
? usageItem.resetAfterSeconds
: null
const remainingSeconds =
typeof usageItem.remainingSeconds === 'number' ? usageItem.remainingSeconds : null
const resetAtMs = usageItem.resetAt ? Date.parse(usageItem.resetAt) : null
const resetElapsed =
resetAfterSeconds !== null &&
((remainingSeconds !== null && remainingSeconds <= 0) ||
(resetAtMs !== null && !Number.isNaN(resetAtMs) && Date.now() >= resetAtMs))
if (resetElapsed) {
return 0
}
if (percent === null) {
return null
}
return Math.max(0, Math.min(100, percent))
}
// OpenAI 额度进度条颜色
const getCodexUsageBarClass = (usageItem) => {
const percent = normalizeCodexUsagePercent(usageItem)
if (percent === null) {
return 'bg-gradient-to-r from-gray-300 to-gray-400'
}
if (percent >= 90) {
return 'bg-gradient-to-r from-red-500 to-red-600'
}
if (percent >= 75) {
return 'bg-gradient-to-r from-yellow-500 to-orange-500'
}
return 'bg-gradient-to-r from-emerald-500 to-teal-500'
}
// OpenAI 额度进度条宽度
const getCodexUsageWidth = (usageItem) => {
const percent = normalizeCodexUsagePercent(usageItem)
if (percent === null) {
return '0%'
}
return `${percent}%`
}
// OpenAI 额度百分比文本
const formatCodexUsagePercent = (usageItem) => {
const percent = normalizeCodexUsagePercent(usageItem)
if (percent === null) {
return '--'
}
return `${percent.toFixed(1)}%`
}
// OpenAI 额度剩余时间
const formatCodexRemaining = (usageItem) => {
if (!usageItem) {
return '--'
}
let seconds = usageItem.remainingSeconds
if (seconds === null || seconds === undefined) {
seconds = usageItem.resetAfterSeconds
}
if (seconds === null || seconds === undefined || Number.isNaN(Number(seconds))) {
return '--'
}
seconds = Math.max(0, Math.floor(Number(seconds)))
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = seconds % 60
if (days > 0) {
if (hours > 0) {
return `${days}${hours}小时`
}
return `${days}`
}
if (hours > 0) {
if (minutes > 0) {
return `${hours}小时${minutes}分钟`
}
return `${hours}小时`
}
if (minutes > 0) {
return `${minutes}分钟`
}
return `${secs}`
}
// OpenAI 窗口标签
const getCodexWindowLabel = (type) => {
if (type === 'secondary') {
return '周限'
}
return '5h'
}
</script>
<style scoped>