mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
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:
@@ -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"),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user