feat: OAuth 账户管理、维护调度、端点健康检查增强及前端优化

- 新增 OAuth 账户管理对话框和提供商详情抽屉中的 OAuth 信息展示
- 新增维护调度器(maintenance_scheduler)支持定时清理和健康检查
- 增强端点健康检查器,支持更多检测策略
- 重构 codex 服务为 metadata_collectors 模块
- 优化 OpenAI CLI normalizer 代码结构
- 前端: 改进使用量表格、统计图表、指南页面和异步任务管理
- 扩展多个数据库字符串列为 TEXT 类型
- 新增倒计时 composable 和 provider OAuth API 端点
This commit is contained in:
fawney19
2026-02-04 23:59:45 +08:00
parent 24c9105628
commit 4d6e7c094f
64 changed files with 3885 additions and 930 deletions

View File

@@ -25,6 +25,7 @@ export interface ProviderOAuthCompleteResponse {
provider_type: string
expires_at?: number | null
has_refresh_token: boolean
email?: string | null
}
export async function getProviderOAuthSupportedTypes(): Promise<ProviderOAuthSupportedType[]> {
@@ -49,3 +50,31 @@ export async function refreshProviderOAuth(keyId: string): Promise<ProviderOAuth
const resp = await client.post(`/api/admin/provider-oauth/keys/${keyId}/refresh`)
return resp.data
}
// Provider-level OAuth (不需要预先创建 key)
export interface ProviderOAuthCompleteRequest {
callback_url: string
name?: string
}
export interface ProviderOAuthCompleteResponseWithKey {
key_id: string
provider_type: string
expires_at?: number | null
has_refresh_token: boolean
email?: string | null
}
export async function startProviderLevelOAuth(providerId: string): Promise<ProviderOAuthStartResponse> {
const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/start`)
return resp.data
}
export async function completeProviderLevelOAuth(
providerId: string,
data: ProviderOAuthCompleteRequest
): Promise<ProviderOAuthCompleteResponseWithKey> {
const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/complete`, data)
return resp.data
}

View File

@@ -249,6 +249,28 @@ export interface EndpointAPIKey {
model_exclude_patterns?: string[] // 模型排除规则(支持 * 和 ? 通配符)
// OAuth 相关
oauth_expires_at?: number | null // OAuth Token 过期时间Unix 时间戳)
oauth_email?: string | null // OAuth 授权的邮箱
oauth_plan_type?: string | null // Codex 订阅类型: plus/free/team/enterprise
oauth_account_id?: string | null // Codex ChatGPT 账号 ID
oauth_invalid_at?: number | null // OAuth Token 失效时间Unix 时间戳)
oauth_invalid_reason?: string | null // OAuth Token 失效原因
// 上游元数据(由响应头采集,如 Codex 额度信息)
upstream_metadata?: CodexUpstreamMetadata | null
}
// Codex 上游元数据类型
export interface CodexUpstreamMetadata {
plan_type?: string // 套餐类型
primary_used_percent?: number // 主限额窗口使用百分比
primary_reset_seconds?: number // 主限额重置剩余秒数
primary_reset_at?: number // 主限额重置时间Unix 时间戳)
primary_window_minutes?: number // 主限额窗口大小(分钟)
secondary_used_percent?: number // 次级限额窗口使用百分比
secondary_reset_seconds?: number // 次级限额重置剩余秒数
secondary_reset_at?: number // 次级限额重置时间Unix 时间戳)
secondary_window_minutes?: number // 次级限额窗口大小(分钟)
has_credits?: boolean // 是否有积分
credits_balance?: number // 积分余额
}
// 按格式的健康度数据

View File

@@ -1,21 +1,46 @@
<template>
<div class="flex flex-wrap items-center gap-2">
<Select v-model:open="presetSelectOpen" v-model="selectedPreset">
<Select
v-model:open="presetSelectOpen"
v-model="selectedPreset"
>
<SelectTrigger class="h-8 w-32 text-xs border-border/60">
<SelectValue placeholder="选择时间段" />
</SelectTrigger>
<SelectContent>
<SelectItem value="today">今天</SelectItem>
<SelectItem value="yesterday">昨天</SelectItem>
<SelectItem value="last7days">最近7天</SelectItem>
<SelectItem value="last30days">最近30天</SelectItem>
<SelectItem value="last90days">最近90天</SelectItem>
<SelectItem value="this_week">本周</SelectItem>
<SelectItem value="last_week">上周</SelectItem>
<SelectItem value="this_month">本月</SelectItem>
<SelectItem value="last_month">上月</SelectItem>
<SelectItem value="this_year">今年</SelectItem>
<SelectItem value="custom">自定义</SelectItem>
<SelectItem value="today">
今天
</SelectItem>
<SelectItem value="yesterday">
昨天
</SelectItem>
<SelectItem value="last7days">
最近7天
</SelectItem>
<SelectItem value="last30days">
最近30天
</SelectItem>
<SelectItem value="last90days">
最近90天
</SelectItem>
<SelectItem value="this_week">
本周
</SelectItem>
<SelectItem value="last_week">
上周
</SelectItem>
<SelectItem value="this_month">
本月
</SelectItem>
<SelectItem value="last_month">
上月
</SelectItem>
<SelectItem value="this_year">
今年
</SelectItem>
<SelectItem value="custom">
自定义
</SelectItem>
</SelectContent>
</Select>
@@ -36,15 +61,30 @@
/>
</div>
<Select v-if="showGranularity" v-model:open="granularitySelectOpen" v-model="selectedGranularity">
<Select
v-if="showGranularity"
v-model:open="granularitySelectOpen"
v-model="selectedGranularity"
>
<SelectTrigger class="h-8 w-24 text-xs border-border/60">
<SelectValue placeholder="粒度" />
</SelectTrigger>
<SelectContent>
<SelectItem v-if="allowHourly && canUseHourly" value="hour">小时</SelectItem>
<SelectItem value="day"></SelectItem>
<SelectItem value="week"></SelectItem>
<SelectItem value="month"></SelectItem>
<SelectItem
v-if="allowHourly && canUseHourly"
value="hour"
>
小时
</SelectItem>
<SelectItem value="day">
</SelectItem>
<SelectItem value="week">
</SelectItem>
<SelectItem value="month">
</SelectItem>
</SelectContent>
</Select>
</div>

View File

@@ -1,8 +1,15 @@
<template>
<Card class="p-4 space-y-2">
<div class="text-xs text-muted-foreground">{{ label }}</div>
<div class="text-lg font-semibold">{{ value }}</div>
<div class="text-xs" :class="changeClass">
<div class="text-xs text-muted-foreground">
{{ label }}
</div>
<div class="text-lg font-semibold">
{{ value }}
</div>
<div
class="text-xs"
:class="changeClass"
>
<span v-if="changePercent !== null">{{ changePercent }}%</span>
<span v-else>--</span>
<span class="ml-1 text-muted-foreground">vs 对比期</span>

View File

@@ -1,14 +1,28 @@
<template>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">{{ title }}</h3>
<span class="text-xs text-muted-foreground" v-if="subtitle">{{ subtitle }}</span>
<h3 class="text-sm font-semibold">
{{ title }}
</h3>
<span
v-if="subtitle"
class="text-xs text-muted-foreground"
>{{ subtitle }}</span>
</div>
<div v-if="loading" class="p-6">
<div
v-if="loading"
class="p-6"
>
<LoadingState />
</div>
<div v-else class="h-[280px]">
<LineChart :data="chartData" :options="chartOptions" />
<div
v-else
class="h-[280px]"
>
<LineChart
:data="chartData"
:options="chartOptions"
/>
</div>
</div>
</template>

View File

@@ -1,14 +1,28 @@
<template>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">{{ title }}</h3>
<span class="text-xs text-muted-foreground" v-if="subtitle">{{ subtitle }}</span>
<h3 class="text-sm font-semibold">
{{ title }}
</h3>
<span
v-if="subtitle"
class="text-xs text-muted-foreground"
>{{ subtitle }}</span>
</div>
<div v-if="loading" class="p-6">
<div
v-if="loading"
class="p-6"
>
<LoadingState />
</div>
<div v-else class="h-[260px]">
<DoughnutChart :data="chartData" :options="chartOptions" />
<div
v-else
class="h-[260px]"
>
<DoughnutChart
:data="chartData"
:options="chartOptions"
/>
</div>
</div>
</template>

View File

@@ -11,36 +11,70 @@
<SelectValue placeholder="指标" />
</SelectTrigger>
<SelectContent>
<SelectItem value="requests">请求数</SelectItem>
<SelectItem value="tokens">Tokens</SelectItem>
<SelectItem value="cost">成本</SelectItem>
<SelectItem value="requests">
请求数
</SelectItem>
<SelectItem value="tokens">
Tokens
</SelectItem>
<SelectItem value="cost">
成本
</SelectItem>
</SelectContent>
</Select>
</template>
<div v-if="loading" class="p-6">
<div
v-if="loading"
class="p-6"
>
<LoadingState />
</div>
<div v-else-if="items.length === 0" class="p-6">
<EmptyState title="暂无数据" description="当前时间范围内没有统计结果" />
<div
v-else-if="items.length === 0"
class="p-6"
>
<EmptyState
title="暂无数据"
description="当前时间范围内没有统计结果"
/>
</div>
<Table v-else>
<TableHeader>
<TableRow>
<TableHead class="w-16">排名</TableHead>
<TableHead class="w-16">
排名
</TableHead>
<TableHead>名称</TableHead>
<TableHead class="text-right">请求数</TableHead>
<TableHead class="text-right">Tokens</TableHead>
<TableHead class="text-right">成本</TableHead>
<TableHead class="text-right">
请求数
</TableHead>
<TableHead class="text-right">
Tokens
</TableHead>
<TableHead class="text-right">
成本
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="item in items" :key="item.id">
<TableCell class="font-medium">{{ item.rank }}</TableCell>
<TableRow
v-for="item in items"
:key="item.id"
>
<TableCell class="font-medium">
{{ item.rank }}
</TableCell>
<TableCell>{{ item.name }}</TableCell>
<TableCell class="text-right">{{ item.requests }}</TableCell>
<TableCell class="text-right">{{ formatTokens(item.tokens) }}</TableCell>
<TableCell class="text-right">{{ formatCurrency(item.cost) }}</TableCell>
<TableCell class="text-right">
{{ item.requests }}
</TableCell>
<TableCell class="text-right">
{{ formatTokens(item.tokens) }}
</TableCell>
<TableCell class="text-right">
{{ formatCurrency(item.cost) }}
</TableCell>
</TableRow>
</TableBody>
</Table>

View File

@@ -1,14 +1,28 @@
<template>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">{{ title }}</h3>
<span class="text-xs text-muted-foreground" v-if="subtitle">{{ subtitle }}</span>
<h3 class="text-sm font-semibold">
{{ title }}
</h3>
<span
v-if="subtitle"
class="text-xs text-muted-foreground"
>{{ subtitle }}</span>
</div>
<div v-if="loading" class="p-6">
<div
v-if="loading"
class="p-6"
>
<LoadingState />
</div>
<div v-else class="h-[260px]">
<LineChart :data="chartData" :options="chartOptions" />
<div
v-else
class="h-[260px]"
>
<LineChart
:data="chartData"
:options="chartOptions"
/>
</div>
</div>
</template>

View File

@@ -1,18 +1,39 @@
<template>
<Card class="p-4 space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">{{ title }}</h3>
<span class="text-xs text-muted-foreground" v-if="subtitle">{{ subtitle }}</span>
<h3 class="text-sm font-semibold">
{{ title }}
</h3>
<span
v-if="subtitle"
class="text-xs text-muted-foreground"
>{{ subtitle }}</span>
</div>
<div v-if="loading" class="p-4">
<div
v-if="loading"
class="p-4"
>
<LoadingState />
</div>
<div v-else-if="providers.length === 0" class="p-4">
<EmptyState title="暂无数据" description="暂无月卡配额数据" />
<div
v-else-if="providers.length === 0"
class="p-4"
>
<EmptyState
title="暂无数据"
description="暂无月卡配额数据"
/>
</div>
<div v-else class="space-y-4">
<div v-for="provider in providers" :key="provider.id" class="space-y-2">
<div
v-else
class="space-y-4"
>
<div
v-for="provider in providers"
:key="provider.id"
class="space-y-2"
>
<div class="flex items-center justify-between text-xs">
<span class="font-medium">{{ provider.name }}</span>
<span class="text-muted-foreground">

View File

@@ -67,3 +67,70 @@ export function getProbeCountdown(nextProbeAt: string | null | undefined, _tick:
}
return '探测中'
}
/**
* OAuth Token 状态信息
*/
export interface OAuthStatusInfo {
text: string
isExpired: boolean
isExpiringSoon: boolean
isInvalid: boolean // Token 已失效(账号被封、授权撤销等)
invalidReason?: string // 失效原因
}
/**
* 格式化 OAuth Token 过期倒计时
* @param expiresAt Unix 时间戳(秒)
* @param _tick 响应式触发器(传入 tick.value 以触发响应式更新)
* @param invalidAt 失效时间戳(秒),可选
* @param invalidReason 失效原因,可选
* @returns 状态信息对象
*/
export function getOAuthExpiresCountdown(
expiresAt: number | null | undefined,
_tick: number,
invalidAt?: number | null,
invalidReason?: string | null
): OAuthStatusInfo | null {
void _tick
// 优先检查失效状态(失效比过期更严重)
if (invalidAt != null) {
return {
text: '已失效',
isExpired: false,
isExpiringSoon: false,
isInvalid: true,
invalidReason: invalidReason || undefined
}
}
if (expiresAt == null) return null
const now = Math.floor(Date.now() / 1000)
const diffSeconds = expiresAt - now
if (diffSeconds <= 0) {
return { text: '已过期', isExpired: true, isExpiringSoon: false, isInvalid: false }
}
// 24 小时内过期视为即将过期
const isExpiringSoon = diffSeconds < 24 * 3600
// 格式化时间
const days = Math.floor(diffSeconds / 86400)
const hours = Math.floor((diffSeconds % 86400) / 3600)
const minutes = Math.floor((diffSeconds % 3600) / 60)
let text: string
if (days > 0) {
text = `${days}${hours}`
} else if (hours > 0) {
text = `${hours}${minutes}`
} else {
text = `${minutes}分钟`
}
return { text, isExpired: false, isExpiringSoon, isInvalid: false }
}

View File

@@ -200,7 +200,9 @@
<!-- 视频计费(分辨率 × 时长) -->
<div class="pt-3 border-t space-y-2">
<div class="text-sm font-medium">视频计费(分辨率 × 时长)</div>
<div class="text-sm font-medium">
视频计费(分辨率 × 时长)
</div>
<div class="flex items-center gap-1.5 flex-wrap">
<Button
@@ -249,7 +251,7 @@
<div class="grid grid-cols-[1fr_1fr_32px] gap-0 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 border-b border-border">
<span>分辨率</span>
<span>单价($/秒)</span>
<span></span>
<span />
</div>
<div class="divide-y divide-border">
<div

View File

