|
@@ -0,0 +1,557 @@
|
|
|
|
|
+<script setup lang="ts">
|
|
|
|
|
+import { computed, onMounted, ref } from 'vue'
|
|
|
|
|
+import { fetchLlmBillingOverview } from '../api/llmBilling'
|
|
|
|
|
+import type { LlmBillingOverview } from '../types/llmBilling'
|
|
|
|
|
+
|
|
|
|
|
+const overview = ref<LlmBillingOverview | null>(null)
|
|
|
|
|
+const loading = ref(true)
|
|
|
|
|
+const error = ref('')
|
|
|
|
|
+const rangeDays = ref<7 | 14 | 30>(30)
|
|
|
|
|
+
|
|
|
|
|
+function todayBizDt(): string {
|
|
|
|
|
+ return new Intl.DateTimeFormat('sv-SE', {
|
|
|
|
|
+ timeZone: 'Asia/Shanghai',
|
|
|
|
|
+ year: 'numeric',
|
|
|
|
|
+ month: '2-digit',
|
|
|
|
|
+ day: '2-digit',
|
|
|
|
|
+ })
|
|
|
|
|
+ .format(new Date())
|
|
|
|
|
+ .replaceAll('-', '')
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function shiftBizDt(bizDt: string, days: number): string {
|
|
|
|
|
+ const y = Number(bizDt.slice(0, 4))
|
|
|
|
|
+ const m = Number(bizDt.slice(4, 6))
|
|
|
|
|
+ const d = Number(bizDt.slice(6, 8))
|
|
|
|
|
+ const date = new Date(Date.UTC(y, m - 1, d))
|
|
|
|
|
+ date.setUTCDate(date.getUTCDate() + days)
|
|
|
|
|
+ const yy = date.getUTCFullYear()
|
|
|
|
|
+ const mm = String(date.getUTCMonth() + 1).padStart(2, '0')
|
|
|
|
|
+ const dd = String(date.getUTCDate()).padStart(2, '0')
|
|
|
|
|
+ return `${yy}${mm}${dd}`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function load() {
|
|
|
|
|
+ loading.value = true
|
|
|
|
|
+ error.value = ''
|
|
|
|
|
+ try {
|
|
|
|
|
+ const endDt = todayBizDt()
|
|
|
|
|
+ const startDt = shiftBizDt(endDt, -(rangeDays.value - 1))
|
|
|
|
|
+ overview.value = await fetchLlmBillingOverview({
|
|
|
|
|
+ startDt,
|
|
|
|
|
+ endDt,
|
|
|
|
|
+ recentLimit: 40,
|
|
|
|
|
+ })
|
|
|
|
|
+ } catch (cause) {
|
|
|
|
|
+ error.value = cause instanceof Error ? cause.message : String(cause)
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ loading.value = false
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+onMounted(load)
|
|
|
|
|
+
|
|
|
|
|
+const maxDailyCost = computed(() => {
|
|
|
|
|
+ const values = overview.value?.daily.map((d) => d.total_cost_usd) ?? []
|
|
|
|
|
+ return Math.max(...values, 0.000001)
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+const maxAgentCost = computed(() => {
|
|
|
|
|
+ const values = overview.value?.by_agent.map((d) => d.total_cost_usd) ?? []
|
|
|
|
|
+ return Math.max(...values, 0.000001)
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+const maxModelCost = computed(() => {
|
|
|
|
|
+ const values = overview.value?.by_model.map((d) => d.total_cost_usd) ?? []
|
|
|
|
|
+ return Math.max(...values, 0.000001)
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+function formatUsd(value: number | null | undefined, digits = 4): string {
|
|
|
|
|
+ if (value == null || Number.isNaN(value)) return '—'
|
|
|
|
|
+ if (value === 0) return '$0'
|
|
|
|
|
+ if (value < 0.0001) return `$${value.toExponential(2)}`
|
|
|
|
|
+ return `$${value.toFixed(digits)}`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function formatTokens(value: number): string {
|
|
|
|
|
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(2)}M`
|
|
|
|
|
+ if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`
|
|
|
|
|
+ return String(value)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function shortModel(model: string): string {
|
|
|
|
|
+ const parts = model.split('/')
|
|
|
|
|
+ return parts[parts.length - 1] || model
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function barHeight(cost: number, max: number): string {
|
|
|
|
|
+ const pct = Math.max(3, (cost / max) * 100)
|
|
|
|
|
+ return `${pct}%`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function barWidth(cost: number, max: number): string {
|
|
|
|
|
+ return `${Math.max(2, (cost / max) * 100)}%`
|
|
|
|
|
+}
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<template>
|
|
|
|
|
+ <main class="billing-page">
|
|
|
|
|
+ <header class="page-header">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <div class="eyebrow"><span /> LLM BILLING</div>
|
|
|
|
|
+ <h1>LLM 费用看板</h1>
|
|
|
|
|
+ <p>按天汇总全部 Agent 的 OpenRouter 调用费用(Asia/Shanghai)。</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="range-switch" role="group" aria-label="时间范围">
|
|
|
|
|
+ <button
|
|
|
|
|
+ v-for="days in [7, 14, 30] as const"
|
|
|
|
|
+ :key="days"
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ :class="{ active: rangeDays === days }"
|
|
|
|
|
+ :disabled="loading"
|
|
|
|
|
+ @click="rangeDays = days; load()"
|
|
|
|
|
+ >
|
|
|
|
|
+ 近 {{ days }} 天
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </header>
|
|
|
|
|
+
|
|
|
|
|
+ <p v-if="error" class="error">{{ error }}</p>
|
|
|
|
|
+ <p v-else-if="loading && !overview" class="loading">加载中…</p>
|
|
|
|
|
+
|
|
|
|
|
+ <template v-if="overview">
|
|
|
|
|
+ <section class="summary-grid" aria-label="费用摘要">
|
|
|
|
|
+ <article class="summary-card accent">
|
|
|
|
|
+ <span>今日费用</span>
|
|
|
|
|
+ <strong>{{ formatUsd(overview.summary.today_cost_usd) }}</strong>
|
|
|
|
|
+ <small>{{ overview.summary.today_call_count }} 次调用 · {{ overview.summary.today_biz_dt }}</small>
|
|
|
|
|
+ </article>
|
|
|
|
|
+ <article class="summary-card">
|
|
|
|
|
+ <span>昨日费用</span>
|
|
|
|
|
+ <strong>{{ formatUsd(overview.summary.yesterday_cost_usd) }}</strong>
|
|
|
|
|
+ <small>对比今日趋势</small>
|
|
|
|
|
+ </article>
|
|
|
|
|
+ <article class="summary-card">
|
|
|
|
|
+ <span>近 7 天</span>
|
|
|
|
|
+ <strong>{{ formatUsd(overview.summary.week_cost_usd) }}</strong>
|
|
|
|
|
+ <small>滚动一周合计</small>
|
|
|
|
|
+ </article>
|
|
|
|
|
+ <article class="summary-card">
|
|
|
|
|
+ <span>区间合计</span>
|
|
|
|
|
+ <strong>{{ formatUsd(overview.range.total_cost_usd) }}</strong>
|
|
|
|
|
+ <small>
|
|
|
|
|
+ {{ formatTokens(overview.range.total_tokens) }} tokens ·
|
|
|
|
|
+ {{ overview.range.call_count }} 次
|
|
|
|
|
+ </small>
|
|
|
|
|
+ </article>
|
|
|
|
|
+ </section>
|
|
|
|
|
+
|
|
|
|
|
+ <section class="panel chart-panel">
|
|
|
|
|
+ <div class="panel-head">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <h2>每日费用</h2>
|
|
|
|
|
+ <p>{{ overview.range.start_dt }} — {{ overview.range.end_dt }}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <span class="currency-tag">USD</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="bar-chart" role="img" :aria-label="`近 ${rangeDays} 天每日 LLM 费用柱状图`">
|
|
|
|
|
+ <div
|
|
|
|
|
+ v-for="day in overview.daily"
|
|
|
|
|
+ :key="day.biz_dt"
|
|
|
|
|
+ class="bar-col"
|
|
|
|
|
+ :title="`${day.label}: ${formatUsd(day.total_cost_usd)} · ${day.call_count} 次`"
|
|
|
|
|
+ >
|
|
|
|
|
+ <span class="bar-value">{{ day.total_cost_usd > 0 ? formatUsd(day.total_cost_usd, 3) : '' }}</span>
|
|
|
|
|
+ <div class="bar-track">
|
|
|
|
|
+ <div
|
|
|
|
|
+ class="bar-fill"
|
|
|
|
|
+ :class="{ empty: day.total_cost_usd <= 0 }"
|
|
|
|
|
+ :style="{ height: day.total_cost_usd > 0 ? barHeight(day.total_cost_usd, maxDailyCost) : '2px' }"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <span class="bar-label">{{ day.label.slice(5) }}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </section>
|
|
|
|
|
+
|
|
|
|
|
+ <section class="split-panels">
|
|
|
|
|
+ <article class="panel">
|
|
|
|
|
+ <div class="panel-head">
|
|
|
|
|
+ <h2>按 Agent</h2>
|
|
|
|
|
+ <p>区间费用占比</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <ul v-if="overview.by_agent.length" class="breakdown-list">
|
|
|
|
|
+ <li v-for="item in overview.by_agent" :key="item.agent_name">
|
|
|
|
|
+ <div class="row-meta">
|
|
|
|
|
+ <strong>{{ item.agent_name }}</strong>
|
|
|
|
|
+ <span>{{ formatUsd(item.total_cost_usd) }}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="h-track">
|
|
|
|
|
+ <div class="h-fill agent" :style="{ width: barWidth(item.total_cost_usd, maxAgentCost) }" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <small>{{ item.call_count }} 次 · {{ formatTokens(item.total_tokens) }} tokens</small>
|
|
|
|
|
+ </li>
|
|
|
|
|
+ </ul>
|
|
|
|
|
+ <p v-else class="empty">暂无 Agent 调用记录</p>
|
|
|
|
|
+ </article>
|
|
|
|
|
+
|
|
|
|
|
+ <article class="panel">
|
|
|
|
|
+ <div class="panel-head">
|
|
|
|
|
+ <h2>按模型</h2>
|
|
|
|
|
+ <p>区间费用占比</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <ul v-if="overview.by_model.length" class="breakdown-list">
|
|
|
|
|
+ <li v-for="item in overview.by_model" :key="item.model">
|
|
|
|
|
+ <div class="row-meta">
|
|
|
|
|
+ <strong :title="item.model">{{ shortModel(item.model) }}</strong>
|
|
|
|
|
+ <span>{{ formatUsd(item.total_cost_usd) }}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="h-track">
|
|
|
|
|
+ <div class="h-fill model" :style="{ width: barWidth(item.total_cost_usd, maxModelCost) }" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <small>{{ item.call_count }} 次 · {{ formatTokens(item.total_tokens) }} tokens</small>
|
|
|
|
|
+ </li>
|
|
|
|
|
+ </ul>
|
|
|
|
|
+ <p v-else class="empty">暂无模型调用记录</p>
|
|
|
|
|
+ </article>
|
|
|
|
|
+ </section>
|
|
|
|
|
+
|
|
|
|
|
+ <section class="panel">
|
|
|
|
|
+ <div class="panel-head">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <h2>最近调用</h2>
|
|
|
|
|
+ <p>按写入时间倒序</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="table-wrap">
|
|
|
|
|
+ <table>
|
|
|
|
|
+ <thead>
|
|
|
|
|
+ <tr>
|
|
|
|
|
+ <th>时间</th>
|
|
|
|
|
+ <th>Agent</th>
|
|
|
|
|
+ <th>模型</th>
|
|
|
|
|
+ <th>Tokens</th>
|
|
|
|
|
+ <th>费用</th>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ </thead>
|
|
|
|
|
+ <tbody>
|
|
|
|
|
+ <tr v-for="event in overview.recent_events" :key="event.id">
|
|
|
|
|
+ <td>{{ event.created_at || '—' }}</td>
|
|
|
|
|
+ <td>{{ event.agent_name || '—' }}</td>
|
|
|
|
|
+ <td :title="event.model || undefined">{{ event.model ? shortModel(event.model) : '—' }}</td>
|
|
|
|
|
+ <td>{{ formatTokens(event.total_tokens) }}</td>
|
|
|
|
|
+ <td>{{ formatUsd(event.cost_usd) }}</td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ <tr v-if="!overview.recent_events.length">
|
|
|
|
|
+ <td colspan="5" class="empty-cell">暂无调用明细。Agent 下次调用 LLM 后将自动写入。</td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ </tbody>
|
|
|
|
|
+ </table>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </section>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </main>
|
|
|
|
|
+</template>
|
|
|
|
|
+
|
|
|
|
|
+<style scoped>
|
|
|
|
|
+.billing-page {
|
|
|
|
|
+ --ink: #172033;
|
|
|
|
|
+ --muted: #6c7484;
|
|
|
|
|
+ --line: #e7e9ef;
|
|
|
|
|
+ --panel: #ffffff;
|
|
|
|
|
+ --accent: #1f6bff;
|
|
|
|
|
+ --accent-soft: #eaf1ff;
|
|
|
|
|
+ max-width: 1240px;
|
|
|
|
|
+ margin: 0 auto;
|
|
|
|
|
+ padding: 32px 24px 72px;
|
|
|
|
|
+ color: var(--ink);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.page-header {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: flex-start;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+ gap: 24px;
|
|
|
|
|
+ margin-bottom: 28px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.eyebrow {
|
|
|
|
|
+ display: inline-flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 8px;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ letter-spacing: 0.14em;
|
|
|
|
|
+ color: var(--muted);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.eyebrow span {
|
|
|
|
|
+ width: 8px;
|
|
|
|
|
+ height: 8px;
|
|
|
|
|
+ border-radius: 999px;
|
|
|
|
|
+ background: var(--accent);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+h1 {
|
|
|
|
|
+ margin: 8px 0 6px;
|
|
|
|
|
+ font-size: 28px;
|
|
|
|
|
+ letter-spacing: -0.02em;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.page-header p,
|
|
|
|
|
+.panel-head p {
|
|
|
|
|
+ margin: 0;
|
|
|
|
|
+ color: var(--muted);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.range-switch {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ gap: 8px;
|
|
|
|
|
+ flex-wrap: wrap;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.range-switch button {
|
|
|
|
|
+ border: 1px solid #cbd2df;
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+ border-radius: 999px;
|
|
|
|
|
+ padding: 8px 14px;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ color: #44506a;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.range-switch button.active {
|
|
|
|
|
+ background: #1e2638;
|
|
|
|
|
+ border-color: #1e2638;
|
|
|
|
|
+ color: #fff;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.summary-grid {
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
|
|
|
+ gap: 14px;
|
|
|
|
|
+ margin-bottom: 18px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.summary-card,
|
|
|
|
|
+.panel {
|
|
|
|
|
+ border: 1px solid var(--line);
|
|
|
|
|
+ border-radius: 16px;
|
|
|
|
|
+ background: var(--panel);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.summary-card {
|
|
|
|
|
+ padding: 18px 18px 16px;
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ gap: 6px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.summary-card span,
|
|
|
|
|
+.summary-card small {
|
|
|
|
|
+ color: var(--muted);
|
|
|
|
|
+ font-size: 13px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.summary-card strong {
|
|
|
|
|
+ font-size: 28px;
|
|
|
|
|
+ letter-spacing: -0.03em;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.summary-card.accent {
|
|
|
|
|
+ background: linear-gradient(160deg, #f7f9ff, var(--accent-soft));
|
|
|
|
|
+ border-color: #d7e3ff;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.summary-card.accent strong {
|
|
|
|
|
+ color: #1848b8;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.panel {
|
|
|
|
|
+ padding: 20px;
|
|
|
|
|
+ margin-bottom: 18px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.panel-head {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+ align-items: flex-start;
|
|
|
|
|
+ gap: 12px;
|
|
|
|
|
+ margin-bottom: 18px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.panel-head h2 {
|
|
|
|
|
+ margin: 0 0 4px;
|
|
|
|
|
+ font-size: 18px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.currency-tag {
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ letter-spacing: 0.08em;
|
|
|
|
|
+ color: #5b6b88;
|
|
|
|
|
+ background: #f3f5f9;
|
|
|
|
|
+ border-radius: 999px;
|
|
|
|
|
+ padding: 6px 10px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.bar-chart {
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ grid-auto-flow: column;
|
|
|
|
|
+ grid-auto-columns: minmax(28px, 1fr);
|
|
|
|
|
+ gap: 8px;
|
|
|
|
|
+ align-items: end;
|
|
|
|
|
+ min-height: 220px;
|
|
|
|
|
+ overflow-x: auto;
|
|
|
|
|
+ padding-bottom: 4px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.bar-col {
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ grid-template-rows: auto 1fr auto;
|
|
|
|
|
+ gap: 6px;
|
|
|
|
|
+ min-height: 200px;
|
|
|
|
|
+ justify-items: center;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.bar-value {
|
|
|
|
|
+ font-size: 10px;
|
|
|
|
|
+ color: #70809c;
|
|
|
|
|
+ min-height: 14px;
|
|
|
|
|
+ white-space: nowrap;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.bar-track {
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ max-width: 36px;
|
|
|
|
|
+ height: 160px;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: flex-end;
|
|
|
|
|
+ background: #f5f7fb;
|
|
|
|
|
+ border-radius: 10px 10px 4px 4px;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.bar-fill {
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ border-radius: 10px 10px 4px 4px;
|
|
|
|
|
+ background: linear-gradient(180deg, #4d84ff, #1f6bff);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.bar-fill.empty {
|
|
|
|
|
+ background: #d9dee8;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.bar-label {
|
|
|
|
|
+ font-size: 11px;
|
|
|
|
|
+ color: var(--muted);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.split-panels {
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ grid-template-columns: 1fr 1fr;
|
|
|
|
|
+ gap: 18px;
|
|
|
|
|
+ margin-bottom: 18px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.split-panels .panel {
|
|
|
|
|
+ margin-bottom: 0;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.breakdown-list {
|
|
|
|
|
+ list-style: none;
|
|
|
|
|
+ margin: 0;
|
|
|
|
|
+ padding: 0;
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ gap: 14px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.row-meta {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+ gap: 12px;
|
|
|
|
|
+ margin-bottom: 6px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.row-meta strong {
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ text-overflow: ellipsis;
|
|
|
|
|
+ white-space: nowrap;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.h-track {
|
|
|
|
|
+ height: 8px;
|
|
|
|
|
+ border-radius: 999px;
|
|
|
|
|
+ background: #eef1f6;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.h-fill {
|
|
|
|
|
+ height: 100%;
|
|
|
|
|
+ border-radius: 999px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.h-fill.agent {
|
|
|
|
|
+ background: linear-gradient(90deg, #4d84ff, #1f6bff);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.h-fill.model {
|
|
|
|
|
+ background: linear-gradient(90deg, #36c2a5, #159a7c);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.breakdown-list small,
|
|
|
|
|
+.empty,
|
|
|
|
|
+.loading {
|
|
|
|
|
+ color: var(--muted);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.table-wrap {
|
|
|
|
|
+ overflow-x: auto;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+table {
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ border-collapse: collapse;
|
|
|
|
|
+ font-size: 14px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+th,
|
|
|
|
|
+td {
|
|
|
|
|
+ text-align: left;
|
|
|
|
|
+ padding: 10px 8px;
|
|
|
|
|
+ border-bottom: 1px solid var(--line);
|
|
|
|
|
+ white-space: nowrap;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+th {
|
|
|
|
|
+ color: var(--muted);
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ letter-spacing: 0.04em;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.empty-cell {
|
|
|
|
|
+ text-align: center;
|
|
|
|
|
+ color: var(--muted);
|
|
|
|
|
+ padding: 28px 8px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.error {
|
|
|
|
|
+ color: #b42335;
|
|
|
|
|
+ margin-bottom: 16px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+@media (max-width: 900px) {
|
|
|
|
|
+ .summary-grid,
|
|
|
|
|
+ .split-panels {
|
|
|
|
|
+ grid-template-columns: 1fr 1fr;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .page-header {
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+@media (max-width: 640px) {
|
|
|
|
|
+ .summary-grid,
|
|
|
|
|
+ .split-panels {
|
|
|
|
|
+ grid-template-columns: 1fr;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+</style>
|