Initial open-source release (MIT): image2api AI gateway

Full Go backend + Vue 3 frontend, OpenAI-compatible API, multi-provider
account pools, billing/admin, Docker one-command deploy with auto HTTPS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 22:59:04 +08:00
co-authored by Claude Opus 4.8
commit 606caaf047
142 changed files with 33648 additions and 0 deletions
+518
View File
@@ -0,0 +1,518 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { api } from '../api'
import { fmtTs, fmtSize } from '../utils/format'
import Icon from '../components/Icon.vue'
const providers = ref([])
const stats = ref({ generated_count: 0, generated_size_bytes: 0 })
const userStats = ref({ total: 0, active: 0, disabled: 0, admins: 0, credits_total: 0, new_24h: 0, new_7d: 0, active_24h: 0 })
// Everything time-windowed now comes pre-aggregated from /dashboard (server-side
// SQL) instead of being recomputed in the browser from the last 200 logs — which
// silently undercounted week / DAU / trend / top-N once volume grew past 200.
const dash = ref(null)
const logs = ref([]) // recent-activity feed only (small page)
const models = ref([]) // managed models, for the model-count card
const range = ref('day') // analytics window toggle: 'day' (24h) | 'week' (7d)
let timer = null
async function refreshAll() {
const [p, s, u, d, l, m] = await Promise.all([
api('/providers'),
api('/stats'),
api('/users'),
api('/dashboard'),
api('/logs?limit=20'),
api('/managed-models'),
])
providers.value = p.data?.data || []
stats.value = s.data || {}
userStats.value = u.data?.stats || {}
dash.value = d.data || null
logs.value = l.data?.data || []
models.value = m.data?.data || []
}
// ---- windowed event aggregates (from /dashboard) ----
const EMPTY_WINDOW = { total: 0, success: 0, failed: 0, pending: 0, image: 0, video: 0, api: 0, web: 0, spent: 0 }
const day = computed(() => dash.value?.day || EMPTY_WINDOW)
const week = computed(() => dash.value?.week || EMPTY_WINDOW)
const successRate = computed(() => (day.value.total ? Math.round((day.value.success / day.value.total) * 100) : 0))
// Direction vs the previous 24h (2448h ago) — a quiet day after a busy week is
// worth seeing. prev_day_total is computed server-side.
const dayDelta = computed(() => {
const cur = day.value.total
const prev = dash.value?.prev_day_total || 0
if (!prev) return cur ? { pct: null, dir: 'up' } : { pct: null, dir: 'flat' }
const pct = Math.round(((cur - prev) / prev) * 100)
return { pct, dir: cur > prev ? 'up' : cur < prev ? 'down' : 'flat' }
})
const dau = computed(() => dash.value?.dau || 0)
const avg24hMs = computed(() => stats.value?.avg_elapsed_ms_24h ?? null)
// ---- range-toggled top-N analytics (both windows ship in the payload, so the
// 24h/7d switch is instant — no re-fetch) ----
const rangeLabel = computed(() => (range.value === 'week' ? '近 7 天' : '近 24h'))
const analytics = computed(() => dash.value?.analytics?.[range.value] || { models: [], failures: [], top_users: [] })
const modelUsage = computed(() => analytics.value.models || [])
const usageMax = computed(() => Math.max(1, ...modelUsage.value.map((m) => m.count)))
const failures = computed(() => analytics.value.failures || [])
const topUsers = computed(() => analytics.value.top_users || [])
const topUserMax = computed(() => Math.max(1, ...topUsers.value.map((u) => u.spent)))
// ---- 24h trend (always 24h) ----
const hourBuckets = computed(() => dash.value?.hourly || Array.from({ length: 24 }, () => ({ image: 0, video: 0 })))
const hourMax = computed(() => Math.max(1, ...hourBuckets.value.map((b) => b.image + b.video)))
// ---- operations cards ----
const cdk = computed(() => dash.value?.cdk || {})
const invites = computed(() => dash.value?.invites || {})
const checkin = computed(() => dash.value?.checkin || {})
// ---- token / model / provider summaries (from /providers + /managed-models) ----
const tokens = computed(() => {
let active = 0, total = 0
for (const p of providers.value) {
active += p.tokens_active || 0
total += (p.tokens_active || 0) + (p.tokens_disabled || 0) + (p.tokens_quota || 0)
}
return { active, total }
})
const modelTypes = computed(() => {
const image = models.value.filter((m) => m.type === 'image').length
const video = models.value.filter((m) => m.type === 'video').length
return { image, video, total: models.value.length }
})
const providerHealth = computed(() =>
providers.value.map((p) => {
const total = (p.tokens_active || 0) + (p.tokens_disabled || 0) + (p.tokens_quota || 0)
let status = 'down'
if ((p.tokens_active || 0) > 0) status = 'healthy'
else if (total > 0) status = 'warning'
return { ...p, status, total }
})
)
// One-glance system health badge derived from provider token availability.
const overallHealth = computed(() => {
const list = providerHealth.value
if (!list.length) return { label: '未配置 Provider', tone: 'down' }
if (list.some((p) => p.status === 'down')) return { label: 'Provider 异常', tone: 'down' }
if (list.some((p) => p.status === 'warning')) return { label: 'Provider 告警', tone: 'warning' }
return { label: '系统健康', tone: 'healthy' }
})
const recentLogs = computed(() => logs.value.slice(0, 12))
// ---- formatters ----
function statusLabel(s) { return s === 'healthy' ? '健康' : s === 'warning' ? '告警' : '未配置' }
function statusDot(s) { return s === 'healthy' ? 'bg-emerald-400' : s === 'warning' ? 'bg-amber-400' : 'bg-rose-500' }
function statusPill(s) {
if (s === 'healthy') return 'bg-emerald-500/10 text-emerald-300 ring-emerald-400/30'
if (s === 'warning') return 'bg-amber-500/10 text-amber-300 ring-amber-400/30'
return 'bg-rose-500/10 text-rose-300 ring-rose-400/30'
}
function logDot(status) {
if (status === 'success') return 'bg-emerald-400'
if (status === 'pending') return 'bg-amber-400'
return 'bg-rose-500'
}
function fmtMs(ms) {
if (!ms) return '—'
if (ms < 1000) return ms + 'ms'
return (ms / 1000).toFixed(1) + 's'
}
function fmtInt(n) { return (n ?? 0).toLocaleString('zh-CN') }
function fmtCredits(n) {
const v = Number(n || 0)
if (v >= 10000) return (v / 10000).toFixed(1) + ' 万'
return fmtInt(Math.round(v))
}
onMounted(() => {
refreshAll()
timer = setInterval(refreshAll, 10000)
})
onUnmounted(() => clearInterval(timer))
</script>
<template>
<section class="space-y-4">
<!-- ===== Toolbar: overall health + refresh ===== -->
<div class="flex items-center justify-between gap-3">
<span class="inline-flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium ring-1 tabular-nums"
:class="statusPill(overallHealth.tone)">
<span class="w-1.5 h-1.5 rounded-full" :class="statusDot(overallHealth.tone)"></span>
{{ overallHealth.label }}
</span>
<button @click="refreshAll" class="btn-ghost">刷新</button>
</div>
<!-- ===== KPI strip ===== -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
<!-- 用户 -->
<div class="card p-4">
<div class="flex items-center justify-between">
<span class="text-xs text-white/55">用户</span>
<span class="w-7 h-7 rounded-lg bg-indigo-500/15 text-indigo-300 grid place-items-center ring-1 ring-indigo-400/20">
<Icon name="accounts" class="w-3.5 h-3.5" />
</span>
</div>
<div class="text-2xl font-semibold tabular-nums mt-2">{{ fmtInt(userStats.total) }}</div>
<div class="text-[11px] text-white/45 mt-1">{{ userStats.active }} 活跃 · {{ userStats.admins }} 管理员</div>
<div class="text-[11px] text-emerald-300/80 mt-0.5">
今日新增 <span class="tabular-nums font-medium">{{ fmtInt(userStats.new_24h) }}</span>
· 7 <span class="tabular-nums font-medium">{{ fmtInt(userStats.new_7d) }}</span>
</div>
</div>
<!-- 24h -->
<div class="card p-4">
<div class="flex items-center justify-between">
<span class="text-xs text-white/55"> 24 小时生成</span>
<span class="w-7 h-7 rounded-lg bg-violet-500/15 text-violet-300 grid place-items-center ring-1 ring-violet-400/20">
<Icon name="spark" class="w-3.5 h-3.5" />
</span>
</div>
<div class="text-2xl font-semibold tabular-nums mt-2 flex items-baseline gap-2">
<span>{{ fmtInt(day.total) }}</span>
<span v-if="dayDelta.pct != null"
class="text-[11px] font-medium tabular-nums"
:class="dayDelta.dir === 'up' ? 'text-emerald-300' : dayDelta.dir === 'down' ? 'text-rose-300' : 'text-white/45'">
{{ dayDelta.dir === 'up' ? '↑' : dayDelta.dir === 'down' ? '↓' : '·' }}{{ Math.abs(dayDelta.pct) }}%
</span>
</div>
<div class="text-[11px] mt-1 flex flex-wrap gap-x-2">
<span class="text-emerald-300 tabular-nums">{{ day.success }} 成功</span>
<span v-if="day.failed" class="text-rose-300 tabular-nums">{{ day.failed }} 失败</span>
<span v-if="day.pending" class="text-amber-300 tabular-nums">{{ day.pending }} 进行中</span>
</div>
<div v-if="day.total" class="text-[10px] text-white/40 mt-1 tabular-nums">
Web {{ day.web }} · API {{ day.api }} · {{ day.image }} · {{ day.video }}
</div>
</div>
<!-- 平均耗时 -->
<div class="card p-4">
<div class="flex items-center justify-between">
<span class="text-xs text-white/55">平均耗时</span>
<span class="w-7 h-7 rounded-lg bg-emerald-500/15 text-emerald-300 grid place-items-center ring-1 ring-emerald-400/20">
<Icon name="refresh" class="w-3.5 h-3.5" />
</span>
</div>
<div class="text-2xl font-semibold tabular-nums mt-2">{{ fmtMs(avg24hMs) }}</div>
<div class="text-[11px] text-white/45 mt-1">{{ successRate }}% 成功率 · 24h</div>
</div>
<!-- 存储 -->
<div class="card p-4">
<div class="flex items-center justify-between">
<span class="text-xs text-white/55">产物存储</span>
<span class="w-7 h-7 rounded-lg bg-amber-500/15 text-amber-300 grid place-items-center ring-1 ring-amber-400/20">
<Icon name="files" class="w-3.5 h-3.5" />
</span>
</div>
<div class="text-2xl font-semibold tabular-nums mt-2">{{ fmtSize(stats.generated_size_bytes || 0) }}</div>
<div class="text-[11px] text-white/45 mt-1">{{ fmtInt(stats.generated_count) }} 个文件</div>
</div>
</div>
<!-- ===== Secondary stat row ===== -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div class="card p-4">
<div class="text-xs text-white/55">系统积分总和</div>
<div class="text-xl font-semibold tabular-nums mt-2">{{ fmtCredits(userStats.credits_total) }}</div>
<div class="text-[11px] text-white/45 mt-1">
所有用户余额累加 · <span class="text-amber-300">24h 消耗 {{ fmtCredits(day.spent) }}</span>
</div>
</div>
<div class="card p-4">
<div class="text-xs text-white/55">Token</div>
<div class="text-xl font-semibold tabular-nums mt-2 flex items-baseline gap-1">
<span class="text-emerald-300">{{ tokens.active }}</span>
<span class="text-white/30">/</span>
<span>{{ tokens.total }}</span>
</div>
<div class="text-[11px] text-white/45 mt-1">活跃 / 已配置</div>
</div>
<div class="card p-4">
<div class="text-xs text-white/55">模型</div>
<div class="text-xl font-semibold tabular-nums mt-2">{{ modelTypes.total }}</div>
<div class="text-[11px] text-white/45 mt-1">图像 {{ modelTypes.image }} · 视频 {{ modelTypes.video }}</div>
</div>
<div class="card p-4">
<div class="text-xs text-white/55">活跃用户 · 24h</div>
<div class="text-xl font-semibold tabular-nums mt-2">{{ fmtInt(dau) }}</div>
<div class="text-[11px] text-white/45 mt-1"> 7 天累计生成 {{ fmtInt(week.total) }}</div>
</div>
</div>
<!-- ===== Operations row: CDK / 邀请 / 签到 ===== -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<!-- 兑换码 -->
<div class="card p-4">
<div class="flex items-center justify-between">
<span class="text-xs text-white/55">兑换码 CDK</span>
<span class="w-7 h-7 rounded-lg bg-sky-500/15 text-sky-300 grid place-items-center ring-1 ring-sky-400/20">
<Icon name="spark" class="w-3.5 h-3.5" />
</span>
</div>
<div class="text-xl font-semibold tabular-nums mt-2 flex items-baseline gap-1">
<span class="text-emerald-300">{{ fmtInt(cdk.active) }}</span>
<span class="text-white/30 text-sm">未用 /</span>
<span class="text-white/70 text-base">{{ fmtInt(cdk.redeemed) }} 已兑</span>
</div>
<div class="text-[11px] text-white/45 mt-1">
待兑积分 <span class="text-amber-300 tabular-nums">{{ fmtCredits(cdk.active_amount) }}</span>
· 已发出 <span class="tabular-nums">{{ fmtCredits(cdk.redeemed_amount) }}</span>
</div>
</div>
<!-- 邀请 -->
<div class="card p-4">
<div class="flex items-center justify-between">
<span class="text-xs text-white/55">邀请</span>
<span class="w-7 h-7 rounded-lg bg-fuchsia-500/15 text-fuchsia-300 grid place-items-center ring-1 ring-fuchsia-400/20">
<Icon name="accounts" class="w-3.5 h-3.5" />
</span>
</div>
<div class="text-xl font-semibold tabular-nums mt-2 flex items-baseline gap-1">
<span>{{ fmtInt(invites.total) }}</span>
<span class="text-white/40 text-sm">邀请注册</span>
</div>
<div class="text-[11px] text-white/45 mt-1">
<span class="text-emerald-300 tabular-nums">{{ fmtInt(invites.completed) }}</span> 已达成奖励
· 已发 <span class="text-amber-300 tabular-nums">{{ fmtCredits(invites.reward_paid) }}</span> 积分
</div>
</div>
<!-- 签到 -->
<div class="card p-4">
<div class="flex items-center justify-between">
<span class="text-xs text-white/55">今日签到</span>
<span class="w-7 h-7 rounded-lg bg-teal-500/15 text-teal-300 grid place-items-center ring-1 ring-teal-400/20">
<Icon name="refresh" class="w-3.5 h-3.5" />
</span>
</div>
<div class="text-xl font-semibold tabular-nums mt-2 flex items-baseline gap-1">
<span>{{ fmtInt(checkin.today) }}</span>
<span class="text-white/40 text-sm"></span>
</div>
<div class="text-[11px] text-white/45 mt-1">
发放 <span class="text-amber-300 tabular-nums">{{ fmtCredits(checkin.awarded_today) }}</span> 积分
</div>
</div>
</div>
<!-- ===== Provider health + 24h trend ===== -->
<div class="grid lg:grid-cols-2 gap-3">
<div class="card">
<div class="px-5 py-3 border-b border-white/[0.06] flex items-center justify-between">
<h2 class="text-sm font-semibold">Provider 健康</h2>
<span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1"
:class="statusPill(overallHealth.tone)">
<span class="w-1.5 h-1.5 rounded-full" :class="statusDot(overallHealth.tone)"></span>
{{ overallHealth.label }}
</span>
</div>
<div class="p-3">
<div v-if="!providerHealth.length" class="text-center text-xs text-white/40 py-8">未注册 Provider</div>
<div v-else class="space-y-0.5">
<div v-for="p in providerHealth" :key="p.name"
class="flex items-center gap-3 px-2 py-2 rounded-lg hover:bg-white/[0.04] transition-colors">
<div class="flex-1 min-w-0">
<div class="text-sm font-medium capitalize truncate">{{ p.name }}</div>
<div class="text-[11px] text-white/45 mt-0.5">
{{ p.model_count }} 模型 · token {{ p.tokens_active }}/{{ p.total || 0 }} 活跃
<span v-if="p.tokens_quota"> · {{ p.tokens_quota }} 限额</span>
<span v-if="p.tokens_disabled"> · {{ p.tokens_disabled }} 停用</span>
</div>
</div>
<span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1 tabular-nums"
:class="statusPill(p.status)">
<span class="w-1.5 h-1.5 rounded-full" :class="statusDot(p.status)"></span>
{{ statusLabel(p.status) }}
</span>
</div>
</div>
</div>
</div>
<div class="card">
<div class="px-5 py-3 border-b border-white/[0.06] flex items-baseline justify-between">
<h2 class="text-sm font-semibold">24 小时生成趋势</h2>
<div class="text-[11px] text-white/45 flex items-center gap-3">
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-sm bg-indigo-400/80"></span>图像</span>
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-sm bg-fuchsia-400/80"></span>视频</span>
<span class="tabular-nums">峰值 {{ hourMax }}/h</span>
</div>
</div>
<div class="p-5">
<div class="flex items-end gap-[3px] h-32">
<div v-for="(b, i) in hourBuckets" :key="i"
class="group/bar relative flex-1 flex flex-col justify-end rounded-t overflow-visible"
:style="{ height: Math.max(4, ((b.image + b.video) / hourMax) * 100) + '%' }">
<!-- hover tooltip -->
<div class="pointer-events-none absolute -top-9 left-1/2 -translate-x-1/2 z-10 hidden group-hover/bar:block
whitespace-nowrap rounded-md bg-black/90 ring-1 ring-white/10 px-2 py-1 text-[10px] text-white/90 tabular-nums">
{{ 23 - i }}h · {{ b.image }} / {{ b.video }}
</div>
<div v-if="b.video" class="bg-fuchsia-400/80 group-hover/bar:bg-fuchsia-400" :style="{ flex: b.video }"></div>
<div v-if="b.image" class="bg-indigo-400/80 group-hover/bar:bg-indigo-400" :style="{ flex: b.image }"></div>
<div v-if="!b.image && !b.video" class="bg-white/[0.06] group-hover/bar:bg-white/15 flex-1 rounded-t"></div>
</div>
</div>
<div class="flex justify-between text-[10px] text-white/40 mt-2 tabular-nums">
<span>-24h</span><span>-18h</span><span>-12h</span><span>-6h</span><span>现在</span>
</div>
</div>
</div>
</div>
<!-- ===== Analytics (range-toggled): top models / failures / spenders ===== -->
<div class="flex items-center justify-between gap-3 pt-1">
<h2 class="text-sm font-semibold text-white/80">使用分析 · <span class="text-white/45 font-normal">{{ rangeLabel }}</span></h2>
<div class="inline-flex rounded-lg bg-white/[0.04] ring-1 ring-white/[0.08] p-0.5 text-xs">
<button @click="range = 'day'"
class="px-3 py-1 rounded-md transition-colors"
:class="range === 'day' ? 'bg-white/10 text-white font-medium' : 'text-white/50 hover:text-white/80'">
24h
</button>
<button @click="range = 'week'"
class="px-3 py-1 rounded-md transition-colors"
:class="range === 'week' ? 'bg-white/10 text-white font-medium' : 'text-white/50 hover:text-white/80'">
7d
</button>
</div>
</div>
<div class="grid lg:grid-cols-2 gap-3">
<div class="card">
<div class="px-5 py-3 border-b border-white/[0.06]">
<h2 class="text-sm font-semibold">热门模型</h2>
</div>
<div class="p-5">
<div v-if="!modelUsage.length" class="text-center text-xs text-white/40 py-6">尚无生成记录</div>
<div v-else class="space-y-3">
<div v-for="m in modelUsage" :key="m.model" class="text-sm">
<div class="flex items-baseline justify-between gap-3 mb-1">
<span class="font-mono text-[12px] text-white/85 truncate">{{ m.model }}</span>
<span class="flex items-baseline gap-2 shrink-0">
<span v-if="m.avg_ms" class="text-[10px] text-white/40 tabular-nums">{{ fmtMs(m.avg_ms) }}</span>
<span class="tabular-nums text-xs font-semibold">{{ m.count }}</span>
</span>
</div>
<div class="h-1.5 rounded-full bg-white/[0.06] overflow-hidden">
<div class="h-full rounded-full bg-gradient-to-r from-violet-400 to-fuchsia-500"
:style="{ width: ((m.count / usageMax) * 100) + '%' }"></div>
</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="px-5 py-3 border-b border-white/[0.06]">
<h2 class="text-sm font-semibold">失败原因 Top</h2>
</div>
<div class="p-5">
<div v-if="!failures.length" class="text-center text-xs text-white/40 py-6">{{ rangeLabel }}内没有失败 一切正常</div>
<div v-else class="space-y-2">
<div v-for="f in failures" :key="f.reason"
class="flex items-start gap-3 px-2 py-2 rounded-lg hover:bg-white/[0.04]">
<span class="w-1.5 h-1.5 mt-1.5 rounded-full bg-rose-400 shrink-0"></span>
<div class="flex-1 min-w-0">
<div class="text-xs text-white/85 break-all leading-snug">{{ f.reason }}</div>
</div>
<span class="tabular-nums text-xs font-semibold text-rose-300 shrink-0">×{{ f.count }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- ===== User consumption + spend summary ===== -->
<div class="grid lg:grid-cols-2 gap-3">
<div class="card">
<div class="px-5 py-3 border-b border-white/[0.06]">
<h2 class="text-sm font-semibold">用户消耗 Top · {{ rangeLabel }}</h2>
</div>
<div class="p-5">
<div v-if="!topUsers.length" class="text-center text-xs text-white/40 py-6">{{ rangeLabel }}内无消耗记录</div>
<div v-else class="space-y-3">
<div v-for="u in topUsers" :key="u.user_id || u.name" class="text-sm">
<div class="flex items-baseline justify-between gap-3 mb-1">
<span class="text-[12px] text-white/85 truncate">{{ u.name }}</span>
<span class="flex items-baseline gap-2 shrink-0">
<span class="text-[10px] text-white/40 tabular-nums">{{ u.count }} </span>
<span class="tabular-nums text-xs font-semibold text-amber-300">{{ fmtCredits(u.spent) }}</span>
</span>
</div>
<div class="h-1.5 rounded-full bg-white/[0.06] overflow-hidden">
<div class="h-full rounded-full bg-gradient-to-r from-amber-400 to-orange-500"
:style="{ width: ((u.spent / topUserMax) * 100) + '%' }"></div>
</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="px-5 py-3 border-b border-white/[0.06]">
<h2 class="text-sm font-semibold">积分消耗概览</h2>
</div>
<div class="p-5 grid grid-cols-2 gap-4">
<div>
<div class="text-xs text-white/55"> 24 小时</div>
<div class="text-2xl font-semibold tabular-nums mt-1 text-amber-300">{{ fmtCredits(day.spent) }}</div>
<div class="text-[11px] text-white/40 mt-1">{{ day.success }} 次成功生成</div>
</div>
<div>
<div class="text-xs text-white/55"> 7 </div>
<div class="text-2xl font-semibold tabular-nums mt-1 text-amber-300">{{ fmtCredits(week.spent) }}</div>
<div class="text-[11px] text-white/40 mt-1">{{ week.success }} 次成功生成</div>
</div>
<div>
<div class="text-xs text-white/55">单次均价</div>
<div class="text-xl font-semibold tabular-nums mt-1">{{ day.success ? fmtCredits(Math.round(day.spent / day.success)) : '—' }}</div>
<div class="text-[11px] text-white/40 mt-1">24h 平均</div>
</div>
<div>
<div class="text-xs text-white/55">消耗用户数</div>
<div class="text-xl font-semibold tabular-nums mt-1">{{ topUsers.length }}</div>
<div class="text-[11px] text-white/40 mt-1">{{ rangeLabel }}有消耗</div>
</div>
</div>
</div>
</div>
<!-- ===== Recent activity (full width) ===== -->
<div class="card">
<div class="px-5 py-3 border-b border-white/[0.06] flex items-baseline justify-between">
<h2 class="text-sm font-semibold">最近活动</h2>
<router-link to="/admin/logs" class="text-[11px] text-white/55 hover:text-white">查看全部 </router-link>
</div>
<div class="p-3">
<div v-if="!recentLogs.length" class="text-center text-xs text-white/40 py-6">尚无活动</div>
<div v-else>
<div v-for="e in recentLogs" :key="e.id"
class="flex items-center gap-3 px-2 py-2 text-xs rounded-lg hover:bg-white/[0.03]">
<span class="w-1.5 h-1.5 rounded-full shrink-0" :class="logDot(e.status)"></span>
<span class="text-[10px] uppercase tracking-wider font-medium w-9"
:class="e.kind === 'video' ? 'text-fuchsia-300' : 'text-indigo-300'">
{{ e.kind === 'video' ? '视频' : '图像' }}
</span>
<span class="font-mono text-white/85 truncate w-40 shrink-0">{{ e.model }}</span>
<span class="text-white/55 truncate flex-1 min-w-0">{{ e.prompt }}</span>
<span class="text-white/40 tabular-nums whitespace-nowrap w-12 text-right">{{ fmtMs(e.elapsed_ms) }}</span>
<span class="text-white/40 whitespace-nowrap text-right tabular-nums">{{ fmtTs(e.ts) }}</span>
</div>
</div>
</div>
</div>
</section>
</template>