@@ -533,6 +533,12 @@ const props = defineProps<{
providerFormatConversionEnabled?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'endpointCreated': []
'endpointUpdated': []
}>()
// 计算端点级格式转换是否应该被禁用
const isEndpointFormatConversionDisabled = computed(() => {
return props.systemFormatConversionEnabled || props.providerFormatConversionEnabled
@@ -549,12 +555,6 @@ const formatConversionDisabledTooltip = computed(() => {
return ''
})
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'endpointCreated': []
'endpointUpdated': []
}>()
const { success, error: showError } = useToast()
// 规则 Select 的展开状态(与 Collapsible 分开管理)

View File

@@ -0,0 +1,232 @@
<template>
<Dialog
:model-value="isOpen"
title="添加账号"
:icon="UserPlus"
size="md"
@update:model-value="handleDialogUpdate"
>
<div class="space-y-6">
<!-- 加载中 -->
<div
v-if="oauth.starting && !oauth.authorization_url"
class="py-12 text-center"
>
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4" />
<p class="text-sm text-muted-foreground">
正在准备授权...
</p>
</div>
<!-- 授权流程 -->
<template v-else-if="oauth.authorization_url">
<!-- 步骤 1: 打开授权链接 -->
<div class="space-y-3">
<div class="flex items-center gap-2">
<div class="w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium shrink-0">
1
</div>
<span class="text-sm font-medium">打开授权链接</span>
</div>
<p class="text-xs text-muted-foreground pl-7">
点击下方按钮在浏览器中完成登录授权
</p>
<div class="ml-7 p-2.5 rounded-md bg-muted/50 border border-border/50">
<p class="text-xs font-mono text-muted-foreground break-all line-clamp-3 leading-relaxed">
{{ oauth.authorization_url }}
</p>
</div>
<div class="flex gap-2 pl-7">
<Button
size="sm"
:disabled="oauthBusy"
@click="openAuthorizationUrl"
>
<ExternalLink class="w-3.5 h-3.5 mr-1.5" />
前往授权
</Button>
<Button
size="sm"
variant="outline"
:disabled="oauthBusy"
@click="copyToClipboard(oauth.authorization_url)"
>
<Copy class="w-3.5 h-3.5 mr-1.5" />
复制链接
</Button>
</div>
</div>
<!-- 步骤 2: 粘贴回调地址 -->
<div class="space-y-3">
<div class="flex items-center gap-2">
<div class="w-5 h-5 rounded-full bg-muted text-muted-foreground flex items-center justify-center text-xs font-medium shrink-0">
2
</div>
<span class="text-sm font-medium">粘贴回调地址</span>
</div>
<p class="text-xs text-muted-foreground pl-7">
授权完成后复制浏览器地址栏的完整 URL 并粘贴到下方
</p>
<div class="pl-7">
<Textarea
v-model="oauth.callback_url"
:disabled="oauthBusy"
placeholder="http://localhost:xxx/callback?code=..."
class="min-h-[80px] text-xs font-mono resize-none"
spellcheck="false"
/>
</div>
</div>
</template>
</div>
<template #footer>
<Button
variant="outline"
@click="handleClose"
>
取消
</Button>
<Button
:disabled="!canCompleteOAuth"
@click="handleCompleteOAuth"
>
{{ oauth.completing ? '验证中...' : '完成授权' }}
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Dialog, Button, Textarea } from '@/components/ui'
import { UserPlus, Copy, ExternalLink } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard'
import { parseApiError } from '@/utils/errorParser'
import {
startProviderLevelOAuth,
completeProviderLevelOAuth,
} from '@/api/endpoints'
const props = defineProps<{
open: boolean
providerId: string | null
}>()
const emit = defineEmits<{
close: []
saved: []
}>()
const { success, error: showError } = useToast()
const { copyToClipboard } = useClipboard()
// OAuth 状态
interface OAuthState {
authorization_url: string
redirect_uri: string
instructions: string
provider_type: string
callback_url: string
starting: boolean
completing: boolean
}
function createInitialOAuthState(): OAuthState {
return {
authorization_url: '',
redirect_uri: '',
instructions: '',
provider_type: '',
callback_url: '',
starting: false,
completing: false,
}
}
const oauth = ref<OAuthState>(createInitialOAuthState())
const isOpen = computed(() => props.open)
const oauthBusy = computed(() =>
oauth.value.starting || oauth.value.completing
)
const canCompleteOAuth = computed(() => {
if (!oauth.value.authorization_url) return false
if (!oauth.value.callback_url.trim()) return false
return !oauthBusy.value
})
function resetForm() {
oauth.value = createInitialOAuthState()
}
function handleDialogUpdate(value: boolean) {
if (!value) {
handleClose()
}
}
function handleClose() {
resetForm()
emit('close')
}
function openAuthorizationUrl() {
const url = oauth.value.authorization_url
if (!url) return
window.open(url, '_blank', 'noopener,noreferrer')
}
// 对话框打开时获取授权 URL不创建 key
async function initOAuth() {
if (!props.providerId) return
oauth.value.starting = true
try {
const resp = await startProviderLevelOAuth(props.providerId)
oauth.value.authorization_url = resp.authorization_url
oauth.value.redirect_uri = resp.redirect_uri
oauth.value.instructions = resp.instructions
oauth.value.provider_type = resp.provider_type
} catch (err: any) {
const errorMessage = parseApiError(err, '初始化授权失败')
showError(errorMessage, '错误')
handleClose()
} finally {
oauth.value.starting = false
}
}
// 完成授权(此时才创建 key
async function handleCompleteOAuth() {
if (!canCompleteOAuth.value || !props.providerId) return
oauth.value.completing = true
try {
await completeProviderLevelOAuth(props.providerId, {
callback_url: oauth.value.callback_url.trim(),
})
success('授权成功,账号已添加')
emit('saved')
handleClose()
} catch (err: any) {
const errorMessage = parseApiError(err, '完成授权失败')
showError(errorMessage, '错误')
} finally {
oauth.value.completing = false
}
}
// 监听对话框打开
watch(() => props.open, (newOpen) => {
if (newOpen) {
initOAuth()
} else {
resetForm()
}
})
</script>

View File

