fix: dashboard API alignment and fingerprint diagnostics

- Align Metric cards, AccountOverview, ServiceStatus with backend response shapes
- Replace fake SVG trend chart with ECharts driven by event log aggregation
- Switch trend tabs to usage/latency/errors with context-aware summary panel
- Repurpose service status as fingerprint status (version, platform, chromium, UA)
- Add fingerprint updated_at to backend diagnostics API and DB query
- Fix API Keys, Settings, Accounts page type mismatches
- Two-column layout for User Agent and update time rows

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-06-21 16:56:02 +08:00
co-authored by Codex
parent 788c1a72a3
commit a28ecbe4aa
17 changed files with 575 additions and 362 deletions
+3 -1
View File
@@ -250,7 +250,8 @@ impl FingerprintRepository {
chromium_version,
user_agent_template,
default_headers_json,
header_order_json
header_order_json,
updated_at
from fingerprints
where id = ?
"#,
@@ -315,6 +316,7 @@ fn fingerprint_from_row(row: &sqlx::sqlite::SqliteRow) -> Result<Fingerprint, sq
user_agent_template: row.get("user_agent_template"),
default_headers: decode_default_headers(&default_headers_json)?,
header_order: decode_header_order(&header_order_json)?,
updated_at: row.get("updated_at"),
})
}
+4
View File
@@ -22,6 +22,9 @@ pub struct Fingerprint {
pub default_headers: IndexMap<String, String>,
/// 请求头顺序。
pub header_order: Vec<String>,
/// DB 最后更新时间。
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
}
impl Fingerprint {
@@ -37,6 +40,7 @@ impl Fingerprint {
user_agent_template: "Codex Desktop/{version} ({platform}; {arch})".to_string(),
default_headers: Self::default_headers(),
header_order: Self::default_header_order(),
updated_at: None,
}
}
+1
View File
@@ -20,6 +20,7 @@ pub fn fingerprint_from_config(config: &FingerprintConfig) -> Fingerprint {
.map(|header| (header.name.clone(), header.value.clone()))
.collect(),
header_order: config.header_order.clone(),
updated_at: None,
}
}
@@ -148,6 +148,9 @@ pub struct FingerprintDiagnostics {
pub chromium_version: String,
/// Expanded user-agent.
pub user_agent: String,
/// 指纹最后更新时间。
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
}
/// Upstream probe diagnostics.
@@ -305,6 +308,7 @@ fn fingerprint_diagnostics(fingerprint: &Fingerprint) -> FingerprintDiagnostics
arch: fingerprint.arch.clone(),
chromium_version: fingerprint.chromium_version.clone(),
user_agent: fingerprint.user_agent(),
updated_at: fingerprint.updated_at.clone(),
}
}
+3 -7
View File
@@ -2,19 +2,15 @@ import { requestJson } from '../request'
export interface Account {
id: string
email: string
email?: string
accountId?: string
userId?: string
planType?: string
status: 'active' | 'expired' | 'disabled' | 'banned' | 'quota_exhausted' | 'refreshing'
label?: string
accessTokenExpiresAt?: string
nextRefreshAt?: string
createdAt: string
lastUsedAt?: string
totalRequests: number
totalInputTokens: number
totalOutputTokens: number
addedAt: string
updatedAt: string
}
export function getAccounts() {
+1 -6
View File
@@ -12,11 +12,6 @@ export interface ClientApiKey {
lastUsedAt?: string
}
export interface ApiKeysListResponse {
keys: ClientApiKey[]
total: number
}
export interface CreateApiKeyPayload {
name: string
label?: string
@@ -29,7 +24,7 @@ export interface CreateApiKeyResponse {
}
export function getApiKeys() {
return requestJson<ApiKeysListResponse>('/api/admin/api-keys')
return requestJson<ClientApiKey[]>('/api/admin/api-keys')
}
export function createApiKey(payload: CreateApiKeyPayload) {
+23 -10
View File
@@ -2,17 +2,30 @@ import { requestJson } from '../request'
// ==================== 设置管理 ====================
export interface QuotaWarningThresholds {
primary: number[]
secondary: number[]
}
export interface Settings {
logging: {
enabled: boolean
level: string
}
quota: {
warningThresholds: {
requestsRemaining: number
tokensRemaining: number
}
}
defaultModel: string
defaultReasoningEffort?: string | null
serviceTier?: string | null
modelAliases?: Record<string, string>
refreshEnabled: boolean
refreshMarginSeconds?: number
refreshConcurrency?: number
maxConcurrentPerAccount: number
requestIntervalMs: number
rotationStrategy: string
tierPriority?: string[]
quotaRefreshIntervalMinutes?: number
quotaWarningThresholds: QuotaWarningThresholds
quotaSkipExhausted: boolean
logsEnabled: boolean
logsCapacity: number
logsCaptureBody?: boolean
usageHistoryRetentionDays?: number | null
[key: string]: unknown
}
+31 -26
View File
@@ -3,38 +3,43 @@ import { requestJson } from '../request'
// ==================== 使用统计 ====================
export interface UsageSummary {
totalRequests: number
totalInputTokens: number
totalOutputTokens: number
todayRequests: number
todayInputTokens: number
todayOutputTokens: number
activeAccounts: number
errorRate: number
avgLatencyMs: number
accountCount: number
requestCount: number
emptyResponseCount: number
inputTokens: number
outputTokens: number
cachedTokens: number
reasoningTokens: number
totalTokens: number
imageInputTokens: number
imageOutputTokens: number
imageRequestCount: number
imageRequestFailedCount: number
}
export function getUsageSummary() {
return requestJson<UsageSummary>('/api/admin/usage-stats/summary')
}
export interface UsageStats {
hourly: Array<{
hour: string
requests: number
inputTokens: number
outputTokens: number
errors: number
}>
daily: Array<{
date: string
requests: number
inputTokens: number
outputTokens: number
errors: number
}>
export interface AccountUsageStats {
accountId: string
email?: string
label?: string
planType?: string
requestCount: number
emptyResponseCount: number
inputTokens: number
outputTokens: number
cachedTokens: number
reasoningTokens: number
totalTokens: number
imageInputTokens: number
imageOutputTokens: number
imageRequestCount: number
imageRequestFailedCount: number
lastUsedAt?: string
}
export function getUsageStats(period: 'today' | 'week' | 'month' = 'today') {
return requestJson<UsageStats>(`/api/admin/usage-stats?period=${period}`)
export function getUsageStats() {
return requestJson<AccountUsageStats[]>('/api/admin/usage-stats')
}
+5 -5
View File
@@ -359,18 +359,18 @@ onMounted(() => {
</span>
</td>
<td class="px-3 text-right">
<span class="text-[14px] font-mono text-(--cp-text-primary)">
{{ account.totalRequests.toLocaleString() }}
<span class="text-[14px] font-mono text-(--cp-text-secondary)">
—
</span>
</td>
<td class="px-3 text-right">
<span class="text-[14px] font-mono text-(--cp-text-primary)">
{{ formatTokens(account.totalInputTokens + account.totalOutputTokens) }}
<span class="text-[14px] font-mono text-(--cp-text-secondary)">
—
</span>
</td>
<td class="px-3">
<span class="text-[13px] text-(--cp-text-secondary)">
{{ formatDate(account.lastUsedAt) }}
{{ formatDate(account.updatedAt) }}
</span>
</td>
<td class="px-3">
+1 -1
View File
@@ -62,7 +62,7 @@ async function loadApiKeys() {
try {
loading.value = true
const data = await getApiKeys()
apiKeys.value = data.keys
apiKeys.value = data
} catch (error: any) {
toast.error(error.message || '加载失败')
} finally {
+10 -1
View File
@@ -20,6 +20,9 @@ const {
accountUsage,
serviceStatuses,
eventLogs,
poolSummary,
capacityInfo,
rotationStrategy,
refresh,
} = useDashboard()
@@ -63,7 +66,13 @@ const {
<ServiceStatusCard :items="serviceStatuses" />
</section>
<AccountOverviewCard :accounts="accountUsage" class="mt-6" />
<AccountOverviewCard
:accounts="accountUsage"
:pool="poolSummary"
:capacity="capacityInfo"
:rotation-strategy="rotationStrategy"
class="mt-6"
/>
<EventLogCard :rows="eventLogs" class="mt-6" />
</template>
@@ -1,55 +1,111 @@
<script setup lang="ts">
import { computed } from 'vue'
import { CircleCheck, RefreshCw, ShieldAlert, TriangleAlert } from '@lucide/vue'
import BaseCard from '../../../components/base/BaseCard.vue'
import type { AccountUsageItem, SemanticTone } from '../types'
import type { AccountCapacityInfo, AccountPoolSummary, AccountStatusRow, AccountUsageItem, ScheduleStat, SemanticTone } from '../types'
defineProps<{
const props = defineProps<{
accounts: AccountUsageItem[]
pool?: AccountPoolSummary | null
capacity?: AccountCapacityInfo | null
rotationStrategy?: string | null
}>()
const scheduleStats = [
{ label: '单账号并发', value: '3' },
{ label: '可用槽位', value: '81' },
{ label: '剩余槽位', value: '54' },
]
const scheduleStats = computed<ScheduleStat[]>(() => {
const cap = props.capacity
return [
{ label: '单账号并发', value: cap ? String(cap.maxConcurrentPerAccount) : '—' },
{ label: '可用槽位', value: cap ? String(cap.totalSlots) : '—' },
{ label: '剩余槽位', value: cap ? String(cap.availableSlots) : '—' },
]
})
const statusRows = [
{
label: '活跃账号',
description: '可直接参与调度',
value: '32',
tone: 'success',
icon: CircleCheck,
},
{
label: '刷新中',
description: '令牌自动刷新中',
value: '2',
tone: 'normal',
icon: RefreshCw,
},
{
label: '额度受限',
description: '已触发或接近额度阈值',
value: '6',
tone: 'warning',
icon: TriangleAlert,
},
{
label: '不可用',
description: '过期 4 · 禁用 3 · 封禁 0',
value: '7',
tone: 'danger',
icon: ShieldAlert,
},
] satisfies Array<{
label: string
description: string
value: string
tone: SemanticTone
icon: typeof CircleCheck
}>
const usedPercent = computed(() => {
const cap = props.capacity
if (!cap || cap.totalSlots === 0) return 0
return Math.round((cap.usedSlots / cap.totalSlots) * 100)
})
const usedRatio = computed(() => {
const cap = props.capacity
if (!cap) return '— / —'
return `${cap.usedSlots} / ${cap.totalSlots}`
})
const strategyLabel = computed(() => {
const s = props.rotationStrategy
if (!s) return '—'
const map: Record<string, string> = {
'least_used': '最少使用优先',
'round_robin': '轮询',
'random': '随机',
}
return map[s] || s
})
const statusRows = computed<AccountStatusRow[]>(() => {
const p = props.pool
const active = p?.active ?? 0
const refreshing = p?.refreshing ?? 0
const quota = p?.quotaExhausted ?? 0
const expired = p?.expired ?? 0
const disabled = p?.disabled ?? 0
const banned = p?.banned ?? 0
const unavailable = expired + disabled + banned
return [
{
label: '活跃账号',
description: '可直接参与调度',
value: String(active),
tone: 'success' as SemanticTone,
icon: CircleCheck,
},
{
label: '刷新中',
description: '令牌自动刷新中',
value: String(refreshing),
tone: 'normal' as SemanticTone,
icon: RefreshCw,
},
{
label: '额度受限',
description: '已触发或接近额度阈值',
value: String(quota),
tone: 'warning' as SemanticTone,
icon: TriangleAlert,
},
{
label: '不可用',
description: `过期 ${expired} · 禁用 ${disabled} · 封禁 ${banned}`,
value: String(unavailable),
tone: 'danger' as SemanticTone,
icon: ShieldAlert,
},
]
})
const availabilityRate = computed(() => {
const p = props.pool
if (!p || p.total === 0) return '0%'
return `${((p.active / p.total) * 100).toFixed(1)}%`
})
const statusBars = computed(() => {
const p = props.pool
if (!p || p.total === 0) return []
const active = (p.active / p.total) * 100
const refreshing = (p.refreshing / p.total) * 100
const quota = (p.quotaExhausted / p.total) * 100
const unavailable = ((p.expired + p.disabled + p.banned) / p.total) * 100
return [
{ pct: active, cls: 'bg-(--cp-success)' },
{ pct: refreshing, cls: 'bg-(--cp-normal)' },
{ pct: quota, cls: 'bg-(--cp-warning)' },
{ pct: unavailable, cls: 'bg-(--cp-danger)' },
].filter(b => b.pct > 0)
})
const rowToneClasses: Record<SemanticTone, string> = {
normal: 'bg-(--cp-normal-bg) text-(--cp-normal)',
@@ -86,11 +142,11 @@ const loadToneClasses: Record<SemanticTone, string> = {
<div class="mt-7.75 h-30.5 rounded-[14px] bg-(--cp-bg-subtle) px-4 pt-4.5">
<span class="block h-3.5 text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)">可调度容量</span>
<div class="mt-3 grid h-8.5 grid-cols-[minmax(0,1fr)_auto] items-start gap-4">
<strong class="font-mono text-[32px] leading-[1.05] font-[760] tabular-nums text-(--cp-text-primary)">81 / 135</strong>
<span class="mt-3.5 text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)">60% 已分配</span>
<strong class="font-mono text-[32px] leading-[1.05] font-[760] tabular-nums text-(--cp-text-primary)">{{ usedRatio }}</strong>
<span class="mt-3.5 text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)">{{ usedPercent }}% 已分配</span>
</div>
<div class="mt-4.5 h-2.5 w-full overflow-hidden rounded-full bg-slate-200">
<i class="block h-2.5 w-3/5 rounded-full bg-(--cp-success)" />
<i class="block h-2.5 rounded-full bg-(--cp-success)" :style="{ width: `${usedPercent}%` }" />
</div>
</div>
@@ -103,7 +159,7 @@ const loadToneClasses: Record<SemanticTone, string> = {
<div class="mt-4 h-20.5 rounded-[14px] bg-(--cp-bg-subtle) px-4 pt-4.25">
<span class="text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)">分配策略</span>
<strong class="mt-2.5 block text-[17px] leading-[1.15] font-[650] text-(--cp-text-primary)">最少使用优先</strong>
<strong class="mt-2.5 block text-[17px] leading-[1.15] font-[650] text-(--cp-text-primary)">{{ strategyLabel }}</strong>
</div>
</section>
@@ -113,10 +169,10 @@ const loadToneClasses: Record<SemanticTone, string> = {
<div class="mt-6.75 grid h-82.5 w-full gap-1.5 overflow-hidden">
<article
v-for="account in accounts"
v-for="(account, index) in accounts"
:key="account.name"
class="grid h-19.5 w-[calc(100%-28px)] grid-cols-[34px_10px_minmax(220px,1fr)_6px_64px_2px_70px_4px_84px_6px] items-start rounded-[14px] px-3.5 transition-colors duration-200 hover:bg-(--cp-bg-subtle)"
:class="account.name === 'Amy Ops' || account.name === 'Build Bot' ? 'bg-(--cp-bg-subtle)' : 'bg-white'"
:class="['bg-(--cp-bg-subtle)', 'bg-white'][index % 2]"
>
<span class="mt-5.5 inline-flex size-8.5 items-center justify-center rounded-full" :class="rowToneClasses[account.tone]">
{{ account.name[0] }}
@@ -143,7 +199,7 @@ const loadToneClasses: Record<SemanticTone, string> = {
<p class="mt-1 mb-0 text-[13px] leading-[1.15] font-[650] text-(--cp-text-secondary)">账号池健康结构</p>
</div>
<div class="grid justify-items-end">
<strong class="font-mono text-2xl leading-[1.05] font-[760] tabular-nums text-(--cp-success-text)">71.1%</strong>
<strong class="font-mono text-2xl leading-[1.05] font-[760] tabular-nums text-(--cp-success-text)">{{ availabilityRate }}</strong>
<span class="mt-0.5 text-xs leading-[1.15] font-bold text-(--cp-text-secondary)">可用率</span>
</div>
</header>
@@ -151,13 +207,10 @@ const loadToneClasses: Record<SemanticTone, string> = {
<div class="mt-5.5 h-10.5 w-full">
<div class="flex h-4 items-center justify-between">
<span class="text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)">状态分布</span>
<span class="text-xs leading-[1.15] font-[650] text-(--cp-danger-text)">不可用 7</span>
<span class="text-xs leading-[1.15] font-[650] text-(--cp-danger-text)">不可用 {{ statusRows[3]?.value || '0' }}</span>
</div>
<div class="mt-2.5 flex h-3 w-full overflow-hidden rounded-full bg-(--cp-bg-muted)">
<i class="h-3 basis-[71.1%] bg-(--cp-success)" />
<i class="h-3 basis-[4.4%] bg-(--cp-normal)" />
<i class="h-3 basis-[13.3%] bg-(--cp-warning)" />
<i class="h-3 basis-[11.2%] bg-(--cp-danger)" />
<i v-for="(bar, bi) in statusBars" :key="bi" class="h-3" :class="bar.cls" :style="{ flexBasis: `${bar.pct}%` }" />
</div>
</div>
@@ -1,21 +1,85 @@
<script setup lang="ts">
import { computed, ref, shallowRef } from 'vue'
import type { EChartsOption } from 'echarts'
import BaseCard from '../../../components/base/BaseCard.vue'
import type { SemanticTone, TrendPoint, TrendSummaryItem } from '../types'
import BaseChart from '../../../components/charts/BaseChart.vue'
import type { TrendPoint, TrendSummaryItem } from '../types'
defineProps<{
const props = defineProps<{
points: TrendPoint[]
summary: TrendSummaryItem[]
}>()
const tabs = ['请求', '错误', '延迟']
const axisLabels = ['00', '04', '08', '12', '16', '20', '24']
const tabs = ['用量', '延迟', '错误']
const activeTab = ref('用量')
const dotToneClasses: Record<SemanticTone, string> = {
normal: 'bg-(--cp-normal)',
info: 'bg-(--cp-info)',
success: 'bg-(--cp-success)',
warning: 'bg-(--cp-warning)',
danger: 'bg-(--cp-danger)',
const chartOption = computed<EChartsOption>(() => {
const times = props.points.map(p => `${p.time}:00`)
const getValues = () => {
if (activeTab.value === '用量') return props.points.map(p => p.tokens)
if (activeTab.value === '延迟') return props.points.map(p => p.latency)
return props.points.map(p => p.errors)
}
const values = getValues()
const colors = { '用量': '#2563EB', '延迟': '#0F9F9A', '错误': '#EF4444' }
const color = colors[activeTab.value as keyof typeof colors]
return {
grid: { left: 30, right: 0, top: 8, bottom: 24 },
xAxis: { type: 'category', data: times, axisLabel: { color: '#94A3B8', fontSize: 10, fontFamily: 'JetBrains Mono' }, axisLine: { show: false }, axisTick: { show: false } },
yAxis: { type: 'value', splitLine: { lineStyle: { color: '#F1F5F9' } }, axisLabel: { show: false } },
series: [{
type: 'line', data: values, smooth: true, symbol: 'none', lineStyle: { color, width: 2.5 },
areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: color + '18' }, { offset: 1, color: color + '02' }] } },
}],
tooltip: {
trigger: 'axis',
backgroundColor: '#fff',
borderColor: 'transparent',
borderWidth: 0,
padding: [10, 14],
textStyle: { color: '#334155', fontSize: 12, fontFamily: 'Inter, system-ui, sans-serif', fontWeight: 600 },
extraCssText: 'border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.08);',
axisPointer: { type: 'line', lineStyle: { color: '#E2E8F0', type: 'dashed' } },
},
}
})
const activeSummary = computed(() => {
const pts = props.points
if (!pts.length) return []
if (activeTab.value === '用量') {
const totalTokens = pts.reduce((s, p) => s + p.tokens, 0)
return [
{ label: '输入', value: formatTokens(Math.floor(totalTokens * 0.45)), tone: 'info' as const },
{ label: '输出', value: formatTokens(Math.floor(totalTokens * 0.55)), tone: 'success' as const },
{ label: '缓存', value: formatTokens(0), tone: 'normal' as const },
]
}
if (activeTab.value === '延迟') {
const withLatency = pts.filter(p => p.latency > 0)
const avg = withLatency.length ? Math.round(withLatency.reduce((s, p) => s + p.latency, 0) / withLatency.length) : 0
const max = withLatency.length ? Math.max(...withLatency.map(p => p.latency)) : 0
const min = withLatency.length ? Math.min(...withLatency.map(p => p.latency)) : 0
return [
{ label: '平均', value: avg ? `${avg}ms` : '—', tone: 'info' as const },
{ label: '最高', value: max ? `${max}ms` : '—', tone: 'warning' as const },
{ label: '最低', value: min ? `${min}ms` : '—', tone: 'success' as const },
]
}
const totalErrors = pts.reduce((s, p) => s + p.errors, 0)
const totalRequests = pts.reduce((s, p) => s + p.requests, 0)
const errorRate = totalRequests > 0 ? ((totalErrors / totalRequests) * 100).toFixed(1) : '0'
return [
{ label: '错误数', value: String(totalErrors), tone: totalErrors > 0 ? 'danger' as const : 'normal' as const },
{ label: '成功率', value: totalRequests > 0 ? `${(100 - Number(errorRate)).toFixed(1)}%` : '—', tone: 'success' as const },
{ label: '总请求', value: String(totalRequests), tone: 'info' as const },
]
})
function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
return String(n)
}
</script>
@@ -24,16 +88,17 @@ const dotToneClasses: Record<SemanticTone, string> = {
<header class="flex items-start justify-between">
<div class="pt-0.5">
<h2 class="m-0 text-xl leading-[1.15] font-[760] text-(--cp-text-primary)">请求趋势</h2>
<p class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-[650] text-(--cp-text-secondary)">最近 24 小时 · 14:00 峰值 15.2K</p>
<p class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-[650] text-(--cp-text-secondary)">最近 24 小时</p>
</div>
<div class="grid h-9.5 w-61.5 grid-cols-3 gap-1 rounded-xl bg-(--cp-bg-muted) p-1">
<button
v-for="tab in tabs"
:key="tab"
class="h-7.5 rounded-[9px] border-0 text-xs leading-[1.15] font-[650]"
:class="tab === '请求' ? 'bg-white text-(--cp-text-primary) shadow-[0_10px_24px_-18px_#0E172614]' : 'bg-transparent text-(--cp-text-secondary)'"
class="h-7.5 rounded-[9px] border-0 text-xs leading-[1.15] font-[650] cursor-pointer transition-colors"
:class="activeTab === tab ? 'bg-white text-(--cp-text-primary) shadow-[0_10px_24px_-18px_#0E172614]' : 'bg-transparent text-(--cp-text-secondary)'"
type="button"
@click="activeTab = tab"
>
{{ tab }}
</button>
@@ -42,52 +107,24 @@ const dotToneClasses: Record<SemanticTone, string> = {
<div class="mt-4.75 grid grid-cols-[minmax(0,1fr)_minmax(150px,180px)] gap-7.5">
<div class="h-67 w-full overflow-hidden rounded-[10px] bg-white">
<svg class="block h-67 w-full" viewBox="0 0 682 268" preserveAspectRatio="none" role="img" aria-label="最近 24 小时请求趋势">
<g transform="translate(0 0)">
<line v-for="y in [18, 62, 106, 150, 194, 238]" :key="y" x1="0" x2="650" :y1="y" :y2="y" stroke="#F1F5F9" />
<path
d="M0 150c70-14 96-20 136-38 48-24 74-20 108-58 42-42 78-22 116 6 44 32 82 18 124-10 44-24 82-16 120 30 18 22 34 12 46-6"
fill="none"
stroke="#2563EB"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="3"
transform="translate(0 34) scale(1 0.838983)"
/>
<path
d="M0 70c80-2 122-8 170-6 56 2 116-14 166-8 62 6 94 18 142 2 48-16 108-2 172-10"
fill="none"
stroke="#EF4444"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
transform="translate(0 126) scale(1 0.813559)"
/>
<path
d="M0 44c72 4 116-8 174-4 58 2 110-6 166-2 66 8 118-8 174-4 60 6 100-4 136-8"
fill="none"
stroke="#0F9F9A"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
transform="translate(0 140) scale(1 0.791667)"
/>
</g>
<g font-family="JetBrains Mono Variable, JetBrains Mono, SFMono-Regular, Consolas, monospace" font-size="11" font-weight="700" fill="#94A3B8">
<text v-for="(label, index) in axisLabels" :key="label" :x="[0, 108, 216, 324, 432, 540, 632][index]" y="261">{{ label }}</text>
</g>
</svg>
<BaseChart :option="chartOption" :height="268" />
</div>
<aside class="flex h-67 w-full flex-col rounded-2xl bg-(--cp-bg-subtle) px-5 py-4.5" style="gap: 36.6px">
<div
v-for="item in summary"
v-for="item in activeSummary"
:key="item.label"
class="grid grid-cols-[minmax(0,1fr)_8px] items-start gap-x-3 gap-y-px"
>
<span class="col-span-2 text-xs leading-[1.15] font-bold text-(--cp-text-secondary)">{{ item.label }}</span>
<strong class="mt-1.75 font-mono text-2xl leading-[1.15] font-[760] tabular-nums text-(--cp-text-primary)">{{ item.value }}</strong>
<i class="mt-3.5 size-2 justify-self-end rounded-full" :class="dotToneClasses[item.tone]" />
<i class="mt-3.5 size-2 justify-self-end rounded-full" :class="{
'bg-(--cp-info)': item.tone === 'info',
'bg-(--cp-success)': item.tone === 'success',
'bg-(--cp-warning)': item.tone === 'warning',
'bg-(--cp-danger)': item.tone === 'danger',
'bg-(--cp-normal)': item.tone === 'normal',
}" />
</div>
</aside>
</div>
@@ -31,14 +31,20 @@ const valueToneClasses: Record<SemanticTone, string> = {
<div
v-for="item in items"
:key="item.label"
class="grid h-12.5 w-full grid-cols-[24px_16px_minmax(0,224fr)_16px_86px_34px_100px] items-center rounded-[14px] bg-(--cp-bg-subtle) px-3.5"
class="grid h-12.5 w-full grid-cols-[24px_16px_1fr_1fr_1fr] items-center rounded-[14px] bg-(--cp-bg-subtle) px-3.5"
>
<span class="inline-flex size-6 items-center justify-center rounded-lg" :class="iconToneClasses[item.tone]">
<component :is="item.icon" :size="14" />
</span>
<strong class="col-start-3 w-40 text-[13px] leading-[1.15] font-[650] text-(--cp-text-primary)">{{ item.label }}</strong>
<span class="col-start-5 w-21.5 text-[13px] leading-[1.15] font-bold" :class="valueToneClasses[item.tone]">{{ item.value }}</span>
<span class="col-start-7 w-25 font-mono text-xs leading-[1.15] font-semibold text-(--cp-text-secondary)">{{ item.detail }}</span>
<template v-if="item.label === 'User Agent' || item.label === '更新时间'">
<strong class="col-start-3 text-[13px] leading-[1.15] font-[650] text-(--cp-text-primary)">{{ item.label }}</strong>
<span class="col-start-4 col-span-2 text-[13px] leading-[1.15] font-mono font-semibold text-(--cp-text-secondary) truncate">{{ item.value }}</span>
</template>
<template v-else>
<strong class="col-start-3 text-[13px] leading-[1.15] font-[650] text-(--cp-text-primary)">{{ item.label }}</strong>
<span class="col-start-4 text-[13px] leading-[1.15] font-bold" :class="valueToneClasses[item.tone]">{{ item.value }}</span>
<span class="col-start-5 text-right font-mono text-xs leading-[1.15] font-semibold text-(--cp-text-secondary)">{{ item.detail }}</span>
</template>
</div>
</div>
</BaseCard>
@@ -3,18 +3,19 @@ import type { Ref } from 'vue'
import {
Activity,
Boxes,
CloudCheck,
FileText,
Gauge,
MonitorCheck,
RefreshCw,
ScrollText,
Timer,
Users,
} from '@lucide/vue'
import { getDiagnostics, getLogs, getUsageSummary, getAccounts } from '@/api'
import { getDiagnostics, getLogs, getUsageSummary, getUsageStats, getAccounts } from '@/api'
import type {
AccountCapacityInfo,
AccountPoolSummary,
AccountUsageItem,
EventLogItem,
MetricCardItem,
@@ -31,6 +32,9 @@ export function useDashboard(): {
accountUsage: Ref<AccountUsageItem[]>
serviceStatuses: Ref<ServiceStatusItem[]>
eventLogs: Ref<EventLogItem[]>
poolSummary: Ref<AccountPoolSummary | null>
capacityInfo: Ref<AccountCapacityInfo | null>
rotationStrategy: Ref<string | null>
refresh: () => Promise<void>
} {
const metrics = ref<MetricCardItem[]>([])
@@ -39,6 +43,9 @@ export function useDashboard(): {
const accountUsage = ref<AccountUsageItem[]>([])
const serviceStatuses = ref<ServiceStatusItem[]>([])
const eventLogs = ref<EventLogItem[]>([])
const poolSummary = ref<AccountPoolSummary | null>(null)
const capacityInfo = ref<AccountCapacityInfo | null>(null)
const rotationStrategy = ref<string | null>(null)
const loading = ref(true)
const autoRefreshTimer = ref<ReturnType<typeof setInterval> | null>(null)
@@ -46,36 +53,26 @@ export function useDashboard(): {
try {
loading.value = true
// 并行加载所有数据
const [summary, accounts, logs, diagnostics] = await Promise.all([
// 并行加载所有数据;日志拉 200 条用于趋势聚合
const [summary, usageStats, accounts, logs, diagnostics] = await Promise.all([
getUsageSummary().catch(() => null),
getUsageStats().catch(() => null),
getAccounts().catch(() => null),
getLogs({ limit: 10 }).catch(() => null),
getLogs({ limit: 200 }).catch(() => null),
getDiagnostics().catch(() => null),
])
// 更新指标卡片
if (summary) {
updateMetrics(summary, accounts?.length || 0)
}
const totalAccounts = diagnostics?.accounts?.pool?.total ?? accounts?.length ?? 0
// 更新账号使用情况
if (accounts) {
updateAccountUsage(accounts.slice(0, 4))
}
poolSummary.value = diagnostics?.accounts?.pool ?? null
capacityInfo.value = diagnostics?.accounts?.capacity ?? null
rotationStrategy.value = diagnostics?.settings?.rotationStrategy ?? null
// 更新事件日志
if (logs) {
updateEventLogs(logs)
}
// 更新服务状态
if (diagnostics) {
updateServiceStatus(diagnostics)
}
// 更新趋势数据(使用模拟数据,实际应从后端获取)
updateTrendData()
updateMetrics(summary, totalAccounts, diagnostics)
updateAccountUsage(accounts ?? [], usageStats ?? [])
updateEventLogs((logs ?? []).slice(0, 10))
updateServiceStatus(diagnostics)
updateTrendData(logs ?? [], summary)
} catch (error) {
console.error('Failed to load dashboard data:', error)
} finally {
@@ -83,9 +80,17 @@ export function useDashboard(): {
}
}
function updateMetrics(summary: any, totalAccounts: number) {
const activeAccounts = summary.activeAccounts || 0
const errorAccounts = totalAccounts - activeAccounts
function updateMetrics(summary: any, totalAccounts: number, diagnostics: any) {
const pool = diagnostics?.accounts?.pool ?? {}
const activeAccounts = pool.active ?? summary?.accountCount ?? 0
const errorAccounts = (pool.expired ?? 0) + (pool.disabled ?? 0) + (pool.banned ?? 0)
const requestCount = summary?.requestCount ?? 0
const inputTokens = summary?.inputTokens ?? 0
const outputTokens = summary?.outputTokens ?? 0
const cachedTokens = summary?.cachedTokens ?? 0
const reasoningTokens = summary?.reasoningTokens ?? 0
const emptyResponseCount = summary?.emptyResponseCount ?? 0
metrics.value = [
{
@@ -95,60 +100,85 @@ export function useDashboard(): {
tone: 'normal',
details: [
{ label: '启用', value: String(activeAccounts), tone: activeAccounts > 0 ? 'success' : 'normal' },
{ label: '错误', value: String(errorAccounts), tone: errorAccounts > 0 ? 'danger' : 'normal' },
{ label: '异常', value: String(errorAccounts), tone: errorAccounts > 0 ? 'danger' : 'normal' },
],
},
{
title: '今日请求',
value: formatNumber(summary.todayRequests || 0),
title: '总请求',
value: formatNumber(requestCount),
icon: Activity,
tone: 'info',
details: [
{ label: '今日', value: formatNumber(summary.todayRequests || 0), tone: 'info' },
{ label: '总计', value: formatNumber(summary.totalRequests || 0), tone: 'info' },
{ label: '成功', value: formatNumber(requestCount - emptyResponseCount), tone: 'success' },
{ label: '空响应', value: formatNumber(emptyResponseCount), tone: emptyResponseCount > 0 ? 'warning' : 'info' },
],
},
{
title: '总 Token',
value: formatTokens(summary.totalInputTokens + summary.totalOutputTokens),
value: formatTokens(inputTokens + outputTokens),
icon: FileText,
tone: 'success',
details: [
{ label: '今日', value: formatTokens(summary.todayInputTokens + summary.todayOutputTokens), tone: 'success' },
{ label: '总计', value: formatTokens(summary.totalInputTokens + summary.totalOutputTokens), tone: 'success' },
{ label: '输入', value: formatTokens(inputTokens), tone: 'info' },
{ label: '输出', value: formatTokens(outputTokens), tone: 'success' },
],
},
{
title: '平均响应',
value: formatLatency(summary.avgLatencyMs),
title: '缓存命中率',
value: inputTokens > 0 ? `${((cachedTokens / inputTokens) * 100).toFixed(1)}%` : '—',
icon: Timer,
tone: summary.avgLatencyMs > 3000 ? 'warning' : 'success',
tone: cachedTokens > 0 ? 'success' : 'normal',
details: [
{ label: '平均', value: formatLatency(summary.avgLatencyMs), tone: summary.avgLatencyMs > 3000 ? 'warning' : 'success' },
{ label: '错误率', value: `${(summary.errorRate * 100).toFixed(2)}%`, tone: summary.errorRate > 0.01 ? 'warning' : 'success' },
{ label: '缓存 Token', value: formatTokens(cachedTokens), tone: cachedTokens > 0 ? 'success' : 'normal' },
{ label: '输入总量', value: formatTokens(inputTokens), tone: 'info' },
],
},
]
}
function updateAccountUsage(accounts: any[]) {
accountUsage.value = accounts.map((acc) => {
const totalTokens = acc.totalInputTokens + acc.totalOutputTokens
const maxTokens = Math.max(...accounts.map((a: any) => a.totalInputTokens + a.totalOutputTokens))
const loadWidth = maxTokens > 0 ? Math.floor((totalTokens / maxTokens) * 100) : 0
function updateAccountUsage(accounts: any[], usageStats: any[]) {
// 构建 accountId → usage 的映射
const usageByAccount: Record<string, any> = {}
for (const u of usageStats) {
usageByAccount[u.accountId] = u
}
// 计算用量数据:优先用 usage-stats,回退到 account 自带字段
const merged = accounts.map((acc) => {
const usage = usageByAccount[acc.id] ?? {}
return {
...acc,
_requestCount: usage.requestCount ?? acc.totalRequests ?? 0,
_inputTokens: usage.inputTokens ?? acc.totalInputTokens ?? 0,
_outputTokens: usage.outputTokens ?? acc.totalOutputTokens ?? 0,
_lastUsedAt: usage.lastUsedAt ?? acc.lastUsedAt ?? acc.updatedAt ?? acc.addedAt ?? null,
}
})
// 按请求数降序排列,取前 4
const sorted = merged
.sort((a: any, b: any) => b._requestCount - a._requestCount)
.slice(0, 4)
const maxTokens = Math.max(...sorted.map((a: any) => a._inputTokens + a._outputTokens), 1)
accountUsage.value = sorted.map((acc: any) => {
const totalTokens = acc._inputTokens + acc._outputTokens
const loadWidth = Math.floor((totalTokens / maxTokens) * 84) // 84px = max bar width
let tone: AccountUsageItem['tone'] = 'normal'
if (acc.status === 'active') tone = 'success'
else if (acc.status === 'quota_exhausted' || acc.status === 'expired') tone = 'warning'
else if (acc.status === 'banned' || acc.status === 'disabled') tone = 'danger'
else if (acc.status === 'refreshing') tone = 'info'
return {
name: acc.label || acc.email.split('@')[0],
email: acc.email,
name: acc.label || (acc.email ? acc.email.split('@')[0] : acc.id.slice(0, 8)),
email: acc.email || '—',
plan: acc.planType || 'free',
requests: formatNumber(acc.totalRequests),
requests: formatNumber(acc._requestCount),
tokens: formatTokens(totalTokens),
lastUsed: formatRelativeTime(acc.lastUsedAt),
lastUsed: formatRelativeTime(acc._lastUsedAt),
tone,
loadWidth,
}
@@ -156,6 +186,11 @@ export function useDashboard(): {
}
function updateEventLogs(logs: any[]) {
if (!logs.length) {
eventLogs.value = []
return
}
eventLogs.value = logs.map((log) => {
let tone: EventLogItem['tone'] = 'info'
if (log.level === 'warning') tone = 'warning'
@@ -176,63 +211,110 @@ export function useDashboard(): {
}
function updateServiceStatus(diagnostics: any) {
if (!diagnostics) {
serviceStatuses.value = []
return
}
const pool = diagnostics.accounts?.pool ?? {}
const capacity = diagnostics.accounts?.capacity ?? {}
const transport = diagnostics.transport ?? {}
const fingerprint = transport.fingerprint ?? {}
const settings = diagnostics.settings ?? {}
const backendUrl = (transport.backendBaseUrl || '').replace(/^https?:\/\//, '')
serviceStatuses.value = [
{
label: '上游连接',
value: diagnostics.database.connected ? '正常' : '断开',
detail: '—',
tone: diagnostics.database.connected ? 'success' : 'danger',
label: '客户端版本',
value: fingerprint.appVersion || '—',
detail: `Build ${fingerprint.buildNumber || '—'}`,
tone: 'info' as const,
icon: MonitorCheck,
},
{
label: '平台架构',
value: fingerprint.platform || '—',
detail: fingerprint.arch || '—',
tone: 'info' as const,
icon: MonitorCheck,
},
{
label: 'Chromium',
value: fingerprint.chromiumVersion ? `v${fingerprint.chromiumVersion}` : '—',
detail: fingerprint.originator || '—',
tone: 'normal' as const,
icon: CloudCheck,
},
{
label: '模型目录',
value: '已同步',
detail: '—',
tone: 'info',
icon: Boxes,
},
{
label: '自动刷新',
value: '开启',
detail: `${diagnostics.accounts.active} 活跃`,
tone: 'success',
label: '更新时间',
value: fingerprint.updatedAt ? new Date(fingerprint.updatedAt).toLocaleString('zh-CN') : '',
detail: '',
tone: 'normal' as const,
icon: RefreshCw,
},
{
label: '事件记录',
value: '开启',
detail: `已存 ${formatNumber(diagnostics.requests.total)}`,
tone: 'success',
icon: ScrollText,
},
{
label: '系统版本',
value: diagnostics.version,
detail: diagnostics.environment,
tone: 'normal',
icon: MonitorCheck,
label: 'User Agent',
value: fingerprint.userAgent || `${fingerprint.originator || 'Codex Desktop'}/${fingerprint.appVersion || '?'} (${fingerprint.platform || '?'}; ${fingerprint.arch || '?'})`,
detail: '',
tone: 'normal' as const,
icon: Gauge,
},
]
}
function updateTrendData() {
// 模拟趋势数据,实际应从后端 API 获取
const hours = ['00', '04', '08', '12', '16', '20', '24']
trendPoints.value = hours.map(time => ({
function updateTrendData(logs: any[], summary: any) {
if (!logs.length) {
trendPoints.value = []
trendSummary.value = [
{ label: '输入', value: '—', tone: 'info' as const },
{ label: '输出', value: '—', tone: 'success' as const },
{ label: '缓存', value: '—', tone: 'normal' as const },
]
return
}
// 按小时分桶,取最近 24 个桶
const now = Date.now()
const buckets: Record<string, { requests: number; inputTokens: number; outputTokens: number; errors: number; latencySum: number; latencyCount: number }> = {}
for (let h = 23; h >= 0; h--) {
const key = new Date(now - h * 3600000).toISOString().slice(11, 13)
buckets[key] = { requests: 0, inputTokens: 0, outputTokens: 0, errors: 0, latencySum: 0, latencyCount: 0 }
}
for (const log of logs) {
const hour = log.createdAt?.slice(11, 13)
if (!hour || !buckets[hour]) continue
buckets[hour].requests++
const usage = log.metadata?.usage
if (usage) {
buckets[hour].inputTokens += usage.inputTokens || 0
buckets[hour].outputTokens += usage.outputTokens || 0
}
const code = log.statusCode
if (code && (code >= 400 || code < 0)) buckets[hour].errors++
if (log.latencyMs) {
buckets[hour].latencySum += log.latencyMs
buckets[hour].latencyCount++
}
}
const entries = Object.entries(buckets)
trendPoints.value = entries.map(([time, b]) => ({
time,
requests: Math.floor(Math.random() * 1000),
tokens: Math.floor(Math.random() * 50000),
errors: Math.floor(Math.random() * 10),
requests: b.requests,
tokens: b.inputTokens + b.outputTokens,
errors: b.errors,
latency: b.latencyCount > 0 ? Math.round(b.latencySum / b.latencyCount) : 0,
}))
const successRate = 99.5 + Math.random() * 0.5
const peak = Math.max(...trendPoints.value.map(p => p.requests))
const slowRequests = Math.floor(Math.random() * 100)
const totalInput = entries.reduce((s, [, b]) => s + b.inputTokens, 0)
const totalOutput = entries.reduce((s, [, b]) => s + b.outputTokens, 0)
const totalErrors = entries.reduce((s, [, b]) => s + b.errors, 0)
trendSummary.value = [
{ label: '成功率', value: `${successRate.toFixed(2)}%`, tone: 'success' },
{ label: '峰值', value: formatNumber(peak), tone: 'info' },
{ label: '慢请求', value: String(slowRequests), tone: slowRequests > 50 ? 'warning' : 'info' },
{ label: '输入', value: totalInput > 0 ? formatTokens(totalInput) : '—', tone: 'info' as const },
{ label: '输出', value: totalOutput > 0 ? formatTokens(totalOutput) : '—', tone: 'success' as const },
{ label: '缓存', value: formatTokens(summary?.cachedTokens ?? 0), tone: (summary?.cachedTokens ?? 0) > 0 ? 'success' as const : 'normal' as const },
]
}
@@ -307,6 +389,9 @@ export function useDashboard(): {
accountUsage,
serviceStatuses,
eventLogs,
poolSummary,
capacityInfo,
rotationStrategy,
refresh: loadDashboardData,
}
}
+31
View File
@@ -21,6 +21,7 @@ export interface TrendPoint {
requests: number
tokens: number
errors: number
latency: number
}
export interface TrendSummaryItem {
@@ -59,3 +60,33 @@ export interface EventLogItem {
latency: string
tone: SemanticTone
}
export interface AccountPoolSummary {
total: number
active: number
expired: number
quotaExhausted: number
refreshing: number
disabled: number
banned: number
}
export interface AccountCapacityInfo {
maxConcurrentPerAccount: number
totalSlots: number
usedSlots: number
availableSlots: number
}
export interface AccountStatusRow {
label: string
description: string
value: string
tone: SemanticTone
icon: Component
}
export interface ScheduleStat {
label: string
value: string
}
+77 -105
View File
@@ -19,26 +19,21 @@ const loading = ref(true)
const saving = ref(false)
const form = ref<Settings>({
logging: {
enabled: true,
level: 'info',
},
quota: {
warningThresholds: {
requestsRemaining: 1000,
tokensRemaining: 100000,
},
},
defaultModel: '',
refreshEnabled: true,
maxConcurrentPerAccount: 3,
requestIntervalMs: 0,
rotationStrategy: 'least_used',
quotaWarningThresholds: { primary: [], secondary: [] },
quotaSkipExhausted: false,
logsEnabled: true,
logsCapacity: 5000,
})
const requestsThreshold = ref('1000')
const tokensThreshold = ref('100000')
const logLevelOptions = [
{ label: 'Debug', value: 'debug' },
{ label: 'Info', value: 'info' },
{ label: 'Warning', value: 'warning' },
{ label: 'Error', value: 'error' },
const rotationOptions = [
{ label: '最少使用优先', value: 'least_used' },
{ label: '轮询', value: 'round_robin' },
{ label: '随机', value: 'random' },
]
async function loadSettings() {
@@ -46,8 +41,6 @@ async function loadSettings() {
loading.value = true
const data = await getSettings()
form.value = data
requestsThreshold.value = String(data.quota.warningThresholds.requestsRemaining)
tokensThreshold.value = String(data.quota.warningThresholds.tokensRemaining)
} catch (error: any) {
toast.error(error.message || '加载失败')
} finally {
@@ -58,12 +51,18 @@ async function loadSettings() {
async function handleSave() {
try {
saving.value = true
// 转换字符串为数字
form.value.quota.warningThresholds.requestsRemaining = parseInt(requestsThreshold.value) || 0
form.value.quota.warningThresholds.tokensRemaining = parseInt(tokensThreshold.value) || 0
await updateSettings(form.value)
const patch: Partial<Settings> = {
defaultModel: form.value.defaultModel,
refreshEnabled: form.value.refreshEnabled,
maxConcurrentPerAccount: form.value.maxConcurrentPerAccount,
requestIntervalMs: form.value.requestIntervalMs,
rotationStrategy: form.value.rotationStrategy,
quotaWarningThresholds: form.value.quotaWarningThresholds,
quotaSkipExhausted: form.value.quotaSkipExhausted,
logsEnabled: form.value.logsEnabled,
logsCapacity: form.value.logsCapacity,
}
await updateSettings(patch)
toast.success('设置已保存')
} catch (error: any) {
toast.error(error.message || '保存失败')
@@ -95,114 +94,87 @@ onMounted(() => {
<BaseSpinner v-if="loading" class="mt-20" />
<div v-else class="mt-6 grid gap-6 max-w-4xl">
<!-- 日志配置 -->
<!-- 模型配置 -->
<BaseCard>
<div class="flex items-center justify-between mb-5">
<div>
<h2 class="m-0 text-[20px] font-bold text-(--cp-text-primary)">
日志配置
</h2>
<p class="mt-1 m-0 text-[13px] text-(--cp-text-secondary)">
控制系统日志记录行为
</p>
</div>
<h2 class="m-0 text-[20px] font-bold text-(--cp-text-primary) mb-5">模型配置</h2>
<div>
<label class="block text-[14px] font-medium text-(--cp-text-primary) mb-2">默认模型</label>
<BaseInput v-model="form.defaultModel" class="w-64" />
</div>
</BaseCard>
<!-- 调度配置 -->
<BaseCard>
<h2 class="m-0 text-[20px] font-bold text-(--cp-text-primary) mb-5">调度配置</h2>
<div class="grid gap-5">
<div>
<label class="block text-[14px] font-medium text-(--cp-text-primary) mb-2">单账号最大并发</label>
<BaseInput v-model.number="form.maxConcurrentPerAccount" type="number" class="w-48" />
</div>
<div>
<label class="block text-[14px] font-medium text-(--cp-text-primary) mb-2">请求间隔 (ms)</label>
<BaseInput v-model.number="form.requestIntervalMs" type="number" class="w-48" />
</div>
<div>
<label class="block text-[14px] font-medium text-(--cp-text-primary) mb-2">轮换策略</label>
<BaseSelect v-model="form.rotationStrategy" :options="rotationOptions" class="w-48" />
</div>
<div class="flex items-center justify-between">
<div>
<label class="block text-[14px] font-medium text-(--cp-text-primary) mb-1">
启用日志记录
</label>
<p class="m-0 text-[13px] text-(--cp-text-secondary)">
记录系统运行事件和错误
</p>
<label class="block text-[14px] font-medium text-(--cp-text-primary) mb-1">跳过配额耗尽账号</label>
<p class="m-0 text-[13px] text-(--cp-text-secondary)">调度时自动跳过已触发配额的账号</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
v-model="form.logging.enabled"
type="checkbox"
class="sr-only peer"
>
<input v-model="form.quotaSkipExhausted" type="checkbox" class="sr-only peer">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600" />
</label>
</div>
<div>
<label class="block text-[14px] font-medium text-(--cp-text-primary) mb-2">
日志级别
</label>
<BaseSelect
v-model="form.logging.level"
:options="logLevelOptions"
class="w-48"
/>
</div>
</div>
</BaseCard>
<!-- 配额设置 -->
<!-- 日志配置 -->
<BaseCard>
<div class="flex items-center justify-between mb-5">
<h2 class="m-0 text-[20px] font-bold text-(--cp-text-primary) mb-5">日志配置</h2>
<div class="grid gap-5">
<div class="flex items-center justify-between">
<div>
<label class="block text-[14px] font-medium text-(--cp-text-primary) mb-1">启用日志记录</label>
<p class="m-0 text-[13px] text-(--cp-text-secondary)">记录系统运行事件和错误</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input v-model="form.logsEnabled" type="checkbox" class="sr-only peer">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600" />
</label>
</div>
<div>
<h2 class="m-0 text-[20px] font-bold text-(--cp-text-primary)">
配额警告阈值
</h2>
<p class="mt-1 m-0 text-[13px] text-(--cp-text-secondary)">
配置账号配额预警参数
</p>
<label class="block text-[14px] font-medium text-(--cp-text-primary) mb-2">日志容量上限</label>
<BaseInput v-model.number="form.logsCapacity" type="number" class="w-48" />
</div>
</div>
</BaseCard>
<div class="grid gap-5">
<!-- 刷新配置 -->
<BaseCard>
<h2 class="m-0 text-[20px] font-bold text-(--cp-text-primary) mb-5">自动刷新</h2>
<div class="flex items-center justify-between">
<div>
<label class="block text-[14px] font-medium text-(--cp-text-primary) mb-2">
剩余请求数阈值
</label>
<BaseInput
v-model="requestsThreshold"
type="number"
class="w-64"
/>
<p class="mt-1.5 m-0 text-[12px] text-(--cp-text-tertiary)">
当账号剩余请求数低于此值时发出警告
</p>
</div>
<div>
<label class="block text-[14px] font-medium text-(--cp-text-primary) mb-2">
剩余 Tokens 阈值
</label>
<BaseInput
v-model="tokensThreshold"
type="number"
class="w-64"
/>
<p class="mt-1.5 m-0 text-[12px] text-(--cp-text-tertiary)">
当账号剩余 Tokens 低于此值时发出警告
</p>
<label class="block text-[14px] font-medium text-(--cp-text-primary) mb-1">启用自动刷新</label>
<p class="m-0 text-[13px] text-(--cp-text-secondary)">定时刷新账号令牌和配额信息</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input v-model="form.refreshEnabled" type="checkbox" class="sr-only peer">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600" />
</label>
</div>
</BaseCard>
<!-- 保存按钮 -->
<div class="flex items-center gap-3">
<BaseButton
variant="primary"
size="md"
:disabled="saving"
@click="handleSave"
>
<BaseButton variant="primary" size="md" :disabled="saving" @click="handleSave">
<Save class="size-4" />
{{ saving ? '保存中...' : '保存设置' }}
</BaseButton>
<BaseButton
variant="ghost"
size="md"
:disabled="loading"
@click="loadSettings"
>
<BaseButton variant="ghost" size="md" :disabled="loading" @click="loadSettings">
<RefreshCw class="size-4" />
重置
</BaseButton>