@@ -142,7 +142,7 @@
{{ ((provider.monthly_used_usd || 0) / provider.monthly_quota_usd * 100).toFixed(1) }}%
</Badge>
</div>
<div class="relative w-full h-2 bg-muted rounded-full overflow-hidden">
<div class="relative w-full h-2 bg-border rounded-full overflow-hidden">
<div
class="absolute left-0 top-0 h-full transition-all duration-300"
:class="{
@@ -172,7 +172,7 @@
<div class="p-4 border-b border-border/60">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">
密钥管理
{{ provider.provider_type === 'custom' ? '密钥管理' : '账号管理' }}
</h3>
<Button
v-if="endpoints.length > 0"
@@ -182,7 +182,7 @@
@click="handleAddKeyToFirstEndpoint"
>
<Plus class="w-3.5 h-3.5 mr-1.5" />
添加密钥
{{ provider.provider_type === 'custom' ? '添加密钥' : '添加账号' }}
</Button>
</div>
</div>
@@ -216,20 +216,58 @@
<GripVertical class="w-4 h-4" />
</div>
<div class="flex flex-col min-w-0">
<span class="text-sm font-medium truncate">{{ key.name || '未命名密钥' }}</span>
<div class="flex items-center gap-1.5">
<span class="text-sm font-medium truncate">{{ key.name || '未命名密钥' }}</span>
<!-- OAuth 订阅类型标签 -->
<Badge
v-if="key.oauth_plan_type"
variant="outline"
class="text-[10px] px-1.5 py-0 shrink-0"
:class="getOAuthPlanTypeClass(key.oauth_plan_type)"
>
{{ formatOAuthPlanType(key.oauth_plan_type) }}
</Badge>
</div>
<div class="flex items-center gap-1">
<span class="text-[11px] font-mono text-muted-foreground">
{{ key.auth_type === 'vertex_ai' ? 'Vertex AI' : key.api_key_masked }}
{{ key.auth_type === 'oauth' ? '[Refresh Token]' : (key.auth_type === 'vertex_ai' ? 'Vertex AI' : key.api_key_masked) }}
</span>
<Button
variant="ghost"
size="icon"
class="h-4 w-4 shrink-0"
title="复制密钥"
:title="key.auth_type === 'oauth' ? '复制 Refresh Token' : '复制密钥'"
@click.stop="copyFullKey(key)"
>
<Copy class="w-2.5 h-2.5" />
</Button>
<!-- OAuth 状态失效/过期/倒计时和刷新按钮 -->
<template v-if="getKeyOAuthExpires(key)">
<span
class="text-[10px]"
:class="{
'text-destructive': getKeyOAuthExpires(key)?.isInvalid || getKeyOAuthExpires(key)?.isExpired,
'text-warning': getKeyOAuthExpires(key)?.isExpiringSoon && !getKeyOAuthExpires(key)?.isExpired && !getKeyOAuthExpires(key)?.isInvalid,
'text-muted-foreground': !getKeyOAuthExpires(key)?.isExpired && !getKeyOAuthExpires(key)?.isExpiringSoon && !getKeyOAuthExpires(key)?.isInvalid
}"
:title="getOAuthStatusTitle(key)"
>
{{ getKeyOAuthExpires(key)?.text }}
</span>
<Button
variant="ghost"
size="icon"
class="h-4 w-4 shrink-0"
:disabled="refreshingOAuthKeyId === key.id"
:title="getKeyOAuthExpires(key)?.isInvalid ? '重新授权' : '刷新 Token'"
@click.stop="handleRefreshOAuth(key)"
>
<RefreshCw
class="w-2.5 h-2.5"
:class="{ 'animate-spin': refreshingOAuthKeyId === key.id }"
/>
</Button>
</template>
</div>
</div>
</div>
@@ -248,7 +286,7 @@
v-if="key.health_score !== undefined"
class="flex items-center gap-1 mr-1"
>
<div class="w-10 h-1.5 bg-muted/80 rounded-full overflow-hidden">
<div class="w-10 h-1.5 bg-border rounded-full overflow-hidden">
<div
class="h-full transition-all duration-300"
:class="getHealthScoreBarColor(key.health_score || 0)"
@@ -273,6 +311,7 @@
<RefreshCw class="w-3.5 h-3.5" />
</Button>
<Button
v-if="key.auth_type !== 'oauth'"
variant="ghost"
size="icon"
class="h-7 w-7"
@@ -282,6 +321,7 @@
<Shield class="w-3.5 h-3.5" />
</Button>
<Button
v-if="key.auth_type !== 'oauth'"
variant="ghost"
size="icon"
class="h-7 w-7"
@@ -311,6 +351,61 @@
</Button>
</div>
</div>
<!-- Codex 上游额度信息仅当有元数据时显示 -->
<div
v-if="key.upstream_metadata && hasCodexQuotaData(key.upstream_metadata)"
class="mt-2 p-2 bg-muted/30 rounded-md"
>
<!-- 限额并排显示 -->
<div class="grid grid-cols-2 gap-3">
<!-- 周限额7天窗口 -->
<div v-if="key.upstream_metadata.primary_used_percent !== undefined">
<div class="flex items-center justify-between text-[10px] mb-0.5">
<span class="text-muted-foreground">周限额</span>
<span :class="getQuotaRemainingClass(key.upstream_metadata.primary_used_percent)">
{{ (100 - key.upstream_metadata.primary_used_percent).toFixed(1) }}%
</span>
</div>
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
<div
class="absolute left-0 top-0 h-full transition-all duration-300"
:class="getQuotaRemainingBarColor(key.upstream_metadata.primary_used_percent)"
:style="{ width: `${Math.max(100 - key.upstream_metadata.primary_used_percent, 0)}%` }"
/>
</div>
<div
v-if="key.upstream_metadata.primary_reset_seconds"
class="text-[9px] text-muted-foreground/70 mt-0.5"
>
{{ formatResetTime(key.upstream_metadata.primary_reset_seconds) }}后重置
</div>
</div>
<!-- 5小时限额 -->
<div v-if="key.upstream_metadata.secondary_used_percent !== undefined">
<div class="flex items-center justify-between text-[10px] mb-0.5">
<span class="text-muted-foreground">5H限额</span>
<span :class="getQuotaRemainingClass(key.upstream_metadata.secondary_used_percent)">
{{ (100 - key.upstream_metadata.secondary_used_percent).toFixed(1) }}%
</span>
</div>
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
<div
class="absolute left-0 top-0 h-full transition-all duration-300"
:class="getQuotaRemainingBarColor(key.upstream_metadata.secondary_used_percent)"
:style="{ width: `${Math.max(100 - key.upstream_metadata.secondary_used_percent, 0)}%` }"
/>
</div>
<div class="text-[9px] text-muted-foreground/70 mt-0.5">
<template v-if="key.upstream_metadata.secondary_reset_seconds">
{{ formatResetTime(key.upstream_metadata.secondary_reset_seconds) }}后重置
</template>
<template v-else>
已重置
</template>
</div>
</div>
</div>
</div>
<!-- 第二行优先级 + API 格式展开显示 + 统计信息 -->
<div class="flex items-center gap-1.5 mt-1 text-[11px] text-muted-foreground">
<!-- 优先级放最前面支持点击编辑 -->
@@ -459,6 +554,15 @@
@edit-created-key="handleEditCreatedKey"
/>
<!-- OAuth 账号对话框 -->
<OAuthAccountDialog
v-if="open && provider"
:open="oauthAccountDialogOpen"
:provider-id="provider.id"
@close="oauthAccountDialogOpen = false"
@saved="handleKeyChanged"
/>
<!-- 模型权限对话框 -->
<KeyAllowedModelsEditDialog
v-if="open"
@@ -526,14 +630,15 @@ import Badge from '@/components/ui/badge.vue'
import Card from '@/components/ui/card.vue'
import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard'
import { useCountdownTimer, formatCountdown } from '@/composables/useCountdownTimer'
import { useCountdownTimer, formatCountdown, getOAuthExpiresCountdown } from '@/composables/useCountdownTimer'
import { getProvider, getProviderEndpoints, updateProvider } from '@/api/endpoints'
import { adminApi } from '@/api/admin'
import {
KeyFormDialog,
KeyAllowedModelsEditDialog,
ModelsTab,
BatchAssignModelsDialog
BatchAssignModelsDialog,
OAuthAccountDialog
} from '@/features/providers/components'
import ModelMappingTab from '@/features/providers/components/provider-tabs/ModelMappingTab.vue'
import EndpointFormDialog from '@/features/providers/components/EndpointFormDialog.vue'
@@ -545,6 +650,7 @@ import {
getProviderKeys,
updateProviderKey,
revealEndpointKey,
refreshProviderOAuth,
type ProviderEndpoint,
type EndpointAPIKey,
type Model,
@@ -591,6 +697,7 @@ const endpointDialogOpen = ref(false)
// 密钥相关状态
const keyFormDialogOpen = ref(false)
const keyPermissionsDialogOpen = ref(false)
const oauthAccountDialogOpen = ref(false)
const currentEndpoint = ref<ProviderEndpoint | null>(null)
const editingKey = ref<EndpointAPIKey | null>(null)
const deleteKeyConfirmOpen = ref(false)
@@ -620,6 +727,9 @@ const editingPriorityValue = ref<number>(0)
const priorityInputRef = ref<HTMLInputElement[] | null>(null)
const prioritySaving = ref(false)
// OAuth 刷新状态
const refreshingOAuthKeyId = ref<string | null>(null)
// 点击编辑倍率相关状态
const editingMultiplierKey = ref<string | null>(null)
const editingMultiplierFormat = ref<string | null>(null)
@@ -632,6 +742,7 @@ const hasBlockingDialogOpen = computed(() =>
endpointDialogOpen.value ||
keyFormDialogOpen.value ||
keyPermissionsDialogOpen.value ||
oauthAccountDialogOpen.value ||
deleteKeyConfirmOpen.value ||
modelFormDialogOpen.value ||
batchAssignDialogOpen.value ||
@@ -695,6 +806,7 @@ watch(() => props.open, (newOpen) => {
endpointDialogOpen.value = false
keyFormDialogOpen.value = false
keyPermissionsDialogOpen.value = false
oauthAccountDialogOpen.value = false
deleteKeyConfirmOpen.value = false
batchAssignDialogOpen.value = false
@@ -759,9 +871,15 @@ function handleAddKey(endpoint: ProviderEndpoint) {
keyFormDialogOpen.value = true
}
// 添加密钥(如果有多个端点则添加到第一个)
// 添加密钥/账号(如果有多个端点则添加到第一个)
function handleAddKeyToFirstEndpoint() {
if (endpoints.value.length > 0) {
if (endpoints.value.length === 0) return
// 非自定义提供商:打开 OAuth 账号对话框
if (provider.value?.provider_type !== 'custom') {
oauthAccountDialogOpen.value = true
} else {
// 自定义提供商:打开密钥表单对话框
handleAddKey(endpoints.value[0])
}
}
@@ -849,6 +967,25 @@ async function handleRecoverKey(key: EndpointAPIKey) {
}
}
async function handleRefreshOAuth(key: EndpointAPIKey) {
if (refreshingOAuthKeyId.value) return
refreshingOAuthKeyId.value = key.id
try {
const result = await refreshProviderOAuth(key.id)
showSuccess('Token 刷新成功')
// 更新本地数据
const keyInList = providerKeys.value.find(k => k.id === key.id)
if (keyInList) {
keyInList.oauth_expires_at = result.expires_at
}
emit('refresh')
} catch (err: any) {
showError(err.response?.data?.detail || 'Token 刷新失败', '错误')
} finally {
refreshingOAuthKeyId.value = null
}
}
async function handleKeyChanged() {
await loadEndpoints()
// 并行刷新模型列表和模型映射(因为模型权限会影响正则映射预览)
@@ -1203,6 +1340,95 @@ function getKeyRateMultiplier(key: EndpointAPIKey, format: string): number {
return 1.0
}
// OAuth 订阅类型格式化
function formatOAuthPlanType(planType: string): string {
const labels: Record<string, string> = {
plus: 'Plus',
pro: 'Pro',
free: 'Free',
team: 'Team',
enterprise: 'Enterprise',
}
return labels[planType] || planType
}
// Codex 剩余额度样式(基于已用百分比计算剩余)
function getQuotaRemainingClass(usedPercent: number): string {
const remaining = 100 - usedPercent
if (remaining <= 10) return 'text-red-600 dark:text-red-400'
if (remaining <= 30) return 'text-yellow-600 dark:text-yellow-400'
return 'text-green-600 dark:text-green-400'
}
// Codex 剩余额度进度条颜色
function getQuotaRemainingBarColor(usedPercent: number): string {
const remaining = 100 - usedPercent
if (remaining <= 10) return 'bg-red-500 dark:bg-red-400'
if (remaining <= 30) return 'bg-yellow-500 dark:bg-yellow-400'
return 'bg-green-500 dark:bg-green-400'
}
// 检查是否有 Codex 额度数据
function hasCodexQuotaData(metadata: any): boolean {
if (!metadata) return false
return metadata.primary_used_percent !== undefined ||
metadata.secondary_used_percent !== undefined ||
(metadata.has_credits && metadata.credits_balance !== undefined)
}
// 格式化重置时间
function formatResetTime(seconds: number): string {
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (days > 0) {
return `${days}${hours}小时`
}
if (hours > 0) {
return `${hours}小时 ${minutes}分钟`
}
return `${minutes}分钟`
}
// OAuth 订阅类型样式
function getOAuthPlanTypeClass(planType: string): string {
const classes: Record<string, string> = {
plus: 'border-green-500/50 text-green-600 dark:text-green-400',
pro: 'border-blue-500/50 text-blue-600 dark:text-blue-400',
free: 'border-primary/50 text-primary',
team: 'border-purple-500/50 text-purple-600 dark:text-purple-400',
enterprise: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
}
return classes[planType] || ''
}
// OAuth 状态信息(包括失效和过期)
function getKeyOAuthExpires(key: EndpointAPIKey) {
if (key.auth_type !== 'oauth') return null
// 即使没有 expires_at也要检查 invalid_at
if (!key.oauth_expires_at && !key.oauth_invalid_at) return null
return getOAuthExpiresCountdown(
key.oauth_expires_at,
countdownTick.value,
key.oauth_invalid_at,
key.oauth_invalid_reason
)
}
// OAuth 状态的 title 提示
function getOAuthStatusTitle(key: EndpointAPIKey): string {
const status = getKeyOAuthExpires(key)
if (!status) return ''
if (status.isInvalid) {
return status.invalidReason ? `Token 已失效: ${status.invalidReason}` : 'Token 已失效'
}
if (status.isExpired) {
return 'Token 已过期,请重新授权'
}
return `Token 剩余有效期: ${status.text}`
}
// 健康度颜色
function getHealthScoreColor(score: number): string {
if (score >= 0.8) return 'text-green-600 dark:text-green-400'

View File

@@ -29,11 +29,21 @@
<SelectValue placeholder="请选择" />
</SelectTrigger>
<SelectContent>
<SelectItem value="custom">自定义</SelectItem>
<SelectItem value="claude_code">ClaudeCode</SelectItem>
<SelectItem value="codex">Codex</SelectItem>
<SelectItem value="gemini_cli">GeminiCli</SelectItem>
<SelectItem value="antigravity">Antigravity</SelectItem>
<SelectItem value="custom">
自定义
</SelectItem>
<SelectItem value="claude_code">
ClaudeCode
</SelectItem>
<SelectItem value="codex">
Codex
</SelectItem>
<SelectItem value="gemini_cli">
GeminiCli
</SelectItem>
<SelectItem value="antigravity">
Antigravity
</SelectItem>
</SelectContent>
</Select>
<p

View File

@@ -89,7 +89,9 @@
<!-- 视频计费(可选覆盖) -->
<div class="pt-3 border-t space-y-2">
<div class="text-sm font-medium">视频计费(可选覆盖)</div>
<div class="text-sm font-medium">
视频计费(可选覆盖)
</div>
<div class="flex items-center gap-1.5 flex-wrap">
<Button
@@ -138,7 +140,7 @@
<div class="grid grid-cols-[1fr_1fr_32px] gap-0 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 border-b border-border">
<span>分辨率</span>
<span>单价($/秒)</span>
<span></span>
<span />
</div>
<div class="divide-y divide-border">
<div
@@ -176,7 +178,6 @@
</div>
</div>
</div>
</form>
<template #footer>

View File

@@ -8,6 +8,7 @@ export { default as ProviderModelFormDialog } from './ProviderModelFormDialog.vu
export { default as ProviderDetailDrawer } from './ProviderDetailDrawer.vue'
export { default as EndpointHealthTimeline } from './EndpointHealthTimeline.vue'
export { default as BatchAssignModelsDialog } from './BatchAssignModelsDialog.vue'
export { default as OAuthAccountDialog } from './OAuthAccountDialog.vue'
export { default as ModelsTab } from './provider-tabs/ModelsTab.vue'
export { default as ProviderAuthDialog } from './ProviderAuthDialog.vue'

View File

@@ -7,64 +7,64 @@
</div>
<div class="overflow-auto max-h-[320px]">
<Table class="text-sm">
<TableHeader>
<TableRow>
<TableHead class="h-8 px-2">
API格式
</TableHead>
<TableHead class="h-8 px-2 text-right">
请求数
</TableHead>
<TableHead class="h-8 px-2 text-right">
Tokens
</TableHead>
<TableHead class="h-8 px-2 text-right">
费用
</TableHead>
<TableHead class="h-8 px-2 text-right">
平均响应
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="data.length === 0">
<TableCell
:colspan="5"
class="text-center py-6 text-muted-foreground px-2"
<TableHeader>
<TableRow>
<TableHead class="h-8 px-2">
API格式
</TableHead>
<TableHead class="h-8 px-2 text-right">
请求数
</TableHead>
<TableHead class="h-8 px-2 text-right">
Tokens
</TableHead>
<TableHead class="h-8 px-2 text-right">
费用
</TableHead>
<TableHead class="h-8 px-2 text-right">
平均响应
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="data.length === 0">
<TableCell
:colspan="5"
class="text-center py-6 text-muted-foreground px-2"
>
暂无API格式统计数据
</TableCell>
</TableRow>
<TableRow
v-for="item in data"
:key="item.api_format"
>
暂无API格式统计数据
</TableCell>
</TableRow>
<TableRow
v-for="item in data"
:key="item.api_format"
>
<TableCell class="font-medium py-2 px-2">
{{ formatApiFormat(item.api_format) }}
</TableCell>
<TableCell class="text-right py-2 px-2">
{{ item.request_count }}
</TableCell>
<TableCell class="text-right py-2 px-2">
<span>{{ formatTokens(item.total_tokens) }}</span>
</TableCell>
<TableCell class="text-right py-2 px-2">
<div class="flex flex-col items-end text-xs gap-0.5">
<span class="text-primary font-medium">{{ formatCurrency(item.total_cost) }}</span>
<span
v-if="isAdmin && item.actual_cost !== undefined"
class="text-muted-foreground text-[10px]"
>
{{ formatCurrency(item.actual_cost) }}
</span>
</div>
</TableCell>
<TableCell class="text-right text-muted-foreground py-2 px-2">
{{ item.avgResponseTime }}
</TableCell>
</TableRow>
</TableBody>
</Table>
<TableCell class="font-medium py-2 px-2">
{{ formatApiFormat(item.api_format) }}
</TableCell>
<TableCell class="text-right py-2 px-2">
{{ item.request_count }}
</TableCell>
<TableCell class="text-right py-2 px-2">
<span>{{ formatTokens(item.total_tokens) }}</span>
</TableCell>
<TableCell class="text-right py-2 px-2">
<div class="flex flex-col items-end text-xs gap-0.5">
<span class="text-primary font-medium">{{ formatCurrency(item.total_cost) }}</span>
<span
v-if="isAdmin && item.actual_cost !== undefined"
class="text-muted-foreground text-[10px]"
>
{{ formatCurrency(item.actual_cost) }}
</span>
</div>
</TableCell>
<TableCell class="text-right text-muted-foreground py-2 px-2">
{{ item.avgResponseTime }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</Card>
</template>

View File

@@ -7,64 +7,64 @@
</div>
<div class="overflow-auto max-h-[320px]">
<Table class="text-sm">
<TableHeader>
<TableRow>
<TableHead class="h-8 px-2">
模型
</TableHead>
<TableHead class="h-8 px-2 text-right">
请求数
</TableHead>
<TableHead class="h-8 px-2 text-right">
Tokens
</TableHead>
<TableHead class="h-8 px-2 text-right">
费用
</TableHead>
<TableHead class="h-8 px-2 text-right">
效率
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="data.length === 0">
<TableCell
:colspan="5"
class="text-center py-6 text-muted-foreground px-2"
<TableHeader>
<TableRow>
<TableHead class="h-8 px-2">
模型
</TableHead>
<TableHead class="h-8 px-2 text-right">
请求数
</TableHead>
<TableHead class="h-8 px-2 text-right">
Tokens
</TableHead>
<TableHead class="h-8 px-2 text-right">
费用
</TableHead>
<TableHead class="h-8 px-2 text-right">
效率
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="data.length === 0">
<TableCell
:colspan="5"
class="text-center py-6 text-muted-foreground px-2"
>
暂无模型统计数据
</TableCell>
</TableRow>
<TableRow
v-for="model in data"
:key="model.model"
>
暂无模型统计数据
</TableCell>
</TableRow>
<TableRow
v-for="model in data"
:key="model.model"
>
<TableCell class="font-medium py-2 px-2">
{{ model.model.replace('claude-', '') }}
</TableCell>
<TableCell class="text-right py-2 px-2">
{{ model.request_count }}
</TableCell>
<TableCell class="text-right py-2 px-2">
<span>{{ formatTokens(model.total_tokens) }}</span>
</TableCell>
<TableCell class="text-right py-2 px-2">
<div class="flex flex-col items-end text-xs gap-0.5">
<span class="text-primary font-medium">{{ formatCurrency(model.total_cost) }}</span>
<span
v-if="isAdmin && model.actual_cost !== undefined"
class="text-muted-foreground text-[10px]"
>
{{ formatCurrency(model.actual_cost) }}
</span>
</div>
</TableCell>
<TableCell class="text-right text-muted-foreground py-2 px-2">
{{ model.costPerToken }}
</TableCell>
</TableRow>
</TableBody>
</Table>
<TableCell class="font-medium py-2 px-2">
{{ model.model.replace('claude-', '') }}
</TableCell>
<TableCell class="text-right py-2 px-2">
{{ model.request_count }}
</TableCell>
<TableCell class="text-right py-2 px-2">
<span>{{ formatTokens(model.total_tokens) }}</span>
</TableCell>
<TableCell class="text-right py-2 px-2">
<div class="flex flex-col items-end text-xs gap-0.5">
<span class="text-primary font-medium">{{ formatCurrency(model.total_cost) }}</span>
<span
v-if="isAdmin && model.actual_cost !== undefined"
class="text-muted-foreground text-[10px]"
>
{{ formatCurrency(model.actual_cost) }}
</span>
</div>
</TableCell>
<TableCell class="text-right text-muted-foreground py-2 px-2">
{{ model.costPerToken }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</Card>
</template>

View File

@@ -7,70 +7,70 @@
</div>
<div class="overflow-auto max-h-[320px]">
<Table class="text-sm">
<TableHeader>
<TableRow>
<TableHead class="h-8 px-2">
提供商
</TableHead>
<TableHead class="h-8 px-2 text-right">
请求数
</TableHead>
<TableHead class="h-8 px-2 text-right">
Tokens
</TableHead>
<TableHead class="h-8 px-2 text-right">
费用
</TableHead>
<TableHead class="h-8 px-2 text-right">
成功率
</TableHead>
<TableHead class="h-8 px-2 text-right">
平均响应
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="data.length === 0">
<TableCell
:colspan="6"
class="text-center py-6 text-muted-foreground px-2"
<TableHeader>
<TableRow>
<TableHead class="h-8 px-2">
提供商
</TableHead>
<TableHead class="h-8 px-2 text-right">
请求数
</TableHead>
<TableHead class="h-8 px-2 text-right">
Tokens
</TableHead>
<TableHead class="h-8 px-2 text-right">
费用
</TableHead>
<TableHead class="h-8 px-2 text-right">
成功率
</TableHead>
<TableHead class="h-8 px-2 text-right">
平均响应
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="data.length === 0">
<TableCell
:colspan="6"
class="text-center py-6 text-muted-foreground px-2"
>
暂无提供商统计数据
</TableCell>
</TableRow>
<TableRow
v-for="provider in data"
:key="provider.provider"
>
暂无提供商统计数据
</TableCell>
</TableRow>
<TableRow
v-for="provider in data"
:key="provider.provider"
>
<TableCell class="font-medium py-2 px-2">
{{ provider.provider }}
</TableCell>
<TableCell class="text-right py-2 px-2">
{{ provider.requests }}
</TableCell>
<TableCell class="text-right py-2 px-2">
<span>{{ formatTokens(provider.totalTokens) }}</span>
</TableCell>
<TableCell class="text-right py-2 px-2">
<div class="flex flex-col items-end text-xs gap-0.5">
<span class="text-primary font-medium">{{ formatCurrency(provider.totalCost) }}</span>
<span
v-if="isAdmin && provider.actualCost !== undefined"
class="text-muted-foreground text-[10px]"
>
{{ formatCurrency(provider.actualCost) }}
</span>
</div>
</TableCell>
<TableCell class="text-right py-2 px-2">
<span :class="getSuccessRateClass(provider.successRate)">{{ provider.successRate }}%</span>
</TableCell>
<TableCell class="text-right text-muted-foreground py-2 px-2">
{{ provider.avgResponseTime }}
</TableCell>
</TableRow>
</TableBody>
</Table>
<TableCell class="font-medium py-2 px-2">
{{ provider.provider }}
</TableCell>
<TableCell class="text-right py-2 px-2">
{{ provider.requests }}
</TableCell>
<TableCell class="text-right py-2 px-2">
<span>{{ formatTokens(provider.totalTokens) }}</span>
</TableCell>
<TableCell class="text-right py-2 px-2">
<div class="flex flex-col items-end text-xs gap-0.5">
<span class="text-primary font-medium">{{ formatCurrency(provider.totalCost) }}</span>
<span
v-if="isAdmin && provider.actualCost !== undefined"
class="text-muted-foreground text-[10px]"
>
{{ formatCurrency(provider.actualCost) }}
</span>
</div>
</TableCell>
<TableCell class="text-right py-2 px-2">
<span :class="getSuccessRateClass(provider.successRate)">{{ provider.successRate }}%</span>
</TableCell>
<TableCell class="text-right text-muted-foreground py-2 px-2">
{{ provider.avgResponseTime }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</Card>
</template>

View File

@@ -516,10 +516,10 @@ const navigation = computed(() => {
// 动态添加已激活模块的菜单项
// 图标映射
const iconMap: Record<string, LucideIcon> = {
'Key': Key,
'FileUp': FileUp,
'Shield': Shield,
'Puzzle': Puzzle,
Key,
FileUp,
Shield,
Puzzle,
}
// 添加模块菜单项(按 admin_menu_order 排序,只显示已激活的)

View File

@@ -2,71 +2,122 @@
<div class="space-y-6 pb-8">
<!-- 统计卡片 -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<Card variant="default" class="p-4">
<Card
variant="default"
class="p-4"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
<Zap class="w-5 h-5 text-primary" />
</div>
<div>
<p class="text-2xl font-bold">{{ stats?.total ?? '-' }}</p>
<p class="text-xs text-muted-foreground">总任务数</p>
<p class="text-2xl font-bold">
{{ stats?.total ?? '-' }}
</p>
<p class="text-xs text-muted-foreground">
总任务数
</p>
</div>
</div>
</Card>
<Card variant="default" class="p-4">
<Card
variant="default"
class="p-4"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
<Loader2 class="w-5 h-5 text-blue-500" :class="{ 'animate-spin': (stats?.processing_count ?? 0) > 0 }" />
<Loader2
class="w-5 h-5 text-blue-500"
:class="{ 'animate-spin': (stats?.processing_count ?? 0) > 0 }"
/>
</div>
<div>
<p class="text-2xl font-bold">{{ stats?.processing_count ?? stats?.by_status?.processing ?? '-' }}</p>
<p class="text-xs text-muted-foreground">处理中</p>
<p class="text-2xl font-bold">
{{ stats?.processing_count ?? stats?.by_status?.processing ?? '-' }}
</p>
<p class="text-xs text-muted-foreground">
处理中
</p>
</div>
</div>
</Card>
<Card variant="default" class="p-4">
<Card
variant="default"
class="p-4"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-green-500/10 flex items-center justify-center">
<CheckCircle class="w-5 h-5 text-green-500" />
</div>
<div>
<p class="text-2xl font-bold">{{ stats?.by_status?.completed ?? '-' }}</p>
<p class="text-xs text-muted-foreground">已完成</p>
<p class="text-2xl font-bold">
{{ stats?.by_status?.completed ?? '-' }}
</p>
<p class="text-xs text-muted-foreground">
已完成
</p>
</div>
</div>
</Card>
<Card variant="default" class="p-4">
<Card
variant="default"
class="p-4"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-amber-500/10 flex items-center justify-center">
<Calendar class="w-5 h-5 text-amber-500" />
</div>
<div>
<p class="text-2xl font-bold">{{ stats?.today_count ?? '-' }}</p>
<p class="text-xs text-muted-foreground">今日任务</p>
<p class="text-2xl font-bold">
{{ stats?.today_count ?? '-' }}
</p>
<p class="text-xs text-muted-foreground">
今日任务
</p>
</div>
</div>
</Card>
</div>
<!-- 任务表格 -->
<Card variant="default" class="overflow-hidden">
<Card
variant="default"
class="overflow-hidden"
>
<!-- 标题和筛选器 -->
<div class="px-4 sm:px-6 py-3.5 border-b border-border/60">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h3 class="text-base font-semibold">异步任务</h3>
<h3 class="text-base font-semibold">
异步任务
</h3>
<div class="flex items-center gap-2">
<!-- 状态筛选 -->
<Select v-model:open="statusSelectOpen" v-model="filterStatus">
<Select
v-model:open="statusSelectOpen"
v-model="filterStatus"
>
<SelectTrigger class="w-28 h-8 text-xs border-border/60">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="submitted">已提交</SelectItem>
<SelectItem value="processing">处理中</SelectItem>
<SelectItem value="completed">已完成</SelectItem>
<SelectItem value="failed">失败</SelectItem>
<SelectItem value="cancelled">已取消</SelectItem>
<SelectItem value="all">
全部状态
</SelectItem>
<SelectItem value="submitted">
已提交
</SelectItem>
<SelectItem value="processing">
处理中
</SelectItem>
<SelectItem value="completed">
已完成
</SelectItem>
<SelectItem value="failed">
失败
</SelectItem>
<SelectItem value="cancelled">
已取消
</SelectItem>
</SelectContent>
</Select>
<!-- 模型筛选 -->
@@ -84,34 +135,62 @@
:disabled="loading"
@click="fetchTasks"
>
<RefreshCw class="w-3.5 h-3.5" :class="{ 'animate-spin': loading }" />
<RefreshCw
class="w-3.5 h-3.5"
:class="{ 'animate-spin': loading }"
/>
</Button>
</div>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading && !tasks.length" class="p-8 text-center">
<div
v-if="loading && !tasks.length"
class="p-8 text-center"
>
<Loader2 class="w-8 h-8 animate-spin mx-auto text-muted-foreground" />
<p class="mt-2 text-sm text-muted-foreground">加载中...</p>
<p class="mt-2 text-sm text-muted-foreground">
加载中...
</p>
</div>
<!-- 空状态 -->
<div v-else-if="!tasks.length" class="p-8 text-center">
<div
v-else-if="!tasks.length"
class="p-8 text-center"
>
<Zap class="w-12 h-12 mx-auto text-muted-foreground/50" />
<p class="mt-2 text-sm text-muted-foreground">暂无异步任务</p>
<p class="mt-2 text-sm text-muted-foreground">
暂无异步任务
</p>
</div>
<!-- 桌面端表格 -->
<Table v-else class="hidden md:table">
<Table
v-else
class="hidden md:table"
>
<TableHeader>
<TableRow>
<TableHead class="w-[25%]">任务</TableHead>
<TableHead class="w-[15%]">用户/Provider</TableHead>
<TableHead class="w-[12%]">状态</TableHead>
<TableHead class="w-[10%]">参数</TableHead>
<TableHead class="w-[15%]">时间</TableHead>
<TableHead class="w-[8%] text-center">操作</TableHead>
<TableHead class="w-[25%]">
任务
</TableHead>
<TableHead class="w-[15%]">
用户/Provider
</TableHead>
<TableHead class="w-[12%]">
状态
</TableHead>
<TableHead class="w-[10%]">
参数
</TableHead>
<TableHead class="w-[15%]">
时间
</TableHead>
<TableHead class="w-[8%] text-center">
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -125,10 +204,16 @@
<TableCell>
<div class="space-y-1">
<div class="flex items-center gap-2">
<Video v-if="isVideoTask(task)" class="w-4 h-4 text-muted-foreground shrink-0" />
<Video
v-if="isVideoTask(task)"
class="w-4 h-4 text-muted-foreground shrink-0"
/>
<span class="font-medium text-sm truncate">{{ task.model }}</span>
</div>
<p class="text-xs text-muted-foreground truncate max-w-[280px]" :title="task.prompt">
<p
class="text-xs text-muted-foreground truncate max-w-[280px]"
:title="task.prompt"
>
{{ task.prompt }}
</p>
</div>
@@ -149,10 +234,16 @@
<!-- 状态 -->
<TableCell>
<div class="flex flex-col items-start gap-1">
<Badge :variant="getStatusVariant(task.status)" class="text-xs">
<Badge
:variant="getStatusVariant(task.status)"
class="text-xs"
>
{{ getStatusLabel(task.status) }}
</Badge>
<div v-if="task.progress_percent > 0 && task.status === 'processing'" class="w-full">
<div
v-if="task.progress_percent > 0 && task.status === 'processing'"
class="w-full"
>
<div class="flex items-center gap-2">
<div class="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
<div
@@ -168,12 +259,19 @@
<!-- 参数 -->
<TableCell>
<div class="text-xs space-y-0.5 text-muted-foreground">
<div v-if="task.duration_seconds" class="flex items-center gap-1">
<div
v-if="task.duration_seconds"
class="flex items-center gap-1"
>
<Timer class="w-3 h-3" />
<span>{{ task.duration_seconds }}s</span>
</div>
<div v-if="task.resolution">{{ task.resolution }}</div>
<div v-if="task.aspect_ratio">{{ task.aspect_ratio }}</div>
<div v-if="task.resolution">
{{ task.resolution }}
</div>
<div v-if="task.aspect_ratio">
{{ task.aspect_ratio }}
</div>
</div>
</TableCell>
<!-- 时间 -->
@@ -183,7 +281,10 @@
<Clock class="w-3 h-3" />
<span>{{ formatDate(task.created_at) }}</span>
</div>
<div v-if="task.completed_at" class="flex items-center gap-1.5 text-green-600 dark:text-green-400">
<div
v-if="task.completed_at"
class="flex items-center gap-1.5 text-green-600 dark:text-green-400"
>
<CheckCircle class="w-3 h-3" />
<span>{{ formatDate(task.completed_at) }}</span>
</div>
@@ -217,7 +318,10 @@
</Table>
<!-- 移动端卡片列表 -->
<div v-if="tasks.length" class="md:hidden divide-y divide-border/60">
<div
v-if="tasks.length"
class="md:hidden divide-y divide-border/60"
>
<div
v-for="task in tasks"
:key="`m-${task.id}`"
@@ -227,27 +331,40 @@
<!-- 顶部模型和状态 -->
<div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-2 min-w-0 flex-1">
<Video v-if="isVideoTask(task)" class="w-4 h-4 text-muted-foreground shrink-0" />
<Video
v-if="isVideoTask(task)"
class="w-4 h-4 text-muted-foreground shrink-0"
/>
<span class="font-medium text-sm truncate">{{ task.model }}</span>
</div>
<Badge :variant="getStatusVariant(task.status)" class="text-xs shrink-0">
<Badge
:variant="getStatusVariant(task.status)"
class="text-xs shrink-0"
>
{{ getStatusLabel(task.status) }}
</Badge>
</div>
<!-- 进度条如果有 -->
<div v-if="task.progress_percent > 0 && task.status === 'processing'" class="space-y-1">
<div
v-if="task.progress_percent > 0 && task.status === 'processing'"
class="space-y-1"
>
<div class="h-1.5 bg-muted rounded-full overflow-hidden">
<div
class="h-full bg-primary transition-all"
:style="{ width: `${task.progress_percent}%` }"
/>
</div>
<p class="text-xs text-muted-foreground text-right">{{ task.progress_percent }}%</p>
<p class="text-xs text-muted-foreground text-right">
{{ task.progress_percent }}%
</p>
</div>
<!-- Prompt -->
<p class="text-sm text-muted-foreground line-clamp-2">{{ task.prompt }}</p>
<p class="text-sm text-muted-foreground line-clamp-2">
{{ task.prompt }}
</p>
<!-- 信息网格 -->
<div class="grid grid-cols-2 gap-2 text-xs">
@@ -263,7 +380,10 @@
<Clock class="w-3 h-3" />
<span>{{ formatDate(task.created_at) }}</span>
</div>
<div v-if="task.duration_seconds" class="flex items-center gap-1.5 text-muted-foreground">
<div
v-if="task.duration_seconds"
class="flex items-center gap-1.5 text-muted-foreground"
>
<Timer class="w-3 h-3" />
<span>{{ task.duration_seconds }}s</span>
</div>
@@ -326,9 +446,14 @@
<!-- 第一行标题模型状态操作按钮 -->
<div class="flex items-center justify-between gap-4 mb-3">
<div class="flex items-center gap-3 flex-wrap">
<h3 class="text-lg font-semibold">任务详情</h3>
<h3 class="text-lg font-semibold">
任务详情
</h3>
<div class="flex items-center gap-1 text-sm font-mono text-muted-foreground bg-muted px-2 py-0.5 rounded">
<Video v-if="isVideoTask(selectedTask)" class="w-3.5 h-3.5 mr-1" />
<Video
v-if="isVideoTask(selectedTask)"
class="w-3.5 h-3.5 mr-1"
/>
<span>{{ selectedTask.model }}</span>
</div>
<Badge :variant="getStatusVariant(selectedTask.status)">
@@ -344,9 +469,18 @@
:title="detailAutoRefresh ? '停止自动刷新' : '开启自动刷新每5秒'"
@click="toggleDetailAutoRefresh"
>
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': detailAutoRefresh }" />
<RefreshCw
class="w-4 h-4"
:class="{ 'animate-spin': detailAutoRefresh }"
/>
</Button>
<Button variant="ghost" size="icon" class="h-8 w-8" title="关闭" @click="closeDetail">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="关闭"
@click="closeDetail"
>
<X class="w-4 h-4" />
</Button>
</div>
@@ -365,7 +499,10 @@
<span>Provider: {{ selectedTask.provider_name }}</span>
</div>
<!-- 进度条 -->
<div v-if="selectedTask.progress_percent > 0 && selectedTask.status === 'processing'" class="mt-3">
<div
v-if="selectedTask.progress_percent > 0 && selectedTask.status === 'processing'"
class="mt-3"
>
<div class="flex items-center gap-3">
<div class="flex-1 h-2 bg-muted rounded-full overflow-hidden">
<div
@@ -375,7 +512,10 @@
</div>
<span class="text-xs text-muted-foreground font-medium">{{ selectedTask.progress_percent }}%</span>
</div>
<p v-if="selectedTask.progress_message" class="text-xs text-muted-foreground mt-1">
<p
v-if="selectedTask.progress_message"
class="text-xs text-muted-foreground mt-1"
>
{{ selectedTask.progress_message }}
</p>
</div>
@@ -384,20 +524,31 @@
<!-- 可滚动内容区域 -->
<div class="flex-1 min-h-0 overflow-y-auto px-3 sm:px-6 py-3 sm:py-4 space-y-5">
<!-- 错误信息 -->
<div v-if="selectedTask.error_message" class="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
<div
v-if="selectedTask.error_message"
class="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800"
>
<div class="flex items-start gap-2">
<AlertCircle class="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
<div>
<p v-if="selectedTask.error_code" class="text-xs font-medium text-red-600 dark:text-red-400 mb-1">
<p
v-if="selectedTask.error_code"
class="text-xs font-medium text-red-600 dark:text-red-400 mb-1"
>
错误码: {{ selectedTask.error_code }}
</p>
<p class="text-sm text-red-600 dark:text-red-400">{{ selectedTask.error_message }}</p>
<p class="text-sm text-red-600 dark:text-red-400">
{{ selectedTask.error_message }}
</p>
</div>
</div>
</div>
<!-- 视频结果放在最前面 -->
<div v-if="selectedTask.video_url || selectedTask.video_urls?.length" class="space-y-3">
<div
v-if="selectedTask.video_url || selectedTask.video_urls?.length"
class="space-y-3"
>
<!-- 主视频 -->
<div v-if="selectedTask.video_url">
<div class="rounded-lg overflow-hidden border border-border/60 bg-black">
@@ -412,7 +563,10 @@
<div class="mt-2 space-y-2">
<!-- 链接 -->
<div class="flex items-center gap-1 p-1.5 bg-muted/50 rounded border border-border/40">
<code class="flex-1 text-xs text-foreground truncate px-1" :title="selectedTask.video_url">{{ selectedTask.video_url }}</code>
<code
class="flex-1 text-xs text-foreground truncate px-1"
:title="selectedTask.video_url"
>{{ selectedTask.video_url }}</code>
<Button
variant="ghost"
size="sm"
@@ -423,11 +577,17 @@
</Button>
</div>
<!-- 元信息 -->
<div v-if="selectedTask.video_size_bytes || selectedTask.video_expires_at" class="flex items-center gap-3 text-xs text-muted-foreground">
<div
v-if="selectedTask.video_size_bytes || selectedTask.video_expires_at"
class="flex items-center gap-3 text-xs text-muted-foreground"
>
<span v-if="selectedTask.video_size_bytes">
大小: {{ formatFileSize(selectedTask.video_size_bytes) }}
</span>
<span v-if="selectedTask.video_expires_at" class="text-amber-600 dark:text-amber-400">
<span
v-if="selectedTask.video_expires_at"
class="text-amber-600 dark:text-amber-400"
>
过期: {{ formatDate(selectedTask.video_expires_at) }}
</span>
</div>
@@ -435,14 +595,30 @@
</div>
<!-- 多个视频 -->
<div v-else-if="selectedTask.video_urls?.length" class="space-y-4">
<div v-for="(url, index) in selectedTask.video_urls" :key="index">
<p class="text-xs text-muted-foreground font-medium mb-1.5">视频 {{ index + 1 }}</p>
<div
v-else-if="selectedTask.video_urls?.length"
class="space-y-4"
>
<div
v-for="(url, index) in selectedTask.video_urls"
:key="index"
>
<p class="text-xs text-muted-foreground font-medium mb-1.5">
视频 {{ index + 1 }}
</p>
<div class="rounded-lg overflow-hidden border border-border/60 bg-black">
<video :src="getVideoUrl(selectedTask.id, url)" controls preload="none" class="w-full max-h-[250px] object-contain" />
<video
:src="getVideoUrl(selectedTask.id, url)"
controls
preload="none"
class="w-full max-h-[250px] object-contain"
/>
</div>
<div class="mt-1.5 flex items-center gap-1 p-1.5 bg-muted/50 rounded border border-border/40">
<code class="flex-1 text-xs text-foreground truncate px-1" :title="url">{{ url }}</code>
<code
class="flex-1 text-xs text-foreground truncate px-1"
:title="url"
>{{ url }}</code>
<Button
variant="ghost"
size="sm"
@@ -457,15 +633,22 @@
</div>
<!-- 任务完成但无视频 -->
<div v-else-if="selectedTask.status === 'completed'" class="p-4 bg-amber-50 dark:bg-amber-900/20 rounded-lg border border-amber-200 dark:border-amber-800 text-center">
<div
v-else-if="selectedTask.status === 'completed'"
class="p-4 bg-amber-50 dark:bg-amber-900/20 rounded-lg border border-amber-200 dark:border-amber-800 text-center"
>
<Video class="w-8 h-8 mx-auto mb-2 text-amber-500" />
<p class="text-sm text-amber-600 dark:text-amber-400">视频链接不可用或已过期</p>
<p class="text-sm text-amber-600 dark:text-amber-400">
视频链接不可用或已过期
</p>
</div>
<!-- Prompt -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<h4 class="text-xs font-medium text-muted-foreground uppercase tracking-wide">Prompt</h4>
<h4 class="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Prompt
</h4>
<Button
variant="outline"
size="sm"
@@ -482,69 +665,134 @@
</div>
<!-- 视频信息网格布局 -->
<div v-if="selectedTask.video_duration_seconds || selectedTask.resolution || selectedTask.aspect_ratio || selectedTask.size" class="space-y-2">
<h4 class="text-xs font-medium text-muted-foreground uppercase tracking-wide">视频信息</h4>
<div
v-if="selectedTask.video_duration_seconds || selectedTask.resolution || selectedTask.aspect_ratio || selectedTask.size"
class="space-y-2"
>
<h4 class="text-xs font-medium text-muted-foreground uppercase tracking-wide">
视频信息
</h4>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div v-if="selectedTask.video_duration_seconds" class="p-3 bg-muted/30 rounded-lg">
<p class="text-xs text-muted-foreground mb-0.5">视频时长</p>
<p class="text-sm font-medium">{{ selectedTask.video_duration_seconds.toFixed(1) }}s</p>
<div
v-if="selectedTask.video_duration_seconds"
class="p-3 bg-muted/30 rounded-lg"
>
<p class="text-xs text-muted-foreground mb-0.5">
视频时长
</p>
<p class="text-sm font-medium">
{{ selectedTask.video_duration_seconds.toFixed(1) }}s
</p>
</div>
<div v-if="selectedTask.resolution" class="p-3 bg-muted/30 rounded-lg">
<p class="text-xs text-muted-foreground mb-0.5">分辨率</p>
<p class="text-sm font-medium">{{ selectedTask.resolution }}</p>
<div
v-if="selectedTask.resolution"
class="p-3 bg-muted/30 rounded-lg"
>
<p class="text-xs text-muted-foreground mb-0.5">
分辨率
</p>
<p class="text-sm font-medium">
{{ selectedTask.resolution }}
</p>
</div>
<div v-if="selectedTask.aspect_ratio" class="p-3 bg-muted/30 rounded-lg">
<p class="text-xs text-muted-foreground mb-0.5">宽高比</p>
<p class="text-sm font-medium">{{ selectedTask.aspect_ratio }}</p>
<div
v-if="selectedTask.aspect_ratio"
class="p-3 bg-muted/30 rounded-lg"
>
<p class="text-xs text-muted-foreground mb-0.5">
宽高比
</p>
<p class="text-sm font-medium">
{{ selectedTask.aspect_ratio }}
</p>
</div>
<div v-if="selectedTask.size" class="p-3 bg-muted/30 rounded-lg">
<p class="text-xs text-muted-foreground mb-0.5">尺寸</p>
<p class="text-sm font-medium">{{ selectedTask.size }}</p>
<div
v-if="selectedTask.size"
class="p-3 bg-muted/30 rounded-lg"
>
<p class="text-xs text-muted-foreground mb-0.5">
尺寸
</p>
<p class="text-sm font-medium">
{{ selectedTask.size }}
</p>
</div>
</div>
</div>
<!-- 执行状态网格布局 -->
<div class="space-y-2">
<h4 class="text-xs font-medium text-muted-foreground uppercase tracking-wide">执行状态</h4>
<h4 class="text-xs font-medium text-muted-foreground uppercase tracking-wide">
执行状态
</h4>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div class="p-3 bg-muted/30 rounded-lg">
<p class="text-xs text-muted-foreground mb-0.5">轮询</p>
<p class="text-sm font-medium">{{ selectedTask.poll_count }} / {{ selectedTask.max_poll_count }}</p>
<p class="text-xs text-muted-foreground mb-0.5">
轮询
</p>
<p class="text-sm font-medium">
{{ selectedTask.poll_count }} / {{ selectedTask.max_poll_count }}
</p>
</div>
<div class="p-3 bg-muted/30 rounded-lg">
<p class="text-xs text-muted-foreground mb-0.5">重试</p>
<p class="text-sm font-medium">{{ selectedTask.retry_count }} / {{ selectedTask.max_retries }}</p>
<p class="text-xs text-muted-foreground mb-0.5">
重试
</p>
<p class="text-sm font-medium">
{{ selectedTask.retry_count }} / {{ selectedTask.max_retries }}
</p>
</div>
<div class="p-3 bg-muted/30 rounded-lg">
<p class="text-xs text-muted-foreground mb-0.5">轮询间隔</p>
<p class="text-sm font-medium">{{ selectedTask.poll_interval_seconds }}s</p>
<p class="text-xs text-muted-foreground mb-0.5">
轮询间隔
</p>
<p class="text-sm font-medium">
{{ selectedTask.poll_interval_seconds }}s
</p>
</div>
<div v-if="selectedTask.next_poll_at" class="p-3 bg-muted/30 rounded-lg">
<p class="text-xs text-muted-foreground mb-0.5">下次轮询</p>
<p class="text-sm font-medium">{{ formatDate(selectedTask.next_poll_at) }}</p>
<div
v-if="selectedTask.next_poll_at"
class="p-3 bg-muted/30 rounded-lg"
>
<p class="text-xs text-muted-foreground mb-0.5">
下次轮询
</p>
<p class="text-sm font-medium">
{{ formatDate(selectedTask.next_poll_at) }}
</p>
</div>
</div>
</div>
<!-- 时间范围 -->
<div class="space-y-2">
<h4 class="text-xs font-medium text-muted-foreground uppercase tracking-wide">时间范围</h4>
<h4 class="text-xs font-medium text-muted-foreground uppercase tracking-wide">
时间范围
</h4>
<div class="flex items-center gap-1 text-sm font-medium">
<span>{{ formatTimeWithMs(selectedTask.created_at) }}</span>
<span class="time-arrow-container">
<span v-if="selectedTask.completed_at" class="time-duration">+{{ calcDuration(selectedTask.created_at, selectedTask.completed_at) }}</span>
<span
v-if="selectedTask.completed_at"
class="time-duration"
>+{{ calcDuration(selectedTask.created_at, selectedTask.completed_at) }}</span>
<span class="time-arrow"></span>
</span>
<template v-if="selectedTask.completed_at">
<span>{{ formatTimeWithMs(selectedTask.completed_at) }}</span>
</template>
<span v-else class="text-muted-foreground">处理中...</span>
<span
v-else
class="text-muted-foreground"
>处理中...</span>
</div>
</div>
<!-- 响应数据 -->
<div v-if="selectedTask.request_metadata?.poll_raw_response" class="space-y-2">
<div
v-if="selectedTask.request_metadata?.poll_raw_response"
class="space-y-2"
>
<div class="flex items-center justify-between">
<h4 class="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<FileJson class="w-3.5 h-3.5" />
@@ -566,7 +814,10 @@
</div>
<!-- 操作按钮 -->
<div v-if="canCancel(selectedTask.status)" class="pt-4 border-t border-border/60">
<div
v-if="canCancel(selectedTask.status)"
class="pt-4 border-t border-border/60"
>
<Button
variant="destructive"
class="w-full"
@@ -591,52 +842,6 @@
</div>
</template>
<style scoped>
.drawer-enter-active,
.drawer-leave-active {
transition: all 0.3s ease;
}
.drawer-enter-active > div:first-child,
.drawer-leave-active > div:first-child {
transition: opacity 0.3s ease;
}
.drawer-enter-active > div:last-child,
.drawer-leave-active > div:last-child {
transition: transform 0.3s ease;
}
.drawer-enter-from,
.drawer-leave-to {
opacity: 0;
}
.drawer-enter-from > div:last-child,
.drawer-leave-to > div:last-child {
transform: translateX(100%);
}
/* 时间范围箭头容器 */
.time-arrow-container {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 0.25rem;
}
.time-arrow {
color: hsl(var(--muted-foreground));
}
.time-duration {
position: absolute;
top: -1rem;
left: 50%;
transform: translateX(-50%);
font-size: 0.65rem;
color: hsl(var(--muted-foreground));
white-space: nowrap;
}
</style>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { asyncTasksApi, type AsyncTaskItem, type AsyncTaskDetail, type AsyncTaskStatsResponse, type AsyncTaskStatus } from '@/api/async-tasks'
@@ -1043,3 +1248,49 @@ onUnmounted(() => {
clearTimeout(filterTimeout)
})
</script>
<style scoped>
.drawer-enter-active,
.drawer-leave-active {
transition: all 0.3s ease;
}
.drawer-enter-active > div:first-child,
.drawer-leave-active > div:first-child {
transition: opacity 0.3s ease;
}
.drawer-enter-active > div:last-child,
.drawer-leave-active > div:last-child {
transition: transform 0.3s ease;
}
.drawer-enter-from,
.drawer-leave-to {
opacity: 0;
}
.drawer-enter-from > div:last-child,
.drawer-leave-to > div:last-child {
transform: translateX(100%);
}
/* 时间范围箭头容器 */
.time-arrow-container {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 0.25rem;
}
.time-arrow {
color: hsl(var(--muted-foreground));
}
.time-duration {
position: absolute;
top: -1rem;
left: 50%;
transform: translateX(-50%);
font-size: 0.65rem;
color: hsl(var(--muted-foreground));
white-space: nowrap;
}
</style>

View File

@@ -2,31 +2,49 @@
<div class="space-y-6 px-4 sm:px-6 lg:px-0">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 class="text-lg font-semibold">成本分析</h1>
<p class="text-xs text-muted-foreground">成本趋势预测与节省统计</p>
<h1 class="text-lg font-semibold">
成本分析
</h1>
<p class="text-xs text-muted-foreground">
成本趋势预测与节省统计
</p>
</div>
<TimeRangePicker v-model="timeRange" />
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<Card class="p-4 space-y-2">
<div class="text-xs text-muted-foreground">缓存节省</div>
<div class="text-lg font-semibold">{{ formatCurrency(costSavings?.cache_savings ?? 0) }}</div>
<div class="text-xs text-muted-foreground">
缓存节省
</div>
<div class="text-lg font-semibold">
{{ formatCurrency(costSavings?.cache_savings ?? 0) }}
</div>
<div class="text-xs text-muted-foreground">
读取成本 {{ formatCurrency(costSavings?.cache_read_cost ?? 0) }}
</div>
</Card>
<Card class="p-4 space-y-2">
<div class="text-xs text-muted-foreground">缓存读取 Tokens</div>
<div class="text-lg font-semibold">{{ formatTokens(costSavings?.cache_read_tokens ?? 0) }}</div>
<div class="text-xs text-muted-foreground">
缓存读取 Tokens
</div>
<div class="text-lg font-semibold">
{{ formatTokens(costSavings?.cache_read_tokens ?? 0) }}
</div>
<div class="text-xs text-muted-foreground">
预计全额成本 {{ formatCurrency(costSavings?.estimated_full_cost ?? 0) }}
</div>
</Card>
<Card class="p-4 space-y-2">
<div class="text-xs text-muted-foreground">缓存创建成本</div>
<div class="text-lg font-semibold">{{ formatCurrency(costSavings?.cache_creation_cost ?? 0) }}</div>
<div class="text-xs text-muted-foreground">基于当前时间范围</div>
<div class="text-xs text-muted-foreground">
缓存创建成本
</div>
<div class="text-lg font-semibold">
{{ formatCurrency(costSavings?.cache_creation_cost ?? 0) }}
</div>
<div class="text-xs text-muted-foreground">
基于当前时间范围
</div>
</Card>
</div>

View File

@@ -2,56 +2,89 @@
<div class="space-y-6 pb-8">
<!-- 统计卡片 -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<Card variant="default" class="p-4">
<Card
variant="default"
class="p-4"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
<FileUp class="w-5 h-5 text-primary" />
</div>
<div>
<p class="text-2xl font-bold">{{ stats?.total_mappings ?? '-' }}</p>
<p class="text-xs text-muted-foreground">总文件数</p>
<p class="text-2xl font-bold">
{{ stats?.total_mappings ?? '-' }}
</p>
<p class="text-xs text-muted-foreground">
总文件数
</p>
</div>
</div>
</Card>
<Card variant="default" class="p-4">
<Card
variant="default"
class="p-4"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-green-500/10 flex items-center justify-center">
<CheckCircle class="w-5 h-5 text-green-500" />
</div>
<div>
<p class="text-2xl font-bold">{{ stats?.active_mappings ?? '-' }}</p>
<p class="text-xs text-muted-foreground">有效文件</p>
<p class="text-2xl font-bold">
{{ stats?.active_mappings ?? '-' }}
</p>
<p class="text-xs text-muted-foreground">
有效文件
</p>
</div>
</div>
</Card>
<Card variant="default" class="p-4">
<Card
variant="default"
class="p-4"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-amber-500/10 flex items-center justify-center">
<Clock class="w-5 h-5 text-amber-500" />
</div>
<div>
<p class="text-2xl font-bold">{{ stats?.expired_mappings ?? '-' }}</p>
<p class="text-xs text-muted-foreground">已过期</p>
<p class="text-2xl font-bold">
{{ stats?.expired_mappings ?? '-' }}
</p>
<p class="text-xs text-muted-foreground">
已过期
</p>
</div>
</div>
</Card>
<Card variant="default" class="p-4">
<Card
variant="default"
class="p-4"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
<Key class="w-5 h-5 text-blue-500" />
</div>
<div>
<p class="text-2xl font-bold">{{ stats?.capable_keys_count ?? '-' }}</p>
<p class="text-xs text-muted-foreground">支持的 Key</p>
<p class="text-2xl font-bold">
{{ stats?.capable_keys_count ?? '-' }}
</p>
<p class="text-xs text-muted-foreground">
支持的 Key
</p>
</div>
</div>
</Card>
</div>
<!-- 上传区域 -->
<Card variant="default" class="p-4">
<Card
variant="default"
class="p-4"
>
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-medium">上传文件</h3>
<h3 class="text-sm font-medium">
上传文件
</h3>
<Button
v-if="capableKeys.length > 0"
variant="ghost"
@@ -64,8 +97,13 @@
</div>
<!-- Key 选择器 -->
<div v-if="capableKeys.length > 0" class="mb-4">
<p class="text-xs text-muted-foreground mb-2">选择要上传到的 Key可多选</p>
<div
v-if="capableKeys.length > 0"
class="mb-4"
>
<p class="text-xs text-muted-foreground mb-2">
选择要上传到的 Key可多选
</p>
<div class="flex flex-wrap gap-2">
<button
v-for="key in capableKeys"
@@ -77,11 +115,17 @@
@click="toggleKeySelection(key.id)"
>
<span class="font-medium">{{ key.name }}</span>
<span v-if="key.provider_name" class="text-muted-foreground ml-1">({{ key.provider_name }})</span>
<span
v-if="key.provider_name"
class="text-muted-foreground ml-1"
>({{ key.provider_name }})</span>
</button>
</div>
</div>
<div v-else class="mb-4 text-sm text-amber-600 bg-amber-50 dark:bg-amber-950/30 rounded-lg p-3">
<div
v-else
class="mb-4 text-sm text-amber-600 bg-amber-50 dark:bg-amber-950/30 rounded-lg p-3"
>
暂无可用的 Key请先配置具有Gemini 文件 API能力的 Key
</div>
@@ -102,12 +146,20 @@
type="file"
class="hidden"
@change="handleFileSelect"
/>
<div v-if="uploading" class="flex flex-col items-center gap-2">
>
<div
v-if="uploading"
class="flex flex-col items-center gap-2"
>
<Loader2 class="w-8 h-8 animate-spin text-primary" />
<p class="text-sm text-muted-foreground">正在上传到 {{ selectedKeyIds.length }} Key...</p>
<p class="text-sm text-muted-foreground">
正在上传到 {{ selectedKeyIds.length }} Key...
</p>
</div>
<div v-else class="flex flex-col items-center gap-2">
<div
v-else
class="flex flex-col items-center gap-2"
>
<Upload class="w-8 h-8 text-muted-foreground" />
<p class="text-sm text-muted-foreground">
<template v-if="selectedKeyIds.length > 0">
@@ -131,8 +183,14 @@
</Card>
<!-- MIME 类型分布 -->
<Card v-if="stats?.by_mime_type && Object.keys(stats.by_mime_type).length > 0" variant="default" class="p-4">
<h3 class="text-sm font-medium mb-3">文件类型分布</h3>
<Card
v-if="stats?.by_mime_type && Object.keys(stats.by_mime_type).length > 0"
variant="default"
class="p-4"
>
<h3 class="text-sm font-medium mb-3">
文件类型分布
</h3>
<div class="flex flex-wrap gap-2">
<Badge
v-for="(count, mimeType) in stats.by_mime_type"
@@ -146,11 +204,16 @@
</Card>
<!-- 文件映射表格 -->
<Card variant="default" class="overflow-hidden">
<Card
variant="default"
class="overflow-hidden"
>
<!-- 标题和筛选器 -->
<div class="px-4 sm:px-6 py-3.5 border-b border-border/60">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h3 class="text-base font-semibold">文件映射</h3>
<h3 class="text-base font-semibold">
文件映射
</h3>
<div class="flex items-center gap-2">
<!-- 搜索 -->
<Input
@@ -165,7 +228,7 @@
v-model="includeExpired"
type="checkbox"
class="rounded border-border"
/>
>
包含过期
</label>
<!-- 清理过期按钮 -->
@@ -187,29 +250,45 @@
:disabled="loading"
@click="fetchData"
>
<RefreshCw class="w-3.5 h-3.5" :class="{ 'animate-spin': loading }" />
<RefreshCw
class="w-3.5 h-3.5"
:class="{ 'animate-spin': loading }"
/>
</Button>
</div>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading && !mappings.length" class="p-8 text-center">
<div
v-if="loading && !mappings.length"
class="p-8 text-center"
>
<Loader2 class="w-8 h-8 animate-spin mx-auto text-muted-foreground" />
<p class="mt-2 text-sm text-muted-foreground">加载中...</p>
<p class="mt-2 text-sm text-muted-foreground">
加载中...
</p>
</div>
<!-- 空状态 -->
<div v-else-if="!mappings.length" class="p-8 text-center">
<div
v-else-if="!mappings.length"
class="p-8 text-center"
>
<FileUp class="w-12 h-12 mx-auto text-muted-foreground/50" />
<p class="mt-2 text-sm text-muted-foreground">暂无文件映射</p>
<p class="mt-2 text-sm text-muted-foreground">
暂无文件映射
</p>
<p class="mt-1 text-xs text-muted-foreground">
用户通过 Gemini Files API 上传文件后会在此显示
</p>
</div>
<!-- 文件列表 -->
<div v-else class="divide-y divide-border/60">
<div
v-else
class="divide-y divide-border/60"
>
<div
v-for="mapping in mappings"
:key="mapping.id"
@@ -220,30 +299,53 @@
<div class="flex-1 min-w-0">
<!-- 文件名和状态 -->
<div class="flex items-center gap-2 mb-1">
<component :is="getFileIcon(mapping.mime_type)" class="w-4 h-4 text-muted-foreground" />
<component
:is="getFileIcon(mapping.mime_type)"
class="w-4 h-4 text-muted-foreground"
/>
<span class="font-mono text-sm font-medium">{{ mapping.file_name }}</span>
<Badge v-if="mapping.is_expired" variant="secondary" class="text-xs">
<Badge
v-if="mapping.is_expired"
variant="secondary"
class="text-xs"
>
已过期
</Badge>
<Badge v-else variant="outline" class="text-xs text-green-600">
<Badge
v-else
variant="outline"
class="text-xs text-green-600"
>
有效
</Badge>
</div>
<!-- 显示名 -->
<p v-if="mapping.display_name" class="text-sm text-muted-foreground truncate">
<p
v-if="mapping.display_name"
class="text-sm text-muted-foreground truncate"
>
{{ mapping.display_name }}
</p>
<!-- 元信息 -->
<div class="flex items-center gap-4 mt-2 text-xs text-muted-foreground">
<span v-if="mapping.mime_type" class="flex items-center gap-1">
<span
v-if="mapping.mime_type"
class="flex items-center gap-1"
>
<File class="w-3 h-3" />
{{ mapping.mime_type }}
</span>
<span v-if="mapping.username" class="flex items-center gap-1">
<span
v-if="mapping.username"
class="flex items-center gap-1"
>
<User class="w-3 h-3" />
{{ mapping.username }}
</span>
<span v-if="mapping.key_name" class="flex items-center gap-1">
<span
v-if="mapping.key_name"
class="flex items-center gap-1"
>
<Key class="w-3 h-3" />
{{ mapping.key_name }}
</span>
@@ -251,7 +353,10 @@
<Clock class="w-3 h-3" />
{{ formatDate(mapping.created_at) }}
</span>
<span class="flex items-center gap-1" :class="{ 'text-red-500': mapping.is_expired }">
<span
class="flex items-center gap-1"
:class="{ 'text-red-500': mapping.is_expired }"
>
<Timer class="w-3 h-3" />
过期: {{ formatDate(mapping.expires_at) }}
</span>
@@ -274,7 +379,10 @@
</div>
<!-- 分页 -->
<div v-if="totalPages > 1" class="px-4 sm:px-6 py-3 border-t border-border/60 flex items-center justify-between">
<div
v-if="totalPages > 1"
class="px-4 sm:px-6 py-3 border-t border-border/60 flex items-center justify-between"
>
<p class="text-xs text-muted-foreground">
{{ total }} 条记录
</p>

View File

@@ -2,8 +2,12 @@
<div class="space-y-6 px-4 sm:px-6 lg:px-0">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 class="text-lg font-semibold">性能分析</h1>
<p class="text-xs text-muted-foreground">延迟分布与错误统计</p>
<h1 class="text-lg font-semibold">
性能分析
</h1>
<p class="text-xs text-muted-foreground">
延迟分布与错误统计
</p>
</div>
<TimeRangePicker v-model="timeRange" />
</div>
@@ -36,22 +40,38 @@
/>
</Card>
<Card class="p-4 space-y-3">
<h3 class="text-sm font-semibold">错误趋势</h3>
<div v-if="errorLoading" class="p-6">
<h3 class="text-sm font-semibold">
错误趋势
</h3>
<div
v-if="errorLoading"
class="p-6"
>
<LoadingState />
</div>
<div v-else class="h-[260px]">
<div
v-else
class="h-[260px]"
>
<LineChart :data="errorTrendChartData" />
</div>
</Card>
</div>
<Card class="p-4 space-y-3">
<h3 class="text-sm font-semibold">提供商健康度</h3>
<div v-if="providerLoading" class="p-4">
<h3 class="text-sm font-semibold">
提供商健康度
</h3>
<div
v-if="providerLoading"
class="p-4"
>
<LoadingState />
</div>
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 text-sm">
<div
v-else
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 text-sm"
>
<div
v-for="provider in providerStatus"
:key="provider.name"

View File

@@ -2,12 +2,22 @@
<div class="space-y-6 px-4 sm:px-6 lg:px-0">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 class="text-lg font-semibold">用户统计</h1>
<p class="text-xs text-muted-foreground">查看用户排行榜与使用趋势</p>
<h1 class="text-lg font-semibold">
用户统计
</h1>
<p class="text-xs text-muted-foreground">
查看用户排行榜与使用趋势
</p>
</div>
<div class="flex flex-wrap items-center gap-3">
<TimeRangePicker v-model="timeRange" :allow-hourly="true" />
<Select v-model:open="userSelectOpen" v-model="selectedUserId">
<TimeRangePicker
v-model="timeRange"
:allow-hourly="true"
/>
<Select
v-model:open="userSelectOpen"
v-model="selectedUserId"
>
<SelectTrigger class="h-8 text-xs w-52">
<SelectValue placeholder="选择用户" />
</SelectTrigger>
@@ -21,12 +31,17 @@
</SelectItem>
</SelectContent>
</Select>
<Select v-model:open="compareUserSelectOpen" v-model="compareUserId">
<Select
v-model:open="compareUserSelectOpen"
v-model="compareUserId"
>
<SelectTrigger class="h-8 text-xs w-52">
<SelectValue placeholder="对比用户(可选)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">不对比</SelectItem>
<SelectItem value="__none__">
不对比
</SelectItem>
<SelectItem
v-for="user in users"
:key="`compare-${user.id}`"
@@ -49,43 +64,80 @@
/>
<Card class="p-4 space-y-3">
<h3 class="text-sm font-semibold">用户摘要</h3>
<div v-if="summaryLoading" class="p-6">
<h3 class="text-sm font-semibold">
用户摘要
</h3>
<div
v-if="summaryLoading"
class="p-6"
>
<LoadingState />
</div>
<div v-else class="grid grid-cols-2 gap-3 text-sm">
<div
v-else
class="grid grid-cols-2 gap-3 text-sm"
>
<div>
<div class="text-xs text-muted-foreground">请求数</div>
<div class="font-semibold">{{ userSummary?.total_requests ?? 0 }}</div>
<div class="text-xs text-muted-foreground">
请求数
</div>
<div class="font-semibold">
{{ userSummary?.total_requests ?? 0 }}
</div>
</div>
<div>
<div class="text-xs text-muted-foreground">Tokens</div>
<div class="font-semibold">{{ formatTokens(userSummary?.total_tokens ?? 0) }}</div>
<div class="text-xs text-muted-foreground">
Tokens
</div>
<div class="font-semibold">
{{ formatTokens(userSummary?.total_tokens ?? 0) }}
</div>
</div>
<div>
<div class="text-xs text-muted-foreground">成本</div>
<div class="font-semibold">{{ formatCurrency(userSummary?.total_cost ?? 0) }}</div>
<div class="text-xs text-muted-foreground">
成本
</div>
<div class="font-semibold">
{{ formatCurrency(userSummary?.total_cost ?? 0) }}
</div>
</div>
<div>
<div class="text-xs text-muted-foreground">错误率</div>
<div class="font-semibold">{{ userSummary?.error_rate ?? 0 }}%</div>
<div class="text-xs text-muted-foreground">
错误率
</div>
<div class="font-semibold">
{{ userSummary?.error_rate ?? 0 }}%
</div>
</div>
</div>
</Card>
</div>
<Card class="p-4 space-y-4">
<h3 class="text-sm font-semibold">用户使用趋势</h3>
<div v-if="seriesLoading" class="p-6">
<h3 class="text-sm font-semibold">
用户使用趋势
</h3>
<div
v-if="seriesLoading"
class="p-6"
>
<LoadingState />
</div>
<div v-else class="h-[280px]">
<div
v-else
class="h-[280px]"
>
<LineChart :data="seriesChartData" />
</div>
</Card>
<Card v-if="comparisonSeries.length > 0" class="p-4 space-y-4">
<h3 class="text-sm font-semibold">用户对比趋势</h3>
<Card
v-if="comparisonSeries.length > 0"
class="p-4 space-y-4"
>
<h3 class="text-sm font-semibold">
用户对比趋势
</h3>
<div class="h-[280px]">
<LineChart :data="comparisonChartData" />
</div>

View File

@@ -247,7 +247,10 @@
class="mb-6 text-3xl sm:text-5xl md:text-7xl font-bold text-[#191919] dark:text-white leading-tight transition-all duration-700"
:style="getTitleStyle(SECTIONS.HOME)"
>
欢迎使用 <span class="text-primary typewriter">{{ aetherText }}<span class="cursor" :class="{ 'cursor-hidden': !showCursor }">_</span></span>
欢迎使用 <span class="text-primary typewriter">{{ aetherText }}<span
class="cursor"
:class="{ 'cursor-hidden': !showCursor }"
>_</span></span>
</h1>
<p
class="mb-8 text-base sm:text-lg md:text-xl text-[#666663] dark:text-[#c9c3b4] max-w-2xl mx-auto transition-all duration-700"
@@ -568,7 +571,7 @@ const aetherText = ref('')
const showCursor = ref(true)
const typewriterFullText = 'Aether'
let typewriterTimer: ReturnType<typeof setTimeout> | null = null
let hasTypewriterStarted = ref(false)
const hasTypewriterStarted = ref(false)
const startTypewriter = () => {
if (hasTypewriterStarted.value) return

View File

@@ -88,12 +88,17 @@ const systemSettings = [
格式转换
</h2>
<div :class="[panelClasses.section, 'p-5']">
<div
class="p-5"
:class="[panelClasses.section]"
>
<div class="flex items-center gap-3 mb-4">
<div class="p-2 rounded-lg bg-purple-500/10">
<Shuffle class="h-5 w-5 text-purple-500" />
</div>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">什么是格式转换</h3>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
什么是格式转换
</h3>
</div>
<p class="text-sm text-[#666663] dark:text-[#a3a094] mb-4">
@@ -112,21 +117,30 @@ const systemSettings = [
<ArrowRight class="h-4 w-4 text-[#999]" />
<span :class="panelClasses.badgeGreen">{{ example.to }}</span>
</div>
<p class="text-xs text-[#666663] dark:text-[#a3a094]">{{ example.description }}</p>
<p class="text-xs text-[#666663] dark:text-[#a3a094]">
{{ example.description }}
</p>
</div>
</div>
</div>
<!-- 如何启用 -->
<div :class="[panelClasses.section, 'p-5 space-y-4']">
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">如何启用格式转换</h3>
<div
class="p-5 space-y-4"
:class="[panelClasses.section]"
>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
如何启用格式转换
</h3>
<div class="flex items-start gap-4">
<div class="w-8 h-8 rounded-full bg-[#cc785c] flex items-center justify-center text-white font-bold text-sm flex-shrink-0">
1
</div>
<div>
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">开启系统设置</p>
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">
开启系统设置
</p>
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-1">
系统设置中开启 <code class="text-xs bg-[#f5f5f0] dark:bg-[#1f1d1a] px-1.5 py-0.5 rounded">ENABLE_API_FORMAT_CONVERSION</code>
</p>
@@ -138,7 +152,9 @@ const systemSettings = [
2
</div>
<div>
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">配置端点</p>
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">
配置端点
</p>
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-1">
在端点配置中启用格式接受配置并选择接受的入站格式
</p>
@@ -150,7 +166,9 @@ const systemSettings = [
3
</div>
<div>
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">使用</p>
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">
使用
</p>
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-1">
用户现在可以用 OpenAI SDK 调用配置在 Claude 格式端点上的模型
</p>
@@ -158,11 +176,16 @@ const systemSettings = [
</div>
</div>
<div :class="[panelClasses.section, 'p-4']">
<div
class="p-4"
:class="[panelClasses.section]"
>
<div class="flex items-start gap-3">
<Info class="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
<div class="text-sm text-[#666663] dark:text-[#a3a094]">
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">注意事项</p>
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">
注意事项
</p>
<p class="mt-1">
格式转换会增加少量延迟通常 &lt;10ms部分特有功能 Claude thinkingOpenAI function calling
可能无法完美转换建议在实际场景中测试
@@ -178,25 +201,37 @@ const systemSettings = [
请求头规则
</h2>
<div :class="[panelClasses.section, 'p-5']">
<div
class="p-5"
:class="[panelClasses.section]"
>
<div class="flex items-center gap-3 mb-4">
<div class="p-2 rounded-lg bg-green-500/10">
<FileCode class="h-5 w-5 text-green-500" />
</div>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">什么是请求头规则</h3>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
什么是请求头规则
</h3>
</div>
<p class="text-sm text-[#666663] dark:text-[#a3a094] mb-4">
请求头规则允许你在转发请求时修改 HTTP 请求头可以添加修改或删除特定的请求头
</p>
<div :class="[panelClasses.section, 'overflow-hidden']">
<div
class="overflow-hidden"
:class="[panelClasses.section]"
>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-[#e5e4df] dark:border-[rgba(227,224,211,0.12)] bg-[#fafaf7]/50 dark:bg-[#1f1d1a]/50">
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">类型</th>
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">说明</th>
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
类型
</th>
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
说明
</th>
</tr>
</thead>
<tbody>
@@ -218,11 +253,16 @@ const systemSettings = [
</div>
</div>
<div :class="[panelClasses.section, 'p-4']">
<div
class="p-4"
:class="[panelClasses.section]"
>
<div class="flex items-start gap-3">
<Info class="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
<div class="text-sm text-[#666663] dark:text-[#a3a094]">
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">使用场景</p>
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">
使用场景
</p>
<ul class="mt-1 space-y-1">
<li> 添加额外的认证信息 API 版本号</li>
<li> 添加跟踪标记如请求 ID来源标识</li>
@@ -239,12 +279,17 @@ const systemSettings = [
代理设置
</h2>
<div :class="[panelClasses.section, 'p-5']">
<div
class="p-5"
:class="[panelClasses.section]"
>
<div class="flex items-center gap-3 mb-4">
<div class="p-2 rounded-lg bg-blue-500/10">
<Globe class="h-5 w-5 text-blue-500" />
</div>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">HTTP 代理</h3>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
HTTP 代理
</h3>
</div>
<p class="text-sm text-[#666663] dark:text-[#a3a094] mb-4">
@@ -253,14 +298,18 @@ const systemSettings = [
<div class="space-y-3">
<div class="p-4 rounded-lg bg-[#f5f5f0]/50 dark:bg-[#1f1d1a]/50">
<h4 class="font-medium text-[#262624] dark:text-[#f1ead8]">端点级代理</h4>
<h4 class="font-medium text-[#262624] dark:text-[#f1ead8]">
端点级代理
</h4>
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-1">
在端点配置中填写代理地址格式如 <code class="text-xs bg-[#f5f5f0] dark:bg-[#1f1d1a] px-1.5 py-0.5 rounded">http://proxy:8080</code>
</p>
</div>
<div class="p-4 rounded-lg bg-[#f5f5f0]/50 dark:bg-[#1f1d1a]/50">
<h4 class="font-medium text-[#262624] dark:text-[#f1ead8]">全局代理</h4>
<h4 class="font-medium text-[#262624] dark:text-[#f1ead8]">
全局代理
</h4>
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-1">
也可以通过环境变量 <code class="text-xs bg-[#f5f5f0] dark:bg-[#1f1d1a] px-1.5 py-0.5 rounded">HTTP_PROXY</code> 设置全局代理
</p>
@@ -283,11 +332,14 @@ const systemSettings = [
<div
v-for="category in systemSettings"
:key="category.category"
:class="[panelClasses.section, 'p-4']"
class="p-4"
:class="[panelClasses.section]"
>
<div class="flex items-center gap-2 mb-3">
<Settings class="h-4 w-4 text-[#cc785c]" />
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">{{ category.category }}</h3>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
{{ category.category }}
</h3>
</div>
<ul class="space-y-2">
<li
@@ -309,12 +361,17 @@ const systemSettings = [
健康监控
</h2>
<div :class="[panelClasses.section, 'p-5']">
<div
class="p-5"
:class="[panelClasses.section]"
>
<div class="flex items-center gap-3 mb-4">
<div class="p-2 rounded-lg bg-green-500/10">
<Shield class="h-5 w-5 text-green-500" />
</div>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">端点健康检查</h3>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
端点健康检查
</h3>
</div>
<ul class="space-y-3 text-sm text-[#666663] dark:text-[#a3a094]">
@@ -337,11 +394,16 @@ const systemSettings = [
</ul>
</div>
<div :class="[panelClasses.section, 'p-4']">
<div
class="p-4"
:class="[panelClasses.section]"
>
<div class="flex items-start gap-3">
<AlertTriangle class="h-5 w-5 text-yellow-500 flex-shrink-0 mt-0.5" />
<div class="text-sm text-[#666663] dark:text-[#a3a094]">
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">端点显示不健康</p>
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">
端点显示不健康
</p>
<p class="mt-1">
检查1) API URL 是否正确2) API Key 是否有效3) 网络是否可达4) 是否需要配置代理
</p>
@@ -354,11 +416,16 @@ const systemSettings = [
<section class="pt-4">
<RouterLink
to="/guide/faq"
:class="[panelClasses.section, panelClasses.cardHover, 'p-4 flex items-center gap-3 group']"
class="p-4 flex items-center gap-3 group"
:class="[panelClasses.section, panelClasses.cardHover]"
>
<div class="flex-1">
<div class="font-medium text-[#262624] dark:text-[#f1ead8]">常见问题</div>
<div class="text-sm text-[#666663] dark:text-[#a3a094]">查看使用中的常见问题和解答</div>
<div class="font-medium text-[#262624] dark:text-[#f1ead8]">
常见问题
</div>
<div class="text-sm text-[#666663] dark:text-[#a3a094]">
查看使用中的常见问题和解答
</div>
</div>
<ArrowRight class="h-5 w-5 text-[#999] group-hover:text-[#cc785c] transition-colors" />
</RouterLink>

View File

@@ -81,7 +81,10 @@ function toggleAll() {
</div>
<!-- 搜索栏 -->
<div :class="[panelClasses.section, 'p-4']">
<div
class="p-4"
:class="[panelClasses.section]"
>
<div class="flex items-center gap-3">
<Search class="h-5 w-5 text-[#999]" />
<input
@@ -89,7 +92,7 @@ function toggleAll() {
type="text"
placeholder="搜索问题..."
class="flex-1 bg-transparent border-none outline-none text-[#262624] dark:text-[#f1ead8] placeholder:text-[#999]"
/>
>
<button
v-if="filteredFaqs.length > 0"
class="text-sm text-[#cc785c] hover:underline"
@@ -101,7 +104,10 @@ function toggleAll() {
</div>
<!-- FAQ 列表 -->
<div v-if="filteredFaqs.length > 0" class="space-y-6">
<div
v-if="filteredFaqs.length > 0"
class="space-y-6"
>
<div
v-for="category in Object.keys(faqsByCategory)"
:key="category"
@@ -116,7 +122,8 @@ function toggleAll() {
<div
v-for="faq in faqsByCategory[category]"
:key="faq.id"
:class="[panelClasses.section, 'overflow-hidden']"
class="overflow-hidden"
:class="[panelClasses.section]"
>
<button
class="w-full px-4 py-3 flex items-center justify-between text-left hover:bg-[#f5f5f0]/50 dark:hover:bg-[#1f1d1a]/50 transition-colors"
@@ -144,7 +151,8 @@ function toggleAll() {
<!-- 无结果 -->
<div
v-else
:class="[panelClasses.section, 'p-8 text-center']"
class="p-8 text-center"
:class="[panelClasses.section]"
>
<HelpCircle class="h-12 w-12 text-[#999] mx-auto mb-4" />
<p class="text-[#666663] dark:text-[#a3a094]">
@@ -163,11 +171,16 @@ function toggleAll() {
href="https://github.com/your-repo/aether"
target="_blank"
rel="noopener noreferrer"
:class="[panelClasses.section, panelClasses.cardHover, 'p-4 flex items-center gap-3 group']"
class="p-4 flex items-center gap-3 group"
:class="[panelClasses.section, panelClasses.cardHover]"
>
<div class="p-2 rounded-lg bg-gray-500/10">
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
<svg
class="h-5 w-5"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
</div>
<div class="flex-1">
@@ -181,11 +194,18 @@ function toggleAll() {
href="https://github.com/your-repo/aether/discussions"
target="_blank"
rel="noopener noreferrer"
:class="[panelClasses.section, panelClasses.cardHover, 'p-4 flex items-center gap-3 group']"
class="p-4 flex items-center gap-3 group"
:class="[panelClasses.section, panelClasses.cardHover]"
>
<div class="p-2 rounded-lg bg-blue-500/10">
<svg class="h-5 w-5 text-blue-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
<svg
class="h-5 w-5 text-blue-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
</div>
<div class="flex-1">

View File

@@ -164,7 +164,7 @@
type="text"
class="w-full px-3 py-2 text-sm rounded-lg border border-[#e5e4df] dark:border-[rgba(227,224,211,0.12)] bg-white dark:bg-[#1f1d1a] text-[#191919] dark:text-white placeholder-[#91918d] focus:outline-none focus:ring-2 focus:ring-[#cc785c]/30"
placeholder="https://your-aether.com"
/>
>
<p class="mt-1.5 text-xs text-[#91918d]">
代码示例将使用此 URL
</p>
@@ -176,7 +176,10 @@
<main class="flex-1 min-w-0">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8 md:py-12">
<RouterView v-slot="{ Component }">
<component :is="Component" :base-url="baseUrl" />
<component
:is="Component"
:base-url="baseUrl"
/>
</RouterView>
</div>
</main>

View File

@@ -50,12 +50,17 @@ const lbIcons = {
什么是模型
</h2>
<div :class="[panelClasses.section, 'p-5']">
<div
class="p-5"
:class="[panelClasses.section]"
>
<div class="flex items-center gap-3 mb-4">
<div class="p-2 rounded-lg bg-purple-500/10">
<Layers class="h-5 w-5 text-purple-500" />
</div>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">模型 (Model)</h3>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
模型 (Model)
</h3>
</div>
<ul class="space-y-3 text-sm text-[#666663] dark:text-[#a3a094]">
@@ -78,11 +83,16 @@ const lbIcons = {
</ul>
</div>
<div :class="[panelClasses.section, 'p-4']">
<div
class="p-4"
:class="[panelClasses.section]"
>
<div class="flex items-start gap-3">
<Info class="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
<div class="text-sm text-[#666663] dark:text-[#a3a094]">
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">示例场景</p>
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">
示例场景
</p>
<p class="mt-1">
用户请求模型 <code class="text-xs bg-[#f5f5f0] dark:bg-[#1f1d1a] px-1.5 py-0.5 rounded">gpt-4</code>
你可以配置它同时使用 OpenAI 官方端点和某个代理端点
@@ -99,13 +109,18 @@ const lbIcons = {
创建模型
</h2>
<div :class="[panelClasses.section, 'p-5 space-y-4']">
<div
class="p-5 space-y-4"
:class="[panelClasses.section]"
>
<div class="flex items-start gap-4">
<div class="w-8 h-8 rounded-full bg-[#cc785c] flex items-center justify-center text-white font-bold text-sm flex-shrink-0">
1
</div>
<div>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">进入模型管理页面</h3>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
进入模型管理页面
</h3>
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-1">
在管理后台左侧菜单点击模型管理
</p>
@@ -117,7 +132,9 @@ const lbIcons = {
2
</div>
<div>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">添加模型</h3>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
添加模型
</h3>
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-1">
点击添加模型填写模型名称这是用户调用时使用的名称
</p>
@@ -129,7 +146,9 @@ const lbIcons = {
3
</div>
<div>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">关联端点</h3>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
关联端点
</h3>
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-1">
选择该模型要使用的端点可以选择多个如果端点的模型名与你定义的不同需要设置目标模型名映射
</p>
@@ -141,7 +160,9 @@ const lbIcons = {
4
</div>
<div>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">配置负载均衡</h3>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
配置负载均衡
</h3>
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-1">
如果选择了多个端点可以配置负载均衡策略
</p>
@@ -156,14 +177,23 @@ const lbIcons = {
配置字段说明
</h2>
<div :class="[panelClasses.section, 'overflow-hidden']">
<div
class="overflow-hidden"
:class="[panelClasses.section]"
>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-[#e5e4df] dark:border-[rgba(227,224,211,0.12)] bg-[#fafaf7]/50 dark:bg-[#1f1d1a]/50">
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">字段</th>
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">说明</th>
<th class="px-4 py-3 text-center font-medium text-[#666663] dark:text-[#a3a094]">必填</th>
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
字段
</th>
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
说明
</th>
<th class="px-4 py-3 text-center font-medium text-[#666663] dark:text-[#a3a094]">
必填
</th>
</tr>
</thead>
<tbody>
@@ -179,8 +209,14 @@ const lbIcons = {
{{ field.description }}
</td>
<td class="px-4 py-3 text-center">
<span v-if="field.required" :class="panelClasses.badgeGreen">必填</span>
<span v-else class="text-[#999]">可选</span>
<span
v-if="field.required"
:class="panelClasses.badgeGreen"
>必填</span>
<span
v-else
class="text-[#999]"
>可选</span>
</td>
</tr>
</tbody>
@@ -202,26 +238,36 @@ const lbIcons = {
<div
v-for="lb in loadBalanceModes"
:key="lb.mode"
:class="[panelClasses.section, 'p-4']"
class="p-4"
:class="[panelClasses.section]"
>
<div class="flex items-center gap-3 mb-2">
<component
:is="lbIcons[lb.mode as keyof typeof lbIcons] || Shuffle"
class="h-5 w-5 text-[#cc785c]"
/>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">{{ lb.name }}</h3>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
{{ lb.name }}
</h3>
</div>
<p class="text-sm text-[#666663] dark:text-[#a3a094]">{{ lb.description }}</p>
<p class="text-sm text-[#666663] dark:text-[#a3a094]">
{{ lb.description }}
</p>
</div>
</div>
<div :class="[panelClasses.section, 'p-4']">
<div
class="p-4"
:class="[panelClasses.section]"
>
<div class="flex items-start gap-3">
<Info class="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
<div class="text-sm text-[#666663] dark:text-[#a3a094]">
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">推荐配置</p>
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">
推荐配置
</p>
<p class="mt-1">
<strong>主备切换场景</strong>使用优先级模式将官方端点设为高优先级代理端点设为低优先级<br />
<strong>主备切换场景</strong>使用优先级模式将官方端点设为高优先级代理端点设为低优先级<br>
<strong>分摊负载场景</strong>使用轮询加权模式将多个同质端点平均分配请求
</p>
</div>
@@ -235,7 +281,10 @@ const lbIcons = {
模型名映射
</h2>
<div :class="[panelClasses.section, 'p-5']">
<div
class="p-5"
:class="[panelClasses.section]"
>
<p class="text-sm text-[#666663] dark:text-[#a3a094] mb-4">
有时候你希望用户调用的模型名与实际发给端点的模型名不同例如
</p>
@@ -278,7 +327,10 @@ const lbIcons = {
模型别名
</h2>
<div :class="[panelClasses.section, 'p-5']">
<div
class="p-5"
:class="[panelClasses.section]"
>
<p class="text-sm text-[#666663] dark:text-[#a3a094] mb-4">
别名让用户可以用多个名称访问同一个模型适用于
</p>
@@ -304,11 +356,16 @@ const lbIcons = {
<section class="pt-4">
<RouterLink
to="/guide/user-key"
:class="[panelClasses.section, panelClasses.cardHover, 'p-4 flex items-center gap-3 group']"
class="p-4 flex items-center gap-3 group"
:class="[panelClasses.section, panelClasses.cardHover]"
>
<div class="flex-1">
<div class="font-medium text-[#262624] dark:text-[#f1ead8]">下一步用户与密钥</div>
<div class="text-sm text-[#666663] dark:text-[#a3a094]">管理用户和 API Key</div>
<div class="font-medium text-[#262624] dark:text-[#f1ead8]">
下一步用户与密钥
</div>
<div class="text-sm text-[#666663] dark:text-[#a3a094]">
管理用户和 API Key
</div>
</div>
<ArrowRight class="h-5 w-5 text-[#999] group-hover:text-[#cc785c] transition-colors" />
</RouterLink>

View File

@@ -61,16 +61,16 @@ const conceptIconColors = {
<div
v-for="concept in coreConcepts"
:key="concept.name"
class="p-4 rounded-xl border-2 transition-all"
:class="[
'p-4 rounded-xl border-2 transition-all',
conceptColors[concept.color as keyof typeof conceptColors]
]"
>
<div class="flex items-start gap-3">
<component
:is="conceptIcons[concept.color as keyof typeof conceptIcons]"
class="h-6 w-6 flex-shrink-0 mt-0.5"
:class="[
'h-6 w-6 flex-shrink-0 mt-0.5',
conceptIconColors[concept.color as keyof typeof conceptIconColors]
]"
/>
@@ -87,7 +87,10 @@ const conceptIconColors = {
</div>
<!-- 关系图 -->
<div :class="[panelClasses.section, 'p-6 mt-6']">
<div
class="p-6 mt-6"
:class="[panelClasses.section]"
>
<h3 class="text-sm font-medium text-[#666663] dark:text-[#a3a094] mb-4">
它们之间的关系
</h3>
@@ -134,7 +137,8 @@ const conceptIconColors = {
<div
v-for="step in configSteps"
:key="step.step"
:class="[panelClasses.section, 'p-4 relative']"
class="p-4 relative"
:class="[panelClasses.section]"
>
<div class="absolute -top-3 -left-2 w-8 h-8 rounded-full bg-[#cc785c] flex items-center justify-center text-white font-bold text-sm">
{{ step.step }}
@@ -160,15 +164,26 @@ const conceptIconColors = {
Aether 支持多种 API 格式可以作为不同客户端的统一入口
</p>
<div :class="[panelClasses.section, 'overflow-hidden']">
<div
class="overflow-hidden"
:class="[panelClasses.section]"
>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-[#e5e4df] dark:border-[rgba(227,224,211,0.12)] bg-[#fafaf7]/50 dark:bg-[#1f1d1a]/50">
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">格式</th>
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">端点</th>
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">认证方式</th>
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">常用客户端</th>
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
格式
</th>
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
端点
</th>
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
认证方式
</th>
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
常用客户端
</th>
</tr>
</thead>
<tbody>
@@ -204,36 +219,51 @@ const conceptIconColors = {
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<RouterLink
to="/guide/provider"
:class="[panelClasses.section, panelClasses.cardHover, 'p-4 flex items-center gap-3 group']"
class="p-4 flex items-center gap-3 group"
:class="[panelClasses.section, panelClasses.cardHover]"
>
<Server class="h-5 w-5 text-[#cc785c]" />
<div class="flex-1">
<div class="font-medium text-[#262624] dark:text-[#f1ead8]">供应商管理</div>
<div class="text-sm text-[#666663] dark:text-[#a3a094]">添加和配置 API 供应商</div>
<div class="font-medium text-[#262624] dark:text-[#f1ead8]">
供应商管理
</div>
<div class="text-sm text-[#666663] dark:text-[#a3a094]">
添加和配置 API 供应商
</div>
</div>
<ArrowRight class="h-5 w-5 text-[#999] group-hover:text-[#cc785c] transition-colors" />
</RouterLink>
<RouterLink
to="/guide/model"
:class="[panelClasses.section, panelClasses.cardHover, 'p-4 flex items-center gap-3 group']"
class="p-4 flex items-center gap-3 group"
:class="[panelClasses.section, panelClasses.cardHover]"
>
<Layers class="h-5 w-5 text-[#cc785c]" />
<div class="flex-1">
<div class="font-medium text-[#262624] dark:text-[#f1ead8]">模型管理</div>
<div class="text-sm text-[#666663] dark:text-[#a3a094]">配置模型和负载均衡</div>
<div class="font-medium text-[#262624] dark:text-[#f1ead8]">
模型管理
</div>
<div class="text-sm text-[#666663] dark:text-[#a3a094]">
配置模型和负载均衡
</div>
</div>
<ArrowRight class="h-5 w-5 text-[#999] group-hover:text-[#cc785c] transition-colors" />
</RouterLink>
<RouterLink
to="/guide/user-key"
:class="[panelClasses.section, panelClasses.cardHover, 'p-4 flex items-center gap-3 group']"
class="p-4 flex items-center gap-3 group"
:class="[panelClasses.section, panelClasses.cardHover]"
>
<Key class="h-5 w-5 text-[#cc785c]" />
<div class="flex-1">
<div class="font-medium text-[#262624] dark:text-[#f1ead8]">用户与密钥</div>
<div class="text-sm text-[#666663] dark:text-[#a3a094]">管理用户和 API Key</div>
<div class="font-medium text-[#262624] dark:text-[#f1ead8]">
用户与密钥
</div>
<div class="text-sm text-[#666663] dark:text-[#a3a094]">
管理用户和 API Key
</div>
</div>
<ArrowRight class="h-5 w-5 text-[#999] group-hover:text-[#cc785c] transition-colors" />
</RouterLink>

View File

@@ -84,12 +84,17 @@ const providerExamples = [
</h2>
<div class="grid gap-4 md:grid-cols-2">
<div :class="[panelClasses.section, 'p-5']">
<div
class="p-5"
:class="[panelClasses.section]"
>
<div class="flex items-center gap-3 mb-3">
<div class="p-2 rounded-lg bg-blue-500/10">
<Server class="h-5 w-5 text-blue-500" />
</div>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">供应商 (Provider)</h3>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
供应商 (Provider)
</h3>
</div>
<ul class="space-y-2 text-sm text-[#666663] dark:text-[#a3a094]">
<li class="flex items-start gap-2">
@@ -107,12 +112,17 @@ const providerExamples = [
</ul>
</div>
<div :class="[panelClasses.section, 'p-5']">
<div
class="p-5"
:class="[panelClasses.section]"
>
<div class="flex items-center gap-3 mb-3">
<div class="p-2 rounded-lg bg-green-500/10">
<Settings class="h-5 w-5 text-green-500" />
</div>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">端点 (Endpoint)</h3>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
端点 (Endpoint)
</h3>
</div>
<ul class="space-y-2 text-sm text-[#666663] dark:text-[#a3a094]">
<li class="flex items-start gap-2">
@@ -138,13 +148,18 @@ const providerExamples = [
添加供应商
</h2>
<div :class="[panelClasses.section, 'p-5 space-y-4']">
<div
class="p-5 space-y-4"
:class="[panelClasses.section]"
>
<div class="flex items-start gap-4">
<div class="w-8 h-8 rounded-full bg-[#cc785c] flex items-center justify-center text-white font-bold text-sm flex-shrink-0">
1
</div>
<div>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">进入供应商管理页面</h3>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
进入供应商管理页面
</h3>
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-1">
登录管理后台在左侧菜单点击供应商管理
</p>
@@ -156,7 +171,9 @@ const providerExamples = [
2
</div>
<div>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">创建供应商</h3>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
创建供应商
</h3>
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-1">
点击添加供应商按钮填写供应商名称 OpenAIAnthropic
</p>
@@ -168,7 +185,9 @@ const providerExamples = [
3
</div>
<div>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">添加端点</h3>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
添加端点
</h3>
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-1">
在供应商下点击添加端点填写 API URL密钥等配置
</p>
@@ -183,14 +202,23 @@ const providerExamples = [
端点配置字段
</h2>
<div :class="[panelClasses.section, 'overflow-hidden']">
<div
class="overflow-hidden"
:class="[panelClasses.section]"
>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-[#e5e4df] dark:border-[rgba(227,224,211,0.12)] bg-[#fafaf7]/50 dark:bg-[#1f1d1a]/50">
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">字段</th>
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">说明</th>
<th class="px-4 py-3 text-center font-medium text-[#666663] dark:text-[#a3a094]">必填</th>
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
字段
</th>
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
说明
</th>
<th class="px-4 py-3 text-center font-medium text-[#666663] dark:text-[#a3a094]">
必填
</th>
</tr>
</thead>
<tbody>
@@ -206,8 +234,14 @@ const providerExamples = [
{{ field.description }}
</td>
<td class="px-4 py-3 text-center">
<span v-if="field.required" :class="panelClasses.badgeGreen">必填</span>
<span v-else class="text-[#999]">可选</span>
<span
v-if="field.required"
:class="panelClasses.badgeGreen"
>必填</span>
<span
v-else
class="text-[#999]"
>可选</span>
</td>
</tr>
</tbody>
@@ -222,11 +256,16 @@ const providerExamples = [
API 格式选择
</h2>
<div :class="[panelClasses.section, 'p-4']">
<div
class="p-4"
:class="[panelClasses.section]"
>
<div class="flex items-start gap-3">
<Info class="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
<div class="text-sm text-[#666663] dark:text-[#a3a094]">
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">如何选择正确的 API 格式</p>
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">
如何选择正确的 API 格式
</p>
<p class="mt-1">
根据目标 API 服务的实际格式选择大多数第三方 API 代理 OpenRouter都兼容 OpenAI 格式
如果不确定可以先尝试 OpenAI 格式
@@ -235,14 +274,23 @@ const providerExamples = [
</div>
</div>
<div :class="[panelClasses.section, 'overflow-hidden']">
<div
class="overflow-hidden"
:class="[panelClasses.section]"
>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-[#e5e4df] dark:border-[rgba(227,224,211,0.12)] bg-[#fafaf7]/50 dark:bg-[#1f1d1a]/50">
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">格式</th>
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">端点路径</th>
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">认证方式</th>
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
格式
</th>
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
端点路径
</th>
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
认证方式
</th>
</tr>
</thead>
<tbody>
@@ -277,7 +325,8 @@ const providerExamples = [
<div
v-for="example in providerExamples"
:key="example.name"
:class="[panelClasses.section, 'p-4']"
class="p-4"
:class="[panelClasses.section]"
>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
{{ example.name }}
@@ -292,7 +341,9 @@ const providerExamples = [
<span :class="panelClasses.badgeBlue">{{ example.format }}</span>
</div>
</div>
<p class="mt-2 text-xs text-[#999]">{{ example.note }}</p>
<p class="mt-2 text-xs text-[#999]">
{{ example.note }}
</p>
</div>
</div>
</section>
@@ -303,11 +354,16 @@ const providerExamples = [
注意事项
</h2>
<div :class="[panelClasses.section, 'p-4 space-y-3']">
<div
class="p-4 space-y-3"
:class="[panelClasses.section]"
>
<div class="flex items-start gap-3">
<AlertTriangle class="h-5 w-5 text-yellow-500 flex-shrink-0 mt-0.5" />
<div class="text-sm">
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">API Key 安全</p>
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">
API Key 安全
</p>
<p class="text-[#666663] dark:text-[#a3a094] mt-1">
端点的 API Key 会被加密存储但仍建议使用子账号或限定范围的 Key而非主账号 Key
</p>
@@ -317,7 +373,9 @@ const providerExamples = [
<div class="flex items-start gap-3">
<AlertTriangle class="h-5 w-5 text-yellow-500 flex-shrink-0 mt-0.5" />
<div class="text-sm">
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">测试端点</p>
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">
测试端点
</p>
<p class="text-[#666663] dark:text-[#a3a094] mt-1">
添加端点后可以在健康监控页面测试连通性如果显示不健康请检查 URL Key 是否正确
</p>
@@ -330,11 +388,16 @@ const providerExamples = [
<section class="pt-4">
<RouterLink
to="/guide/model"
:class="[panelClasses.section, panelClasses.cardHover, 'p-4 flex items-center gap-3 group']"
class="p-4 flex items-center gap-3 group"
:class="[panelClasses.section, panelClasses.cardHover]"
>
<div class="flex-1">
<div class="font-medium text-[#262624] dark:text-[#f1ead8]">下一步模型管理</div>
<div class="text-sm text-[#666663] dark:text-[#a3a094]">配置模型映射和负载均衡</div>
<div class="font-medium text-[#262624] dark:text-[#f1ead8]">
下一步模型管理
</div>
<div class="text-sm text-[#666663] dark:text-[#a3a094]">
配置模型映射和负载均衡
</div>
</div>
<ArrowRight class="h-5 w-5 text-[#999] group-hover:text-[#cc785c] transition-colors" />
</RouterLink>

View File

@@ -65,12 +65,17 @@ const roleComparison = [
</h2>
<div class="grid gap-4 md:grid-cols-2">
<div :class="[panelClasses.section, 'p-5']">
<div
class="p-5"
:class="[panelClasses.section]"
>
<div class="flex items-center gap-3 mb-3">
<div class="p-2 rounded-lg bg-blue-500/10">
<Users class="h-5 w-5 text-blue-500" />
</div>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">普通用户</h3>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
普通用户
</h3>
</div>
<ul class="space-y-2 text-sm text-[#666663] dark:text-[#a3a094]">
<li class="flex items-start gap-2">
@@ -88,12 +93,17 @@ const roleComparison = [
</ul>
</div>
<div :class="[panelClasses.section, 'p-5']">
<div
class="p-5"
:class="[panelClasses.section]"
>
<div class="flex items-center gap-3 mb-3">
<div class="p-2 rounded-lg bg-orange-500/10">
<Shield class="h-5 w-5 text-orange-500" />
</div>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">管理员</h3>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
管理员
</h3>
</div>
<ul class="space-y-2 text-sm text-[#666663] dark:text-[#a3a094]">
<li class="flex items-start gap-2">
@@ -113,14 +123,23 @@ const roleComparison = [
</div>
<!-- 权限对比表 -->
<div :class="[panelClasses.section, 'overflow-hidden']">
<div
class="overflow-hidden"
:class="[panelClasses.section]"
>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-[#e5e4df] dark:border-[rgba(227,224,211,0.12)] bg-[#fafaf7]/50 dark:bg-[#1f1d1a]/50">
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">功能</th>
<th class="px-4 py-3 text-center font-medium text-[#666663] dark:text-[#a3a094]">普通用户</th>
<th class="px-4 py-3 text-center font-medium text-[#666663] dark:text-[#a3a094]">管理员</th>
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
功能
</th>
<th class="px-4 py-3 text-center font-medium text-[#666663] dark:text-[#a3a094]">
普通用户
</th>
<th class="px-4 py-3 text-center font-medium text-[#666663] dark:text-[#a3a094]">
管理员
</th>
</tr>
</thead>
<tbody>
@@ -133,12 +152,24 @@ const roleComparison = [
{{ item.feature }}
</td>
<td class="px-4 py-3 text-center">
<Check v-if="item.user" class="h-5 w-5 text-green-500 mx-auto" />
<span v-else class="text-[#999]"></span>
<Check
v-if="item.user"
class="h-5 w-5 text-green-500 mx-auto"
/>
<span
v-else
class="text-[#999]"
></span>
</td>
<td class="px-4 py-3 text-center">
<Check v-if="item.admin" class="h-5 w-5 text-green-500 mx-auto" />
<span v-else class="text-[#999]"></span>
<Check
v-if="item.admin"
class="h-5 w-5 text-green-500 mx-auto"
/>
<span
v-else
class="text-[#999]"
></span>
</td>
</tr>
</tbody>
@@ -153,12 +184,17 @@ const roleComparison = [
API Key 管理
</h2>
<div :class="[panelClasses.section, 'p-5']">
<div
class="p-5"
:class="[panelClasses.section]"
>
<div class="flex items-center gap-3 mb-4">
<div class="p-2 rounded-lg bg-orange-500/10">
<Key class="h-5 w-5 text-orange-500" />
</div>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">什么是 API Key</h3>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
什么是 API Key
</h3>
</div>
<ul class="space-y-3 text-sm text-[#666663] dark:text-[#a3a094]">
@@ -188,14 +224,23 @@ const roleComparison = [
Key 配置选项
</h2>
<div :class="[panelClasses.section, 'overflow-hidden']">
<div
class="overflow-hidden"
:class="[panelClasses.section]"
>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-[#e5e4df] dark:border-[rgba(227,224,211,0.12)] bg-[#fafaf7]/50 dark:bg-[#1f1d1a]/50">
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">选项</th>
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">说明</th>
<th class="px-4 py-3 text-center font-medium text-[#666663] dark:text-[#a3a094]">必填</th>
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
选项
</th>
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
说明
</th>
<th class="px-4 py-3 text-center font-medium text-[#666663] dark:text-[#a3a094]">
必填
</th>
</tr>
</thead>
<tbody>
@@ -211,8 +256,14 @@ const roleComparison = [
{{ field.description }}
</td>
<td class="px-4 py-3 text-center">
<span v-if="field.required" :class="panelClasses.badgeGreen">必填</span>
<span v-else class="text-[#999]">可选</span>
<span
v-if="field.required"
:class="panelClasses.badgeGreen"
>必填</span>
<span
v-else
class="text-[#999]"
>可选</span>
</td>
</tr>
</tbody>
@@ -227,22 +278,31 @@ const roleComparison = [
配额设置
</h2>
<div :class="[panelClasses.section, 'p-5 space-y-4']">
<div
class="p-5 space-y-4"
:class="[panelClasses.section]"
>
<div class="flex items-center gap-3">
<Clock class="h-5 w-5 text-[#cc785c]" />
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">配额类型</h3>
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
配额类型
</h3>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="p-4 rounded-lg bg-[#f5f5f0]/50 dark:bg-[#1f1d1a]/50">
<h4 class="font-medium text-[#262624] dark:text-[#f1ead8]">请求次数配额</h4>
<h4 class="font-medium text-[#262624] dark:text-[#f1ead8]">
请求次数配额
</h4>
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-1">
限制每日/每月的 API 调用次数超过后请求会被拒绝
</p>
</div>
<div class="p-4 rounded-lg bg-[#f5f5f0]/50 dark:bg-[#1f1d1a]/50">
<h4 class="font-medium text-[#262624] dark:text-[#f1ead8]">Token 用量配额</h4>
<h4 class="font-medium text-[#262624] dark:text-[#f1ead8]">
Token 用量配额
</h4>
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-1">
限制每日/每月的 Token 消耗量适合控制成本
</p>
@@ -252,7 +312,9 @@ const roleComparison = [
<div class="flex items-start gap-3">
<Info class="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
<div class="text-sm text-[#666663] dark:text-[#a3a094]">
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">配额继承</p>
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">
配额继承
</p>
<p class="mt-1">
可以在用户级别设置默认配额新创建的 Key 会自动继承该配额
也可以在创建 Key 时覆盖默认配额
@@ -268,7 +330,10 @@ const roleComparison = [
模型访问控制
</h2>
<div :class="[panelClasses.section, 'p-5']">
<div
class="p-5"
:class="[panelClasses.section]"
>
<p class="text-sm text-[#666663] dark:text-[#a3a094] mb-4">
通过允许的模型字段可以限制 Key 只能访问特定模型
</p>
@@ -288,7 +353,9 @@ const roleComparison = [
<div class="flex items-start gap-2">
<AlertTriangle class="h-5 w-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" />
<div class="text-sm">
<p class="font-medium text-yellow-700 dark:text-yellow-300">使用场景</p>
<p class="font-medium text-yellow-700 dark:text-yellow-300">
使用场景
</p>
<p class="text-yellow-600 dark:text-yellow-400 mt-1">
比如限制免费用户只能使用 gpt-3.5付费用户可以使用 gpt-4
或者为不同项目创建只能访问特定模型的 Key
@@ -305,11 +372,16 @@ const roleComparison = [
安全建议
</h2>
<div :class="[panelClasses.section, 'p-4 space-y-3']">
<div
class="p-4 space-y-3"
:class="[panelClasses.section]"
>
<div class="flex items-start gap-3">
<Shield class="h-5 w-5 text-[#cc785c] flex-shrink-0 mt-0.5" />
<div class="text-sm">
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">设置 Key 有效期</p>
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">
设置 Key 有效期
</p>
<p class="text-[#666663] dark:text-[#a3a094] mt-1">
为临时使用的 Key 设置过期时间避免遗忘造成安全风险
</p>
@@ -319,7 +391,9 @@ const roleComparison = [
<div class="flex items-start gap-3">
<Shield class="h-5 w-5 text-[#cc785c] flex-shrink-0 mt-0.5" />
<div class="text-sm">
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">按场景分配 Key</p>
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">
按场景分配 Key
</p>
<p class="text-[#666663] dark:text-[#a3a094] mt-1">
为开发测试生产环境分别创建 Key便于追踪和管理
</p>
@@ -329,7 +403,9 @@ const roleComparison = [
<div class="flex items-start gap-3">
<Shield class="h-5 w-5 text-[#cc785c] flex-shrink-0 mt-0.5" />
<div class="text-sm">
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">启用 IP 白名单</p>
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">
启用 IP 白名单
</p>
<p class="text-[#666663] dark:text-[#a3a094] mt-1">
对于生产环境的 Key配置 IP 白名单可以防止 Key 泄露后被滥用
</p>
@@ -339,7 +415,9 @@ const roleComparison = [
<div class="flex items-start gap-3">
<Shield class="h-5 w-5 text-[#cc785c] flex-shrink-0 mt-0.5" />
<div class="text-sm">
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">定期审计</p>
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">
定期审计
</p>
<p class="text-[#666663] dark:text-[#a3a094] mt-1">
定期检查用量统计和审计日志发现异常及时禁用相关 Key
</p>
@@ -352,11 +430,16 @@ const roleComparison = [
<section class="pt-4">
<RouterLink
to="/guide/advanced"
:class="[panelClasses.section, panelClasses.cardHover, 'p-4 flex items-center gap-3 group']"
class="p-4 flex items-center gap-3 group"
:class="[panelClasses.section, panelClasses.cardHover]"
>
<div class="flex-1">
<div class="font-medium text-[#262624] dark:text-[#f1ead8]">下一步高级功能</div>
<div class="text-sm text-[#666663] dark:text-[#a3a094]">格式转换请求头规则等高级配置</div>
<div class="font-medium text-[#262624] dark:text-[#f1ead8]">
下一步高级功能
</div>
<div class="text-sm text-[#666663] dark:text-[#a3a094]">
格式转换请求头规则等高级配置
</div>
</div>
<ArrowRight class="h-5 w-5 text-[#999] group-hover:text-[#cc785c] transition-colors" />
</RouterLink>

View File

@@ -398,7 +398,10 @@
<h3 class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
统计周期
</h3>
<TimeRangePicker v-model="dailyTimeRange" :allow-hourly="true" />
<TimeRangePicker
v-model="dailyTimeRange"
:allow-hourly="true"
/>
</div>
<!-- 趋势图表区域 -->