mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
refactor: 继续拆分大型模块并增强模块注册健壮性
后端: - chat_handler_base 错误处理函数提取到 chat_error_utils 子模块 - CLI mixin 引入 CliHandlerProtocol 协议类改善类型标注 - aware_scheduler 拆分为 _candidate_builder 和 _candidate_sorter 子模块 - usage recording 拆分为 _billing_integration 和 _recording_helpers 子模块 - ModuleRegistry 添加循环依赖检测,将写操作从查询方法分离到 reconcile_module_state - 修正 plugin manager 入度注释 前端: - 路由守卫逻辑拆分为独立 guards 模块 - ProviderManagement 拆分为 TableHeader/TableRow/BalanceCell/MobileCard 子组件 - SystemSettings 拆分为多个 Section 子组件和 composables - 提取 useEndpointStatus/useProviderBalance/useProviderFilters composables 测试适配重构后的子模块结构
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<!-- 余额正在加载中 -->
|
||||
<div
|
||||
v-if="provider.ops_configured && isBalanceLoading(provider.id)"
|
||||
class="flex items-center gap-1.5 text-xs text-muted-foreground"
|
||||
>
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
<!-- 显示从上游 API 查询的余额 -->
|
||||
<div
|
||||
v-else-if="provider.ops_configured && getProviderBalance(provider.id)"
|
||||
class="flex items-center gap-2 text-xs"
|
||||
>
|
||||
<!-- 余额文字:balance + points 分开显示,或普通余额 -->
|
||||
<template
|
||||
v-for="(bd, idx) in [getProviderBalanceBreakdown(provider.id)]"
|
||||
:key="idx"
|
||||
>
|
||||
<div
|
||||
v-if="bd"
|
||||
class="min-w-[4.5rem] tabular-nums leading-tight"
|
||||
>
|
||||
<div class="font-semibold text-foreground/90">
|
||||
${{ bd.balance.toFixed(2) }}
|
||||
</div>
|
||||
<div class="text-muted-foreground/70 text-[10px]">
|
||||
${{ bd.points.toFixed(2) }}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-else
|
||||
class="font-semibold text-foreground/90 min-w-[4.5rem] tabular-nums"
|
||||
>
|
||||
{{ formatBalanceDisplay(getProviderBalance(provider.id)) }}
|
||||
</span>
|
||||
</template>
|
||||
<!-- 窗口限额 + 签到状态 + Cookie 失效警告 -->
|
||||
<div
|
||||
v-if="getProviderBalanceExtra(provider.id, provider.ops_architecture_id).length > 0 || getProviderCheckin(provider.id) || getProviderCookieExpired(provider.id)"
|
||||
class="text-muted-foreground/70 space-y-0.5"
|
||||
>
|
||||
<!-- 限额(进度条 + 倒计时,每行一个) -->
|
||||
<template
|
||||
v-for="item in getProviderBalanceExtra(provider.id, provider.ops_architecture_id)"
|
||||
:key="item.label"
|
||||
>
|
||||
<div
|
||||
:title="item.tooltip"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<span class="text-[10px] text-muted-foreground/60 w-4">{{ item.label }}</span>
|
||||
<div class="w-12 h-1.5 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full"
|
||||
:class="[
|
||||
item.percent !== undefined && item.percent >= 50 ? 'bg-green-500' :
|
||||
item.percent !== undefined && item.percent >= 20 ? 'bg-amber-500' : 'bg-red-500'
|
||||
]"
|
||||
:style="{ width: `${item.percent ?? 0}%` }"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-[10px] text-muted-foreground/50 w-7 text-right tabular-nums">{{ item.value }}</span>
|
||||
<span
|
||||
v-if="item.resetsAt"
|
||||
class="text-[10px] text-muted-foreground/40 w-14 text-right tabular-nums"
|
||||
>{{ formatResetCountdown(item.resetsAt) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Cookie 失效警告 -->
|
||||
<div
|
||||
v-if="getProviderCookieExpired(provider.id)"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<span
|
||||
class="text-[10px] text-amber-600 dark:text-amber-500"
|
||||
:title="getProviderCookieExpired(provider.id)?.message"
|
||||
>签到 Cookie 已失效</span>
|
||||
</div>
|
||||
<!-- 签到状态 -->
|
||||
<div
|
||||
v-else-if="getProviderCheckin(provider.id)"
|
||||
class="flex items-center gap-1.5"
|
||||
>
|
||||
<span
|
||||
v-if="getProviderCheckin(provider.id)?.success !== false"
|
||||
class="text-[10px] text-muted-foreground/60"
|
||||
:title="getProviderCheckin(provider.id)?.message"
|
||||
>已签到</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-[10px] text-destructive/70"
|
||||
:title="getProviderCheckin(provider.id)?.message"
|
||||
>签到失败</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 余额查询失败时显示错误 -->
|
||||
<div
|
||||
v-else-if="provider.ops_configured && getProviderBalanceError(provider.id)"
|
||||
class="text-xs text-destructive/80"
|
||||
:title="getProviderBalanceError(provider.id)?.message"
|
||||
>
|
||||
{{ getProviderBalanceError(provider.id)?.message }}
|
||||
</div>
|
||||
<!-- 显示本地配置的月度配额 -->
|
||||
<div
|
||||
v-else-if="provider.billing_type === 'monthly_quota'"
|
||||
class="space-y-0.5 text-xs"
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="text-[10px] font-normal border-border/50"
|
||||
>
|
||||
{{ formatBillingType(provider.billing_type) }}
|
||||
</Badge>
|
||||
<div class="text-muted-foreground/70 pt-0.5">
|
||||
<span
|
||||
class="font-semibold"
|
||||
:class="getQuotaUsedColorClass(provider)"
|
||||
>${{ (provider.monthly_used_usd ?? 0).toFixed(2) }}</span> / <span class="font-medium">${{ (provider.monthly_quota_usd ?? 0).toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-else
|
||||
class="text-xs text-muted-foreground/50"
|
||||
>-</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Loader2 } from 'lucide-vue-next'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||
import { formatBillingType } from '@/utils/format'
|
||||
import type { BalanceExtraItem } from '@/features/providers/auth-templates'
|
||||
|
||||
defineProps<{
|
||||
provider: ProviderWithEndpointsSummary
|
||||
isBalanceLoading: (providerId: string) => boolean
|
||||
getProviderBalance: (providerId: string) => { available: number | null; currency: string } | null
|
||||
getProviderBalanceBreakdown: (providerId: string) => { balance: number; points: number; currency: string } | null
|
||||
getProviderBalanceError: (providerId: string) => { status: string; message: string } | null
|
||||
getProviderCheckin: (providerId: string) => { success: boolean | null; message: string } | null
|
||||
getProviderCookieExpired: (providerId: string) => { expired: boolean; message: string } | null
|
||||
getProviderBalanceExtra: (providerId: string, architectureId?: string) => BalanceExtraItem[]
|
||||
formatBalanceDisplay: (balance: { available: number | null; currency: string } | null) => string
|
||||
formatResetCountdown: (resetsAt: number) => string
|
||||
getQuotaUsedColorClass: (provider: ProviderWithEndpointsSummary) => string
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,305 @@
|
||||
<template>
|
||||
<div
|
||||
class="p-4 space-y-3 hover:bg-muted/20 transition-colors cursor-pointer"
|
||||
@click="$emit('viewDetail', provider.id)"
|
||||
>
|
||||
<!-- 第一行:名称 + 状态 + 操作 -->
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex-1 min-w-0 space-y-0.5">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-medium text-foreground truncate">{{ provider.name }}</span>
|
||||
<a
|
||||
v-if="provider.website"
|
||||
:href="provider.website"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-muted-foreground hover:text-primary transition-colors shrink-0"
|
||||
:title="provider.website"
|
||||
@click.stop
|
||||
>
|
||||
<ExternalLink class="w-3.5 h-3.5" />
|
||||
</a>
|
||||
<Badge
|
||||
:variant="provider.is_active ? 'success' : 'secondary'"
|
||||
class="text-xs shrink-0"
|
||||
>
|
||||
{{ provider.is_active ? '活跃' : '停用' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<!-- 内联编辑备注 (移动端) -->
|
||||
<div
|
||||
v-if="editingDescriptionId === provider.id"
|
||||
data-desc-editor
|
||||
class="flex items-center gap-1 max-w-[180px]"
|
||||
@click.stop
|
||||
>
|
||||
<input
|
||||
v-model="localDescriptionValue"
|
||||
v-auto-focus
|
||||
class="flex-1 min-w-0 text-xs px-1.5 py-0.5 rounded border border-border bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||
placeholder="输入备注..."
|
||||
@keydown="handleDescriptionKeydown"
|
||||
>
|
||||
<button
|
||||
class="shrink-0 p-0.5 rounded hover:bg-muted text-primary"
|
||||
title="保存"
|
||||
@click="handleSave"
|
||||
>
|
||||
<Check class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
class="shrink-0 p-0.5 rounded hover:bg-muted text-muted-foreground"
|
||||
title="取消"
|
||||
@click="handleCancel"
|
||||
>
|
||||
<X class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<span
|
||||
v-else-if="provider.description"
|
||||
class="text-xs text-muted-foreground truncate block max-w-[120px] group/desc cursor-pointer hover:text-foreground/70 transition-colors"
|
||||
:title="provider.description"
|
||||
@click="handleStartEdit"
|
||||
>{{ provider.description }} <Pencil class="w-3 h-3 inline-block opacity-0 group-hover/desc:opacity-50 transition-opacity" /></span>
|
||||
<span
|
||||
v-else
|
||||
class="text-xs text-muted-foreground cursor-pointer hover:text-foreground/70 transition-colors"
|
||||
@click="handleStartEdit"
|
||||
>添加备注</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-0.5 shrink-0"
|
||||
@click.stop
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="查看详情"
|
||||
@click="$emit('viewDetail', provider.id)"
|
||||
>
|
||||
<Eye class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="编辑"
|
||||
@click="$emit('editProvider', provider)"
|
||||
>
|
||||
<Edit class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="扩展操作配置"
|
||||
@click="$emit('openOpsConfig', provider)"
|
||||
>
|
||||
<KeyRound class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
@click="$emit('toggleStatus', provider)"
|
||||
>
|
||||
<Power class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
@click="$emit('deleteProvider', provider)"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二行:计费类型 + 余额/配额 + 资源统计 -->
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="text-xs font-normal border-border/50"
|
||||
>
|
||||
{{ formatBillingType(provider.billing_type || 'pay_as_you_go') }}
|
||||
</Badge>
|
||||
<!-- 余额加载中 -->
|
||||
<span
|
||||
v-if="provider.ops_configured && isBalanceLoading(provider.id)"
|
||||
class="text-muted-foreground flex items-center gap-1"
|
||||
>
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
加载中...
|
||||
</span>
|
||||
<!-- 余额(从上游 API 查询) -->
|
||||
<span
|
||||
v-else-if="provider.ops_configured && getProviderBalance(provider.id)"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
余额 <span class="font-semibold text-foreground/90">{{ formatBalanceDisplay(getProviderBalance(provider.id)) }}</span>
|
||||
<!-- Cookie 失效警告 -->
|
||||
<span
|
||||
v-if="getProviderCookieExpired(provider.id)"
|
||||
class="ml-1 text-amber-600 dark:text-amber-500"
|
||||
:title="getProviderCookieExpired(provider.id)?.message"
|
||||
>签到 Cookie 已失效</span>
|
||||
<!-- 签到状态显示 -->
|
||||
<span
|
||||
v-else-if="getProviderCheckin(provider.id) && getProviderCheckin(provider.id)?.success !== false"
|
||||
class="ml-1 text-muted-foreground"
|
||||
:title="getProviderCheckin(provider.id)?.message"
|
||||
>已签到</span>
|
||||
<span
|
||||
v-else-if="getProviderCheckin(provider.id)?.success === false"
|
||||
class="ml-1 text-destructive/70"
|
||||
:title="getProviderCheckin(provider.id)?.message"
|
||||
>签到失败</span>
|
||||
</span>
|
||||
<!-- 余额查询失败时显示错误 -->
|
||||
<span
|
||||
v-else-if="provider.ops_configured && getProviderBalanceError(provider.id)"
|
||||
class="text-destructive/80"
|
||||
:title="getProviderBalanceError(provider.id)?.message"
|
||||
>
|
||||
{{ getProviderBalanceError(provider.id)?.message }}
|
||||
</span>
|
||||
<!-- 本地配额 -->
|
||||
<span
|
||||
v-else-if="provider.billing_type === 'monthly_quota'"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
配额 <span
|
||||
class="font-semibold"
|
||||
:class="getQuotaUsedColorClass(provider)"
|
||||
>${{ (provider.monthly_used_usd ?? 0).toFixed(2) }}</span>/<span class="font-medium">${{ (provider.monthly_quota_usd ?? 0).toFixed(2) }}</span>
|
||||
</span>
|
||||
<span class="text-muted-foreground">
|
||||
端点 {{ provider.active_endpoints }}/{{ provider.total_endpoints }}
|
||||
</span>
|
||||
<span class="text-muted-foreground">
|
||||
密钥 {{ provider.active_keys }}/{{ provider.total_keys }}
|
||||
</span>
|
||||
<span class="text-muted-foreground">
|
||||
模型 {{ provider.active_models }}/{{ provider.total_models }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 第三行:端点健康 -->
|
||||
<div
|
||||
v-if="provider.endpoint_health_details && provider.endpoint_health_details.length > 0"
|
||||
class="grid grid-cols-3 gap-x-3 gap-y-2 max-w-[240px]"
|
||||
>
|
||||
<div
|
||||
v-for="endpoint in sortEndpoints(provider.endpoint_health_details)"
|
||||
:key="endpoint.api_format"
|
||||
class="flex flex-col gap-1.5"
|
||||
:title="getEndpointTooltip(endpoint)"
|
||||
>
|
||||
<!-- 上排:缩写 + 百分比 -->
|
||||
<div class="flex items-center justify-between text-[10px] leading-none">
|
||||
<span class="font-medium text-muted-foreground/80">
|
||||
{{ API_FORMAT_SHORT[endpoint.api_format] || endpoint.api_format.substring(0,2) }}
|
||||
</span>
|
||||
<span class="font-medium text-muted-foreground/80">
|
||||
{{ isEndpointAvailable(endpoint) ? `${(endpoint.health_score * 100).toFixed(0)}%` : '-' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 下排:进度条 -->
|
||||
<div class="h-1.5 w-full bg-border dark:bg-border/80 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-300"
|
||||
:class="getEndpointDotColor(endpoint)"
|
||||
:style="{ width: isEndpointAvailable(endpoint) ? `${Math.max(endpoint.health_score * 100, 5)}%` : '100%' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import {
|
||||
Edit,
|
||||
Eye,
|
||||
Trash2,
|
||||
Power,
|
||||
KeyRound,
|
||||
ExternalLink,
|
||||
Pencil,
|
||||
Check,
|
||||
X,
|
||||
Loader2,
|
||||
} from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import { type ProviderWithEndpointsSummary, API_FORMAT_SHORT } from '@/api/endpoints'
|
||||
import { formatBillingType } from '@/utils/format'
|
||||
import { sortEndpoints, isEndpointAvailable, getEndpointDotColor, getEndpointTooltip } from '@/features/providers/composables/useEndpointStatus'
|
||||
|
||||
const props = defineProps<{
|
||||
provider: ProviderWithEndpointsSummary
|
||||
editingDescriptionId: string | null
|
||||
// Balance functions
|
||||
isBalanceLoading: (providerId: string) => boolean
|
||||
getProviderBalance: (providerId: string) => { available: number | null; currency: string } | null
|
||||
getProviderBalanceError: (providerId: string) => { status: string; message: string } | null
|
||||
getProviderCheckin: (providerId: string) => { success: boolean | null; message: string } | null
|
||||
getProviderCookieExpired: (providerId: string) => { expired: boolean; message: string } | null
|
||||
formatBalanceDisplay: (balance: { available: number | null; currency: string } | null) => string
|
||||
getQuotaUsedColorClass: (provider: ProviderWithEndpointsSummary) => string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'viewDetail': [providerId: string]
|
||||
'editProvider': [provider: ProviderWithEndpointsSummary]
|
||||
'openOpsConfig': [provider: ProviderWithEndpointsSummary]
|
||||
'toggleStatus': [provider: ProviderWithEndpointsSummary]
|
||||
'deleteProvider': [provider: ProviderWithEndpointsSummary]
|
||||
'startEditDescription': [event: Event, provider: ProviderWithEndpointsSummary]
|
||||
'saveDescription': [event: Event, provider: ProviderWithEndpointsSummary, value: string]
|
||||
'cancelEditDescription': [event?: Event]
|
||||
}>()
|
||||
|
||||
const vAutoFocus = {
|
||||
mounted: (el: HTMLElement) => el.focus(),
|
||||
}
|
||||
|
||||
const localDescriptionValue = ref('')
|
||||
|
||||
watch(
|
||||
() => props.editingDescriptionId,
|
||||
(newId) => {
|
||||
if (newId === props.provider.id) {
|
||||
localDescriptionValue.value = props.provider.description || ''
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function handleStartEdit(event: Event) {
|
||||
event.stopPropagation()
|
||||
emit('startEditDescription', event, props.provider)
|
||||
}
|
||||
|
||||
function handleSave(event: Event) {
|
||||
event.stopPropagation()
|
||||
emit('saveDescription', event, props.provider, localDescriptionValue.value)
|
||||
}
|
||||
|
||||
function handleCancel(event?: Event) {
|
||||
event?.stopPropagation()
|
||||
emit('cancelEditDescription', event)
|
||||
}
|
||||
|
||||
function handleDescriptionKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
handleSave(event)
|
||||
} else if (event.key === 'Escape') {
|
||||
handleCancel(event)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/50">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
||||
<!-- 左侧:标题 -->
|
||||
<h3 class="text-sm sm:text-base font-semibold text-foreground shrink-0">
|
||||
提供商管理
|
||||
</h3>
|
||||
|
||||
<!-- 右侧:操作区 -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<!-- 搜索框 -->
|
||||
<div class="relative">
|
||||
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/70 z-10 pointer-events-none" />
|
||||
<Input
|
||||
id="provider-search"
|
||||
:model-value="searchQuery"
|
||||
type="text"
|
||||
placeholder="搜索提供商..."
|
||||
class="w-32 sm:w-44 pl-8 pr-3 h-8 text-sm bg-muted/30 border-border/50 focus:border-primary/50 transition-colors"
|
||||
@update:model-value="$emit('update:searchQuery', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 状态筛选 -->
|
||||
<Select
|
||||
:model-value="filterStatus"
|
||||
@update:model-value="$emit('update:filterStatus', $event)"
|
||||
>
|
||||
<SelectTrigger class="w-20 sm:w-28 h-8 text-xs border-border/60">
|
||||
<SelectValue placeholder="全部状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="status in statusFilters"
|
||||
:key="status.value"
|
||||
:value="status.value"
|
||||
>
|
||||
{{ status.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- API 格式筛选 -->
|
||||
<Select
|
||||
:model-value="filterApiFormat"
|
||||
@update:model-value="$emit('update:filterApiFormat', $event)"
|
||||
>
|
||||
<SelectTrigger class="w-20 sm:w-28 h-8 text-xs border-border/60">
|
||||
<SelectValue placeholder="全部格式" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="fmt in apiFormatFilters"
|
||||
:key="fmt.value"
|
||||
:value="fmt.value"
|
||||
>
|
||||
{{ fmt.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- 模型筛选 -->
|
||||
<Select
|
||||
:model-value="filterModel"
|
||||
@update:model-value="$emit('update:filterModel', $event)"
|
||||
>
|
||||
<SelectTrigger class="w-20 sm:w-36 h-8 text-xs border-border/60">
|
||||
<SelectValue placeholder="全部模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="model in modelFilters"
|
||||
:key="model.value"
|
||||
:value="model.value"
|
||||
>
|
||||
{{ model.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- 重置筛选 -->
|
||||
<Button
|
||||
v-if="hasActiveFilters"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="重置筛选"
|
||||
@click="$emit('resetFilters')"
|
||||
>
|
||||
<FilterX class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
|
||||
<div class="hidden sm:block h-4 w-px bg-border" />
|
||||
|
||||
<!-- 调度策略 -->
|
||||
<button
|
||||
class="group inline-flex items-center gap-1.5 px-2.5 h-8 rounded-md border border-border/50 bg-muted/20 hover:bg-muted/40 hover:border-primary/40 transition-all duration-200 text-xs"
|
||||
title="点击调整调度策略"
|
||||
@click="$emit('openPriorityDialog')"
|
||||
>
|
||||
<span class="text-muted-foreground/80 hidden sm:inline">调度:</span>
|
||||
<span class="font-medium text-foreground/90">{{ priorityModeLabel }}</span>
|
||||
<ChevronDown class="w-3 h-3 text-muted-foreground/70 group-hover:text-foreground transition-colors" />
|
||||
</button>
|
||||
|
||||
<div class="hidden sm:block h-4 w-px bg-border" />
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="新增提供商"
|
||||
@click="$emit('addProvider')"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<RefreshButton
|
||||
:loading="loading"
|
||||
@click="$emit('refresh')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Search, Plus, ChevronDown, FilterX } from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Select from '@/components/ui/select.vue'
|
||||
import SelectTrigger from '@/components/ui/select-trigger.vue'
|
||||
import SelectValue from '@/components/ui/select-value.vue'
|
||||
import SelectContent from '@/components/ui/select-content.vue'
|
||||
import SelectItem from '@/components/ui/select-item.vue'
|
||||
import RefreshButton from '@/components/ui/refresh-button.vue'
|
||||
import type { FilterOption } from '@/features/providers/composables/useProviderFilters'
|
||||
|
||||
defineProps<{
|
||||
searchQuery: string
|
||||
filterStatus: string
|
||||
filterApiFormat: string
|
||||
filterModel: string
|
||||
statusFilters: FilterOption[]
|
||||
apiFormatFilters: FilterOption[]
|
||||
modelFilters: FilterOption[]
|
||||
hasActiveFilters: boolean
|
||||
priorityModeLabel: string
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:searchQuery': [value: string]
|
||||
'update:filterStatus': [value: string]
|
||||
'update:filterApiFormat': [value: string]
|
||||
'update:filterModel': [value: string]
|
||||
'resetFilters': []
|
||||
'openPriorityDialog': []
|
||||
'addProvider': []
|
||||
'refresh': []
|
||||
}>()
|
||||
</script>
|
||||
288
frontend/src/features/providers/components/ProviderTableRow.vue
Normal file
288
frontend/src/features/providers/components/ProviderTableRow.vue
Normal file
@@ -0,0 +1,288 @@
|
||||
<template>
|
||||
<TableRow
|
||||
class="border-b border-border/30 hover:bg-muted/20 transition-colors cursor-pointer"
|
||||
@mousedown="$emit('mousedown', $event)"
|
||||
@click="$emit('rowClick', $event, provider.id)"
|
||||
>
|
||||
<TableCell class="py-3.5">
|
||||
<div class="space-y-0.5">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-sm font-medium text-foreground">{{ provider.name }}</span>
|
||||
<a
|
||||
v-if="provider.website"
|
||||
:href="provider.website"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-muted-foreground hover:text-primary transition-colors shrink-0"
|
||||
:title="provider.website"
|
||||
@click.stop
|
||||
>
|
||||
<ExternalLink class="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
<!-- 内联编辑备注 -->
|
||||
<div
|
||||
v-if="editingDescriptionId === provider.id"
|
||||
data-desc-editor
|
||||
class="flex items-center gap-1 max-w-[220px]"
|
||||
@click.stop
|
||||
>
|
||||
<input
|
||||
v-model="localDescriptionValue"
|
||||
v-auto-focus
|
||||
class="flex-1 min-w-0 text-xs px-1.5 py-0.5 rounded border border-border bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||
placeholder="输入备注..."
|
||||
@keydown="handleDescriptionKeydown"
|
||||
>
|
||||
<button
|
||||
class="shrink-0 p-0.5 rounded hover:bg-muted text-primary"
|
||||
title="保存"
|
||||
@click="handleSave"
|
||||
>
|
||||
<Check class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
class="shrink-0 p-0.5 rounded hover:bg-muted text-muted-foreground"
|
||||
title="取消"
|
||||
@click="handleCancel"
|
||||
>
|
||||
<X class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<span
|
||||
v-else-if="provider.description"
|
||||
class="text-xs text-muted-foreground truncate block max-w-[200px] group/desc cursor-pointer hover:text-foreground/70 transition-colors"
|
||||
:title="provider.description"
|
||||
@click="handleStartEdit"
|
||||
>{{ provider.description }} <Pencil class="w-3 h-3 inline-block opacity-0 group-hover/desc:opacity-50 transition-opacity" /></span>
|
||||
<span
|
||||
v-else
|
||||
class="text-xs text-muted-foreground cursor-pointer hover:text-foreground/70 transition-colors"
|
||||
@click="handleStartEdit"
|
||||
>添加备注</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-3.5">
|
||||
<ProviderBalanceCell
|
||||
:provider="provider"
|
||||
:is-balance-loading="isBalanceLoading"
|
||||
:get-provider-balance="getProviderBalance"
|
||||
:get-provider-balance-breakdown="getProviderBalanceBreakdown"
|
||||
:get-provider-balance-error="getProviderBalanceError"
|
||||
:get-provider-checkin="getProviderCheckin"
|
||||
:get-provider-cookie-expired="getProviderCookieExpired"
|
||||
:get-provider-balance-extra="getProviderBalanceExtra"
|
||||
:format-balance-display="formatBalanceDisplay"
|
||||
:format-reset-countdown="formatResetCountdown"
|
||||
:get-quota-used-color-class="getQuotaUsedColorClass"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell class="py-3.5 text-center">
|
||||
<div class="space-y-0.5 text-xs">
|
||||
<div class="flex items-center justify-center gap-1.5">
|
||||
<span class="text-muted-foreground/70">端点:</span>
|
||||
<span class="font-medium text-foreground/90">{{ provider.active_endpoints }}</span>
|
||||
<span class="text-muted-foreground/50">/{{ provider.total_endpoints }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-center gap-1.5">
|
||||
<span class="text-muted-foreground/70">密钥:</span>
|
||||
<span class="font-medium text-foreground/90">{{ provider.active_keys }}</span>
|
||||
<span class="text-muted-foreground/50">/{{ provider.total_keys }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-center gap-1.5">
|
||||
<span class="text-muted-foreground/70">模型:</span>
|
||||
<span class="font-medium text-foreground/90">{{ provider.active_models }}</span>
|
||||
<span class="text-muted-foreground/50">/{{ provider.total_models }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-3.5 align-middle">
|
||||
<div
|
||||
v-if="provider.endpoint_health_details && provider.endpoint_health_details.length > 0"
|
||||
class="grid grid-cols-3 gap-x-3 gap-y-2 max-w-[240px]"
|
||||
>
|
||||
<div
|
||||
v-for="endpoint in sortEndpoints(provider.endpoint_health_details)"
|
||||
:key="endpoint.api_format"
|
||||
class="flex flex-col gap-1.5"
|
||||
:title="getEndpointTooltip(endpoint)"
|
||||
>
|
||||
<!-- 上排:缩写 + 百分比 -->
|
||||
<div class="flex items-center justify-between text-[10px] leading-none">
|
||||
<span class="font-medium text-muted-foreground/80">
|
||||
{{ API_FORMAT_SHORT[endpoint.api_format] || endpoint.api_format.substring(0,2) }}
|
||||
</span>
|
||||
<span class="font-medium text-muted-foreground/80">
|
||||
{{ isEndpointAvailable(endpoint) ? `${(endpoint.health_score * 100).toFixed(0)}%` : '-' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 下排:进度条 -->
|
||||
<div class="h-1.5 w-full bg-border dark:bg-border/80 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-300"
|
||||
:class="getEndpointDotColor(endpoint)"
|
||||
:style="{ width: isEndpointAvailable(endpoint) ? `${Math.max(endpoint.health_score * 100, 5)}%` : '100%' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-else
|
||||
class="text-xs text-muted-foreground/50"
|
||||
>暂无端点</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-3.5 text-center">
|
||||
<Badge
|
||||
:variant="provider.is_active ? 'success' : 'secondary'"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ provider.is_active ? '活跃' : '已停用' }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class="py-3.5"
|
||||
@click.stop
|
||||
>
|
||||
<div class="flex items-center justify-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
|
||||
title="查看详情"
|
||||
@click="$emit('viewDetail', provider.id)"
|
||||
>
|
||||
<Eye class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
|
||||
title="编辑提供商"
|
||||
@click="$emit('editProvider', provider)"
|
||||
>
|
||||
<Edit class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
|
||||
title="扩展操作配置"
|
||||
@click="$emit('openOpsConfig', provider)"
|
||||
>
|
||||
<KeyRound class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
|
||||
:title="provider.is_active ? '停用提供商' : '启用提供商'"
|
||||
@click="$emit('toggleStatus', provider)"
|
||||
>
|
||||
<Power class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 text-muted-foreground/70 hover:text-destructive"
|
||||
title="删除提供商"
|
||||
@click="$emit('deleteProvider', provider)"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import {
|
||||
Edit,
|
||||
Eye,
|
||||
Trash2,
|
||||
Power,
|
||||
KeyRound,
|
||||
ExternalLink,
|
||||
Pencil,
|
||||
Check,
|
||||
X,
|
||||
} from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import TableRow from '@/components/ui/table-row.vue'
|
||||
import TableCell from '@/components/ui/table-cell.vue'
|
||||
import ProviderBalanceCell from './ProviderBalanceCell.vue'
|
||||
import { type ProviderWithEndpointsSummary, API_FORMAT_SHORT } from '@/api/endpoints'
|
||||
import { sortEndpoints, isEndpointAvailable, getEndpointDotColor, getEndpointTooltip } from '@/features/providers/composables/useEndpointStatus'
|
||||
import type { BalanceExtraItem } from '@/features/providers/auth-templates'
|
||||
|
||||
const props = defineProps<{
|
||||
provider: ProviderWithEndpointsSummary
|
||||
editingDescriptionId: string | null
|
||||
// Balance functions
|
||||
isBalanceLoading: (providerId: string) => boolean
|
||||
getProviderBalance: (providerId: string) => { available: number | null; currency: string } | null
|
||||
getProviderBalanceBreakdown: (providerId: string) => { balance: number; points: number; currency: string } | null
|
||||
getProviderBalanceError: (providerId: string) => { status: string; message: string } | null
|
||||
getProviderCheckin: (providerId: string) => { success: boolean | null; message: string } | null
|
||||
getProviderCookieExpired: (providerId: string) => { expired: boolean; message: string } | null
|
||||
getProviderBalanceExtra: (providerId: string, architectureId?: string) => BalanceExtraItem[]
|
||||
formatBalanceDisplay: (balance: { available: number | null; currency: string } | null) => string
|
||||
formatResetCountdown: (resetsAt: number) => string
|
||||
getQuotaUsedColorClass: (provider: ProviderWithEndpointsSummary) => string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'mousedown': [event: MouseEvent]
|
||||
'rowClick': [event: MouseEvent, providerId: string]
|
||||
'viewDetail': [providerId: string]
|
||||
'editProvider': [provider: ProviderWithEndpointsSummary]
|
||||
'openOpsConfig': [provider: ProviderWithEndpointsSummary]
|
||||
'toggleStatus': [provider: ProviderWithEndpointsSummary]
|
||||
'deleteProvider': [provider: ProviderWithEndpointsSummary]
|
||||
'startEditDescription': [event: Event, provider: ProviderWithEndpointsSummary]
|
||||
'saveDescription': [event: Event, provider: ProviderWithEndpointsSummary, value: string]
|
||||
'cancelEditDescription': [event?: Event]
|
||||
}>()
|
||||
|
||||
const vAutoFocus = {
|
||||
mounted: (el: HTMLElement) => el.focus(),
|
||||
}
|
||||
|
||||
const localDescriptionValue = ref('')
|
||||
|
||||
// 当进入编辑模式时,同步 props 的 description
|
||||
watch(
|
||||
() => props.editingDescriptionId,
|
||||
(newId) => {
|
||||
if (newId === props.provider.id) {
|
||||
localDescriptionValue.value = props.provider.description || ''
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function handleStartEdit(event: Event) {
|
||||
event.stopPropagation()
|
||||
emit('startEditDescription', event, props.provider)
|
||||
}
|
||||
|
||||
function handleSave(event: Event) {
|
||||
event.stopPropagation()
|
||||
emit('saveDescription', event, props.provider, localDescriptionValue.value)
|
||||
}
|
||||
|
||||
function handleCancel(event?: Event) {
|
||||
event?.stopPropagation()
|
||||
emit('cancelEditDescription', event)
|
||||
}
|
||||
|
||||
function handleDescriptionKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
handleSave(event)
|
||||
} else if (event.key === 'Escape') {
|
||||
handleCancel(event)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { EndpointHealthDetail } from '@/api/endpoints'
|
||||
|
||||
// 端点状态枚举
|
||||
export type EndpointStatus = 'disabled' | 'no_keys' | 'keys_disabled' | 'available'
|
||||
|
||||
const ENDPOINT_SORT_ORDER = [
|
||||
'claude:chat',
|
||||
'claude:cli',
|
||||
'openai:chat',
|
||||
'openai:cli',
|
||||
'gemini:chat',
|
||||
'gemini:cli',
|
||||
'openai:video',
|
||||
'gemini:video',
|
||||
]
|
||||
|
||||
/**
|
||||
* 端点排序
|
||||
*/
|
||||
export function sortEndpoints<T extends { api_format: string }>(endpoints: T[]): T[] {
|
||||
return [...endpoints].sort((a, b) => {
|
||||
return ENDPOINT_SORT_ORDER.indexOf(a.api_format) - ENDPOINT_SORT_ORDER.indexOf(b.api_format)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取端点状态
|
||||
*/
|
||||
export function getEndpointStatus(endpoint: EndpointHealthDetail): EndpointStatus {
|
||||
if (endpoint.is_active === false) {
|
||||
return 'disabled'
|
||||
}
|
||||
if ((endpoint.active_keys ?? 0) === 0) {
|
||||
return (endpoint.total_keys ?? 0) > 0 ? 'keys_disabled' : 'no_keys'
|
||||
}
|
||||
return 'available'
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断端点是否可用
|
||||
*/
|
||||
export function isEndpointAvailable(endpoint: EndpointHealthDetail): boolean {
|
||||
return getEndpointStatus(endpoint) === 'available'
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据健康分数获取颜色
|
||||
*/
|
||||
export function getHealthScoreColor(score: number | undefined | null): string {
|
||||
if (score === undefined || score === null) {
|
||||
return 'bg-muted-foreground/40'
|
||||
}
|
||||
if (score >= 0.8) return 'bg-green-500'
|
||||
if (score >= 0.5) return 'bg-amber-500'
|
||||
return 'bg-red-500'
|
||||
}
|
||||
|
||||
/**
|
||||
* 端点不可用时进度条颜色
|
||||
*/
|
||||
export function getEndpointDotColor(endpoint: EndpointHealthDetail): string {
|
||||
if (!isEndpointAvailable(endpoint)) {
|
||||
return 'bg-muted-foreground/40'
|
||||
}
|
||||
return getHealthScoreColor(endpoint.health_score)
|
||||
}
|
||||
|
||||
/**
|
||||
* 端点提示文本
|
||||
*/
|
||||
export function getEndpointTooltip(endpoint: EndpointHealthDetail): string {
|
||||
const format = endpoint.api_format
|
||||
const status = getEndpointStatus(endpoint)
|
||||
|
||||
switch (status) {
|
||||
case 'disabled':
|
||||
return `${format}: 端点已禁用`
|
||||
case 'no_keys':
|
||||
return `${format}: 未配置密钥`
|
||||
case 'keys_disabled':
|
||||
return `${format}: 无可用密钥`
|
||||
case 'available': {
|
||||
const score = endpoint.health_score
|
||||
if (score === undefined || score === null) {
|
||||
return `${format}: 暂无健康数据`
|
||||
}
|
||||
return `${format}: 健康度 ${(score * 100).toFixed(0)}%`
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||
import { batchQueryBalance, getArchitectures, type ActionResultResponse, type ArchitectureInfo } from '@/api/providerOps'
|
||||
import { formatBalanceExtraFromSchema, type CredentialsSchema } from '@/features/providers/auth-templates/schema-utils'
|
||||
import type { BalanceExtraItem } from '@/features/providers/auth-templates'
|
||||
|
||||
const MAX_BALANCE_RETRIES = 3
|
||||
|
||||
export function useProviderBalance() {
|
||||
// 余额数据缓存 {providerId: ActionResultResponse}
|
||||
const balanceCache = ref<Record<string, ActionResultResponse>>({})
|
||||
// 余额加载请求版本计数器(用于防止竞态条件)
|
||||
let balanceLoadVersion = 0
|
||||
|
||||
// 追踪待处理的定时器,用于组件卸载时清理
|
||||
const pendingTimers = new Set<ReturnType<typeof setTimeout>>()
|
||||
|
||||
// 架构 schema 缓存(用于 balance extra 格式化)
|
||||
const architectureSchemas = ref<Record<string, CredentialsSchema>>({})
|
||||
const architectureSchemasLoaded = ref(false)
|
||||
|
||||
// 用于触发倒计时更新的响应式计数器
|
||||
const tickCounter = ref(0)
|
||||
let tickInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function startTick() {
|
||||
if (tickInterval) return
|
||||
tickInterval = setInterval(() => {
|
||||
tickCounter.value++
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function stopTick() {
|
||||
if (tickInterval) {
|
||||
clearInterval(tickInterval)
|
||||
tickInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载架构 schema 缓存 */
|
||||
async function loadArchitectureSchemas() {
|
||||
if (architectureSchemasLoaded.value) return
|
||||
try {
|
||||
const archs: ArchitectureInfo[] = await getArchitectures()
|
||||
const schemas: Record<string, CredentialsSchema> = {}
|
||||
for (const arch of archs) {
|
||||
if (arch.credentials_schema) {
|
||||
schemas[arch.architecture_id] = arch.credentials_schema as CredentialsSchema
|
||||
}
|
||||
}
|
||||
architectureSchemas.value = schemas
|
||||
architectureSchemasLoaded.value = true
|
||||
} catch {
|
||||
// 加载失败不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
// 异步加载余额数据(使用批量接口)
|
||||
async function loadBalances(providers: ProviderWithEndpointsSummary[]) {
|
||||
// 清空旧的余额缓存,避免数据累积
|
||||
balanceCache.value = {}
|
||||
const currentVersion = ++balanceLoadVersion
|
||||
try {
|
||||
const opsProviderIds = providers.filter(p => p.ops_configured).map(p => p.id)
|
||||
if (opsProviderIds.length === 0) return
|
||||
|
||||
const results = await batchQueryBalance(opsProviderIds)
|
||||
|
||||
// 检查是否有新的请求已经开始,如果有则丢弃当前结果
|
||||
if (currentVersion !== balanceLoadVersion) return
|
||||
|
||||
// 收集需要重试的 provider IDs
|
||||
const pendingProviderIds: string[] = []
|
||||
|
||||
// 将结果存入缓存(包括 pending 状态)
|
||||
for (const [providerId, result] of Object.entries(results)) {
|
||||
// 存入缓存:success, auth_expired (带有效数据), pending
|
||||
if (result.status === 'success' || result.status === 'auth_expired' || result.status === 'pending') {
|
||||
balanceCache.value[providerId] = result
|
||||
}
|
||||
// 收集 pending 状态的 provider,稍后重试
|
||||
if (result.status === 'pending') {
|
||||
pendingProviderIds.push(providerId)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有 pending 状态的 provider,3秒后自动重试
|
||||
if (pendingProviderIds.length > 0) {
|
||||
const timerId = setTimeout(() => {
|
||||
pendingTimers.delete(timerId)
|
||||
// 检查版本号,确保没有新的加载请求
|
||||
if (currentVersion === balanceLoadVersion) {
|
||||
retryPendingBalances(pendingProviderIds, currentVersion, 0)
|
||||
}
|
||||
}, 3000)
|
||||
pendingTimers.add(timerId)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[loadBalances] 加载余额数据失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 重试加载 pending 状态的余额
|
||||
async function retryPendingBalances(providerIds: string[], loadVersion: number, retryCount: number) {
|
||||
try {
|
||||
const results = await batchQueryBalance(providerIds)
|
||||
const stillPending: string[] = []
|
||||
|
||||
for (const [providerId, result] of Object.entries(results)) {
|
||||
if (result.status !== 'pending') {
|
||||
balanceCache.value[providerId] = result
|
||||
} else {
|
||||
stillPending.push(providerId)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果还有 pending 且未达到最大重试次数,继续重试(指数退避)
|
||||
if (stillPending.length > 0 && retryCount < MAX_BALANCE_RETRIES) {
|
||||
const delay = 3000 * Math.pow(1.5, retryCount) // 3s, 4.5s, 6.75s
|
||||
const timerId = setTimeout(() => {
|
||||
pendingTimers.delete(timerId)
|
||||
// 检查版本号,确保没有新的加载请求
|
||||
if (loadVersion === balanceLoadVersion) {
|
||||
retryPendingBalances(stillPending, loadVersion, retryCount + 1)
|
||||
}
|
||||
}, delay)
|
||||
pendingTimers.add(timerId)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[retryPendingBalances] 重试加载余额失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 类型守卫:检查是否为 BalanceInfo(简化版)
|
||||
*/
|
||||
function isBalanceInfo(data: unknown): data is { total_available: number | null; currency: string } {
|
||||
if (typeof data !== 'object' || data === null) return false
|
||||
if (!('total_available' in data) || !('currency' in data)) return false
|
||||
const d = data as Record<string, unknown>
|
||||
if (d.total_available !== null && typeof d.total_available !== 'number') return false
|
||||
if (typeof d.currency !== 'string') return false
|
||||
return true
|
||||
}
|
||||
|
||||
// 获取 provider 的余额显示
|
||||
function getProviderBalance(providerId: string): { available: number | null; currency: string } | null {
|
||||
const result = balanceCache.value[providerId]
|
||||
// auth_expired 时余额数据仍有效(只是签到 Cookie 失效)
|
||||
if (!result || (result.status !== 'success' && result.status !== 'auth_expired') || !result.data) {
|
||||
return null
|
||||
}
|
||||
if (!isBalanceInfo(result.data)) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
available: result.data.total_available,
|
||||
currency: result.data.currency || 'USD',
|
||||
}
|
||||
}
|
||||
|
||||
// 获取 provider 余额明细(balance + points 分开显示)
|
||||
function getProviderBalanceBreakdown(providerId: string): { balance: number; points: number; currency: string } | null {
|
||||
const result = balanceCache.value[providerId]
|
||||
if (!result || (result.status !== 'success' && result.status !== 'auth_expired') || !result.data) {
|
||||
return null
|
||||
}
|
||||
const data = result.data as Record<string, any>
|
||||
const extra = data.extra
|
||||
if (!extra || extra.balance === undefined || extra.points === undefined) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
balance: extra.balance,
|
||||
points: extra.points,
|
||||
currency: data.currency || 'USD',
|
||||
}
|
||||
}
|
||||
|
||||
// 获取 provider 余额查询的错误状态
|
||||
function getProviderBalanceError(providerId: string): { status: string; message: string } | null {
|
||||
const result = balanceCache.value[providerId]
|
||||
if (!result) {
|
||||
return null
|
||||
}
|
||||
// pending 状态不是错误,正在加载中
|
||||
if (result.status === 'pending') {
|
||||
return null
|
||||
}
|
||||
// 认证失败或过期
|
||||
if (result.status === 'auth_failed' || result.status === 'auth_expired') {
|
||||
return {
|
||||
status: result.status,
|
||||
message: result.message || '认证失败',
|
||||
}
|
||||
}
|
||||
// 其他错误
|
||||
if (result.status !== 'success') {
|
||||
return {
|
||||
status: result.status,
|
||||
message: result.message || '查询失败',
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 检查余额是否正在加载中
|
||||
function isBalanceLoading(providerId: string): boolean {
|
||||
const result = balanceCache.value[providerId]
|
||||
return result?.status === 'pending'
|
||||
}
|
||||
|
||||
// 获取 provider 的签到信息(从 extra 字段)
|
||||
function getProviderCheckin(providerId: string): { success: boolean | null; message: string } | null {
|
||||
const result = balanceCache.value[providerId]
|
||||
if (!result || result.status !== 'success' || !result.data) {
|
||||
return null
|
||||
}
|
||||
const data = result.data as Record<string, any>
|
||||
const extra = data.extra
|
||||
if (!extra || extra.checkin_success === undefined) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
success: extra.checkin_success,
|
||||
message: extra.checkin_message || '',
|
||||
}
|
||||
}
|
||||
|
||||
// 获取 provider 的 Cookie 失效状态(从 extra 字段)
|
||||
function getProviderCookieExpired(providerId: string): { expired: boolean; message: string } | null {
|
||||
const result = balanceCache.value[providerId]
|
||||
if (!result || !result.data) {
|
||||
return null
|
||||
}
|
||||
if (result.status !== 'success' && result.status !== 'auth_expired') {
|
||||
return null
|
||||
}
|
||||
const data = result.data as Record<string, any>
|
||||
const extra = data.extra
|
||||
if (!extra || !extra.cookie_expired) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
expired: true,
|
||||
message: extra.cookie_expired_message || 'Cookie 已失效',
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化余额显示
|
||||
function formatBalanceDisplay(balance: { available: number | null; currency: string } | null): string {
|
||||
if (!balance || balance.available == null) {
|
||||
return '-'
|
||||
}
|
||||
const symbol = balance.currency === 'USD' ? '$' : balance.currency
|
||||
return `${symbol}${balance.available.toFixed(2)}`
|
||||
}
|
||||
|
||||
// 格式化重置倒计时(从 Unix 时间戳)
|
||||
function formatResetCountdown(resetsAt: number): string {
|
||||
// 依赖 tickCounter 触发响应式更新
|
||||
void tickCounter.value
|
||||
|
||||
const now = Date.now() / 1000
|
||||
const diff = resetsAt - now
|
||||
|
||||
if (diff <= 0) return '即将重置'
|
||||
|
||||
const totalHours = Math.floor(diff / 3600)
|
||||
const minutes = Math.floor((diff % 3600) / 60)
|
||||
const seconds = Math.floor(diff % 60)
|
||||
|
||||
const pad = (n: number) => n.toString().padStart(2, '0')
|
||||
|
||||
if (totalHours > 0) {
|
||||
return `${totalHours}:${pad(minutes)}:${pad(seconds)}`
|
||||
}
|
||||
return `${minutes}:${pad(seconds)}`
|
||||
}
|
||||
|
||||
// 获取 provider 余额的额外信息(如窗口限额)
|
||||
function getProviderBalanceExtra(providerId: string, architectureId?: string): BalanceExtraItem[] {
|
||||
if (!architectureId) return []
|
||||
|
||||
const result = balanceCache.value[providerId]
|
||||
// auth_expired 时余额数据仍有效(只是签到 Cookie 失效)
|
||||
if (!result || (result.status !== 'success' && result.status !== 'auth_expired') || !result.data) {
|
||||
return []
|
||||
}
|
||||
|
||||
const data = result.data as Record<string, any>
|
||||
const extra = data.extra
|
||||
if (!extra) return []
|
||||
|
||||
// 从 schema 缓存中获取格式化配置
|
||||
const schema = architectureSchemas.value[architectureId]
|
||||
if (!schema) return []
|
||||
|
||||
return formatBalanceExtraFromSchema(schema, extra)
|
||||
}
|
||||
|
||||
// 配额已用颜色(根据使用比例)
|
||||
function getQuotaUsedColorClass(provider: ProviderWithEndpointsSummary): string {
|
||||
const used = provider.monthly_used_usd ?? 0
|
||||
const quota = provider.monthly_quota_usd ?? 0
|
||||
if (quota <= 0) return 'text-foreground'
|
||||
const ratio = used / quota
|
||||
if (ratio >= 0.9) return 'text-red-600 dark:text-red-400'
|
||||
if (ratio >= 0.7) return 'text-amber-600 dark:text-amber-400'
|
||||
return 'text-foreground'
|
||||
}
|
||||
|
||||
// 组件卸载时清理
|
||||
function cleanup() {
|
||||
stopTick()
|
||||
pendingTimers.forEach(clearTimeout)
|
||||
pendingTimers.clear()
|
||||
}
|
||||
|
||||
onUnmounted(cleanup)
|
||||
|
||||
return {
|
||||
balanceCache,
|
||||
loadArchitectureSchemas,
|
||||
loadBalances,
|
||||
getProviderBalance,
|
||||
getProviderBalanceBreakdown,
|
||||
getProviderBalanceError,
|
||||
isBalanceLoading,
|
||||
getProviderCheckin,
|
||||
getProviderCookieExpired,
|
||||
formatBalanceDisplay,
|
||||
formatResetCountdown,
|
||||
getProviderBalanceExtra,
|
||||
getQuotaUsedColorClass,
|
||||
tickCounter,
|
||||
startTick,
|
||||
stopTick,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||
|
||||
export interface FilterOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export function useProviderFilters(
|
||||
providers: () => ProviderWithEndpointsSummary[],
|
||||
globalModels: () => { id: string; name: string }[],
|
||||
) {
|
||||
// 搜索与筛选
|
||||
const searchQuery = ref('')
|
||||
const filterStatus = ref('all')
|
||||
const filterApiFormat = ref('all')
|
||||
const filterModel = ref('all')
|
||||
|
||||
const statusFilters: FilterOption[] = [
|
||||
{ value: 'all', label: '全部状态' },
|
||||
{ value: 'active', label: '活跃' },
|
||||
{ value: 'inactive', label: '已停用' },
|
||||
]
|
||||
|
||||
const apiFormatFilters: FilterOption[] = [
|
||||
{ value: 'all', label: '全部格式' },
|
||||
{ value: 'claude:chat', label: 'Claude Chat' },
|
||||
{ value: 'claude:cli', label: 'Claude CLI' },
|
||||
{ value: 'openai:chat', label: 'OpenAI Chat' },
|
||||
{ value: 'openai:cli', label: 'OpenAI CLI' },
|
||||
{ value: 'gemini:chat', label: 'Gemini Chat' },
|
||||
{ value: 'gemini:cli', label: 'Gemini CLI' },
|
||||
]
|
||||
|
||||
// 动态计算模型筛选选项:只展示当前提供商列表中实际关联的全局模型
|
||||
const modelFilters = computed<FilterOption[]>(() => {
|
||||
const usedIds = new Set(providers().flatMap(p => p.global_model_ids || []))
|
||||
const items = globalModels()
|
||||
.filter(m => usedIds.has(m.id))
|
||||
.map(m => ({ value: m.id, label: m.name }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
return [{ value: 'all', label: '全部模型' }, ...items]
|
||||
})
|
||||
|
||||
const hasActiveFilters = computed(() => {
|
||||
return (
|
||||
searchQuery.value !== '' ||
|
||||
filterStatus.value !== 'all' ||
|
||||
filterApiFormat.value !== 'all' ||
|
||||
filterModel.value !== 'all'
|
||||
)
|
||||
})
|
||||
|
||||
// 筛选后的提供商列表
|
||||
const filteredProviders = computed(() => {
|
||||
let result = [...providers()]
|
||||
|
||||
// 搜索筛选(支持空格分隔的多关键词 AND 搜索)
|
||||
if (searchQuery.value.trim()) {
|
||||
const keywords = searchQuery.value
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter(k => k.length > 0)
|
||||
result = result.filter(p => {
|
||||
const searchableText = `${p.name}`.toLowerCase()
|
||||
return keywords.every(keyword => searchableText.includes(keyword))
|
||||
})
|
||||
}
|
||||
|
||||
// 状态筛选
|
||||
if (filterStatus.value !== 'all') {
|
||||
const isActive = filterStatus.value === 'active'
|
||||
result = result.filter(p => p.is_active === isActive)
|
||||
}
|
||||
|
||||
// API 格式筛选
|
||||
if (filterApiFormat.value !== 'all') {
|
||||
result = result.filter(
|
||||
p => p.api_formats && p.api_formats.includes(filterApiFormat.value),
|
||||
)
|
||||
}
|
||||
|
||||
// 模型筛选
|
||||
if (filterModel.value !== 'all') {
|
||||
result = result.filter(
|
||||
p => p.global_model_ids && p.global_model_ids.includes(filterModel.value),
|
||||
)
|
||||
}
|
||||
|
||||
// 排序
|
||||
return result.sort((a, b) => {
|
||||
// 1. 优先显示活跃的提供商
|
||||
if (a.is_active !== b.is_active) {
|
||||
return a.is_active ? -1 : 1
|
||||
}
|
||||
// 2. 按优先级排序
|
||||
if (a.provider_priority !== b.provider_priority) {
|
||||
return a.provider_priority - b.provider_priority
|
||||
}
|
||||
// 3. 按名称排序
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
})
|
||||
|
||||
// 分页
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
const paginatedProviders = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
const end = start + pageSize.value
|
||||
return filteredProviders.value.slice(start, end)
|
||||
})
|
||||
|
||||
// 搜索/筛选时重置分页
|
||||
watch([searchQuery, filterStatus, filterApiFormat, filterModel], () => {
|
||||
currentPage.value = 1
|
||||
})
|
||||
|
||||
function resetFilters() {
|
||||
searchQuery.value = ''
|
||||
filterStatus.value = 'all'
|
||||
filterApiFormat.value = 'all'
|
||||
filterModel.value = 'all'
|
||||
}
|
||||
|
||||
return {
|
||||
searchQuery,
|
||||
filterStatus,
|
||||
filterApiFormat,
|
||||
filterModel,
|
||||
statusFilters,
|
||||
apiFormatFilters,
|
||||
modelFilters,
|
||||
hasActiveFilters,
|
||||
filteredProviders,
|
||||
currentPage,
|
||||
pageSize,
|
||||
paginatedProviders,
|
||||
resetFilters,
|
||||
}
|
||||
}
|
||||
48
frontend/src/router/guards/adminGuard.ts
Normal file
48
frontend/src/router/guards/adminGuard.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { RouteLocationNormalized } from 'vue-router'
|
||||
import type { useAuthStore } from '@/stores/auth'
|
||||
import type { useModuleStore } from '@/stores/modules'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
/**
|
||||
* 检查管理员权限和管理端模块可用性。
|
||||
* @returns 重定向路径,或 null 表示通过
|
||||
*/
|
||||
export async function checkAdminAccess(
|
||||
to: RouteLocationNormalized,
|
||||
authStore: ReturnType<typeof useAuthStore>,
|
||||
moduleStore: ReturnType<typeof useModuleStore>
|
||||
): Promise<string | null> {
|
||||
const isAdmin = authStore.user?.role === 'admin'
|
||||
if (!isAdmin) {
|
||||
log.warn('Non-admin user attempted to access admin page, redirecting to user dashboard')
|
||||
return '/dashboard'
|
||||
}
|
||||
|
||||
// 检查路由链中是否有模块要求
|
||||
const moduleName = to.matched.find(record => record.meta.module)?.meta.module as
|
||||
| string
|
||||
| undefined
|
||||
if (!moduleName) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 确保模块状态已加载
|
||||
if (!moduleStore.loaded) {
|
||||
try {
|
||||
await moduleStore.fetchModules()
|
||||
} catch (error) {
|
||||
// fail-close: 获取模块状态失败时拒绝访问
|
||||
log.warn('Failed to fetch modules status, denying access', { error })
|
||||
return '/admin/dashboard'
|
||||
}
|
||||
}
|
||||
|
||||
// 如果模块不可用(未部署),重定向到管理员首页
|
||||
// 注意:只检查 available,不检查 enabled/active,允许管理员配置未启用的模块
|
||||
if (!moduleStore.isAvailable(moduleName)) {
|
||||
log.warn(`Module ${moduleName} is not available, redirecting to admin dashboard`)
|
||||
return '/admin/dashboard'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
37
frontend/src/router/guards/authGuard.ts
Normal file
37
frontend/src/router/guards/authGuard.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { useAuthStore } from '@/stores/auth'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
/**
|
||||
* 判断错误是否为网络错误
|
||||
*/
|
||||
function isNetworkError(error: any): boolean {
|
||||
return !error.response || error.message?.includes('Network') || error.message?.includes('timeout')
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保用户信息已加载。如果有 token 但未加载用户信息,尝试获取。
|
||||
* @returns true 如果用户已认证,false 如果认证失败
|
||||
*/
|
||||
export async function ensureUserLoaded(
|
||||
authStore: ReturnType<typeof useAuthStore>
|
||||
): Promise<boolean> {
|
||||
if (authStore.token && !authStore.user) {
|
||||
try {
|
||||
await authStore.fetchCurrentUser()
|
||||
} catch (error: any) {
|
||||
// 区分网络错误和认证错误
|
||||
if (isNetworkError(error)) {
|
||||
log.warn('Network error while fetching user info, keeping session', {
|
||||
error: error?.message
|
||||
})
|
||||
} else if (error.response?.status === 401) {
|
||||
log.info('Authentication failed, clearing session')
|
||||
authStore.logout()
|
||||
} else {
|
||||
log.warn('Failed to fetch user info, but keeping session', { error: error?.message })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return !!authStore.token
|
||||
}
|
||||
37
frontend/src/router/guards/homeGuard.ts
Normal file
37
frontend/src/router/guards/homeGuard.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { RouteLocationNormalized } from 'vue-router'
|
||||
import type { useAuthStore } from '@/stores/auth'
|
||||
|
||||
/**
|
||||
* 处理已认证用户访问首页时的重定向。
|
||||
* @returns 重定向路径,null 表示不需要重定向,空字符串表示放行(不跳转)
|
||||
*/
|
||||
export function resolveHomeRedirect(
|
||||
to: RouteLocationNormalized,
|
||||
from: RouteLocationNormalized,
|
||||
authStore: ReturnType<typeof useAuthStore>
|
||||
): string | null {
|
||||
if (to.path !== '/') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!authStore.isAuthenticated) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 已登录用户如果是从dashboard返回首页、刷新首页、或者有returnTo参数,允许访问首页
|
||||
const isFromApp =
|
||||
from.path.startsWith('/dashboard') || from.path.startsWith('/admin') || from.path === '/'
|
||||
if (to.query.returnTo || isFromApp) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// 已登录用户首次访问首页(非返回/刷新场景),根据角色跳转到对应仪表盘
|
||||
const isAdmin = authStore.user?.role === 'admin'
|
||||
const redirectPath = sessionStorage.getItem('redirectPath')
|
||||
if (redirectPath && redirectPath !== '/') {
|
||||
sessionStorage.removeItem('redirectPath')
|
||||
return redirectPath
|
||||
}
|
||||
|
||||
return isAdmin ? '/admin/dashboard' : '/dashboard'
|
||||
}
|
||||
4
frontend/src/router/guards/index.ts
Normal file
4
frontend/src/router/guards/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { ensureUserLoaded } from './authGuard'
|
||||
export { resolveHomeRedirect } from './homeGuard'
|
||||
export { checkAdminAccess } from './adminGuard'
|
||||
export { checkModuleAccess } from './moduleGuard'
|
||||
39
frontend/src/router/guards/moduleGuard.ts
Normal file
39
frontend/src/router/guards/moduleGuard.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { RouteLocationNormalized } from 'vue-router'
|
||||
import type { useModuleStore } from '@/stores/modules'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
/**
|
||||
* 检查非管理端路由的模块激活状态。
|
||||
* @returns 重定向路径,或 null 表示通过
|
||||
*/
|
||||
export async function checkModuleAccess(
|
||||
to: RouteLocationNormalized,
|
||||
moduleStore: ReturnType<typeof useModuleStore>
|
||||
): Promise<string | null> {
|
||||
// 检查路由链中是否有模块要求
|
||||
const moduleName = to.matched.find(record => record.meta.module)?.meta.module as
|
||||
| string
|
||||
| undefined
|
||||
if (!moduleName) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 确保模块状态已加载
|
||||
if (!moduleStore.loaded) {
|
||||
try {
|
||||
await moduleStore.fetchModules()
|
||||
} catch (error) {
|
||||
// fail-close: 获取模块状态失败时拒绝访问
|
||||
log.warn('Failed to fetch modules status, denying access', { error })
|
||||
return '/dashboard'
|
||||
}
|
||||
}
|
||||
|
||||
// 用户侧需要检查模块是否激活(active),而不仅仅是可用(available)
|
||||
if (!moduleStore.isActive(moduleName)) {
|
||||
log.warn(`Module ${moduleName} is not active, redirecting to user dashboard`)
|
||||
return '/dashboard'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -4,6 +4,12 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import { useModuleStore } from '@/stores/modules'
|
||||
import { importWithRetry } from '@/utils/importRetry'
|
||||
import { log } from '@/utils/logger'
|
||||
import {
|
||||
ensureUserLoaded,
|
||||
resolveHomeRedirect,
|
||||
checkAdminAccess,
|
||||
checkModuleAccess
|
||||
} from './guards'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
@@ -255,111 +261,39 @@ const router = createRouter({
|
||||
routes
|
||||
})
|
||||
|
||||
/**
|
||||
* 判断错误是否为网络错误
|
||||
*/
|
||||
function isNetworkError(error: any): boolean {
|
||||
return !error.response || error.message?.includes('Network') || error.message?.includes('timeout')
|
||||
}
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
const moduleStore = useModuleStore()
|
||||
|
||||
try {
|
||||
// 如果有token但没有用户信息,尝试获取用户信息
|
||||
if (authStore.token && !authStore.user) {
|
||||
try {
|
||||
await authStore.fetchCurrentUser()
|
||||
} catch (error: any) {
|
||||
// 区分网络错误和认证错误
|
||||
if (isNetworkError(error)) {
|
||||
log.warn('Network error while fetching user info, keeping session', { error: error?.message })
|
||||
} else if (error.response?.status === 401) {
|
||||
log.info('Authentication failed, clearing session')
|
||||
authStore.logout()
|
||||
} else {
|
||||
log.warn('Failed to fetch user info, but keeping session', { error: error?.message })
|
||||
}
|
||||
}
|
||||
}
|
||||
const isAuthenticated = await ensureUserLoaded(authStore)
|
||||
|
||||
// 检查整个路由匹配记录链中的 meta
|
||||
// 首页重定向
|
||||
const homeRedirect = resolveHomeRedirect(to, from, authStore)
|
||||
if (homeRedirect !== null) return next(homeRedirect === '' ? undefined : homeRedirect)
|
||||
|
||||
// 需要认证但未认证
|
||||
const requiresAuth = to.matched.some(record => record.meta.requiresAuth !== false)
|
||||
const requiresAdmin = to.matched.some(record => record.meta.requiresAdmin)
|
||||
const moduleName = to.matched.find(record => record.meta.module)?.meta.module as string | undefined
|
||||
|
||||
// 如果需要认证但没有token,跳转到首页
|
||||
if (requiresAuth && !authStore.token) {
|
||||
if (requiresAuth && !isAuthenticated) {
|
||||
sessionStorage.setItem('redirectPath', to.fullPath)
|
||||
log.debug('No valid token found, redirecting to home')
|
||||
next('/')
|
||||
} else if (to.path === '/' && authStore.isAuthenticated && (to.query.returnTo || from.path.startsWith('/dashboard') || from.path.startsWith('/admin') || from.path === '/')) {
|
||||
// 已登录用户如果是从dashboard返回首页、刷新首页、或者有returnTo参数,允许访问首页
|
||||
next()
|
||||
} else if (authStore.isAuthenticated && to.path === '/' && !to.query.returnTo) {
|
||||
// 已登录用户首次访问首页(非返回/刷新场景),根据角色跳转到对应仪表盘
|
||||
const isAdmin = authStore.user?.role === 'admin'
|
||||
const redirectPath = sessionStorage.getItem('redirectPath')
|
||||
if (redirectPath && redirectPath !== '/') {
|
||||
sessionStorage.removeItem('redirectPath')
|
||||
next(redirectPath)
|
||||
} else {
|
||||
next(isAdmin ? '/admin/dashboard' : '/dashboard')
|
||||
}
|
||||
} else if (requiresAdmin) {
|
||||
// 需要管理员权限的页面
|
||||
const isAdmin = authStore.user?.role === 'admin'
|
||||
if (!isAdmin) {
|
||||
log.warn('Non-admin user attempted to access admin page, redirecting to user dashboard')
|
||||
next('/dashboard')
|
||||
} else {
|
||||
// 检查模块可用性
|
||||
if (moduleName) {
|
||||
// 确保模块状态已加载
|
||||
if (!moduleStore.loaded) {
|
||||
try {
|
||||
await moduleStore.fetchModules()
|
||||
} catch (error) {
|
||||
// fail-close: 获取模块状态失败时拒绝访问
|
||||
log.warn('Failed to fetch modules status, denying access', { error })
|
||||
next('/admin/dashboard')
|
||||
return
|
||||
}
|
||||
}
|
||||
// 如果模块不可用(未部署),重定向到管理员首页
|
||||
// 注意:只检查 available,不检查 enabled/active,允许管理员配置未启用的模块
|
||||
if (!moduleStore.isAvailable(moduleName)) {
|
||||
log.warn(`Module ${moduleName} is not available, redirecting to admin dashboard`)
|
||||
next('/admin/dashboard')
|
||||
return
|
||||
}
|
||||
}
|
||||
next()
|
||||
}
|
||||
} else if (moduleName) {
|
||||
// 非管理员页面但需要模块的路由(如用户侧访问令牌)
|
||||
// 确保模块状态已加载
|
||||
if (!moduleStore.loaded) {
|
||||
try {
|
||||
await moduleStore.fetchModules()
|
||||
} catch (error) {
|
||||
// fail-close: 获取模块状态失败时拒绝访问
|
||||
log.warn('Failed to fetch modules status, denying access', { error })
|
||||
next('/dashboard')
|
||||
return
|
||||
}
|
||||
}
|
||||
// 用户侧需要检查模块是否激活(active),而不仅仅是可用(available)
|
||||
if (!moduleStore.isActive(moduleName)) {
|
||||
log.warn(`Module ${moduleName} is not active, redirecting to user dashboard`)
|
||||
next('/dashboard')
|
||||
return
|
||||
}
|
||||
next()
|
||||
} else {
|
||||
next()
|
||||
return next('/')
|
||||
}
|
||||
|
||||
// 管理端检查
|
||||
const requiresAdmin = to.matched.some(record => record.meta.requiresAdmin)
|
||||
if (requiresAdmin) {
|
||||
const adminRedirect = await checkAdminAccess(to, authStore, moduleStore)
|
||||
if (adminRedirect) return next(adminRedirect)
|
||||
}
|
||||
|
||||
// 非管理端的模块检查
|
||||
if (!requiresAdmin) {
|
||||
const moduleRedirect = await checkModuleAccess(to, moduleStore)
|
||||
if (moduleRedirect) return next(moduleRedirect)
|
||||
}
|
||||
|
||||
next()
|
||||
} catch (error) {
|
||||
log.error('Router guard error', error)
|
||||
// 发生错误时,直接放行,不要乱跳转
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
148
frontend/src/views/admin/system-settings/BasicConfigSection.vue
Normal file
148
frontend/src/views/admin/system-settings/BasicConfigSection.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<CardSection
|
||||
title="基础配置"
|
||||
description="配置系统默认参数"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="loading || !hasChanges"
|
||||
@click="$emit('save')"
|
||||
>
|
||||
{{ loading ? '保存中...' : '保存' }}
|
||||
</Button>
|
||||
</template>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<Label
|
||||
for="default-quota"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
默认用户配额(美元)
|
||||
</Label>
|
||||
<Input
|
||||
id="default-quota"
|
||||
:model-value="defaultUserQuotaUsd"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="10.00"
|
||||
class="mt-1"
|
||||
@update:model-value="$emit('update:defaultUserQuotaUsd', Number($event))"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
新用户注册时的默认配额
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="rate-limit"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
每分钟请求限制
|
||||
</Label>
|
||||
<Input
|
||||
id="rate-limit"
|
||||
:model-value="rateLimitPerMinute"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
class="mt-1"
|
||||
@update:model-value="$emit('update:rateLimitPerMinute', Number($event))"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
0 表示不限制
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center h-full">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="enable-registration"
|
||||
:checked="enableRegistration"
|
||||
@update:checked="$emit('update:enableRegistration', $event)"
|
||||
/>
|
||||
<div>
|
||||
<Label
|
||||
for="enable-registration"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
开放用户注册
|
||||
</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
允许新用户自助注册账户
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center h-full">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="auto-delete-expired-keys"
|
||||
:checked="autoDeleteExpiredKeys"
|
||||
@update:checked="$emit('update:autoDeleteExpiredKeys', $event)"
|
||||
/>
|
||||
<div>
|
||||
<Label
|
||||
for="auto-delete-expired-keys"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
自动删除过期 Key
|
||||
</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
关闭时仅禁用过期的独立余额 Key
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center h-full">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="enable-format-conversion"
|
||||
:checked="enableFormatConversion"
|
||||
@update:checked="$emit('update:enableFormatConversion', $event)"
|
||||
/>
|
||||
<div>
|
||||
<Label
|
||||
for="enable-format-conversion"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
全局格式转换
|
||||
</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
开启后强制允许所有提供商接受跨格式请求
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
import Checkbox from '@/components/ui/checkbox.vue'
|
||||
import { CardSection } from '@/components/layout'
|
||||
|
||||
defineProps<{
|
||||
defaultUserQuotaUsd: number
|
||||
rateLimitPerMinute: number
|
||||
enableRegistration: boolean
|
||||
autoDeleteExpiredKeys: boolean
|
||||
enableFormatConversion: boolean
|
||||
loading: boolean
|
||||
hasChanges: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
save: []
|
||||
'update:defaultUserQuotaUsd': [value: number]
|
||||
'update:rateLimitPerMinute': [value: number]
|
||||
'update:enableRegistration': [value: boolean]
|
||||
'update:autoDeleteExpiredKeys': [value: boolean]
|
||||
'update:enableFormatConversion': [value: boolean]
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<CardSection
|
||||
title="请求记录清理策略"
|
||||
description="配置请求记录的分级保留和自动清理"
|
||||
>
|
||||
<template #actions>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch
|
||||
id="enable-auto-cleanup"
|
||||
:model-value="enableAutoCleanup"
|
||||
@update:model-value="$emit('toggleAutoCleanup', $event)"
|
||||
/>
|
||||
<div>
|
||||
<Label
|
||||
for="enable-auto-cleanup"
|
||||
class="text-sm cursor-pointer"
|
||||
>
|
||||
启用自动清理
|
||||
</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
每天凌晨执行
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="loading || !hasChanges"
|
||||
@click="$emit('save')"
|
||||
>
|
||||
{{ loading ? '保存中...' : '保存' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<Label
|
||||
for="detail-log-retention-days"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
详细记录保留天数
|
||||
</Label>
|
||||
<Input
|
||||
id="detail-log-retention-days"
|
||||
:model-value="detailLogRetentionDays"
|
||||
type="number"
|
||||
placeholder="7"
|
||||
class="mt-1"
|
||||
@update:model-value="$emit('update:detailLogRetentionDays', Number($event))"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
超过后压缩 body 字段
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="compressed-log-retention-days"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
压缩记录保留天数
|
||||
</Label>
|
||||
<Input
|
||||
id="compressed-log-retention-days"
|
||||
:model-value="compressedLogRetentionDays"
|
||||
type="number"
|
||||
placeholder="90"
|
||||
class="mt-1"
|
||||
@update:model-value="$emit('update:compressedLogRetentionDays', Number($event))"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
超过后删除 body 字段
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="header-retention-days"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
请求头保留天数
|
||||
</Label>
|
||||
<Input
|
||||
id="header-retention-days"
|
||||
:model-value="headerRetentionDays"
|
||||
type="number"
|
||||
placeholder="90"
|
||||
class="mt-1"
|
||||
@update:model-value="$emit('update:headerRetentionDays', Number($event))"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
超过后清空 headers 字段
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="log-retention-days"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
完整记录保留天数
|
||||
</Label>
|
||||
<Input
|
||||
id="log-retention-days"
|
||||
:model-value="logRetentionDays"
|
||||
type="number"
|
||||
placeholder="365"
|
||||
class="mt-1"
|
||||
@update:model-value="$emit('update:logRetentionDays', Number($event))"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
超过后删除整条记录
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="cleanup-batch-size"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
每批次清理记录数
|
||||
</Label>
|
||||
<Input
|
||||
id="cleanup-batch-size"
|
||||
:model-value="cleanupBatchSize"
|
||||
type="number"
|
||||
placeholder="1000"
|
||||
class="mt-1"
|
||||
@update:model-value="$emit('update:cleanupBatchSize', Number($event))"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
避免单次操作过大影响性能
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="audit-log-retention-days"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
审计日志保留天数
|
||||
</Label>
|
||||
<Input
|
||||
id="audit-log-retention-days"
|
||||
:model-value="auditLogRetentionDays"
|
||||
type="number"
|
||||
placeholder="30"
|
||||
class="mt-1"
|
||||
@update:model-value="$emit('update:auditLogRetentionDays', Number($event))"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
超过后删除审计日志记录
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 清理策略说明 -->
|
||||
<div class="mt-4 p-4 bg-muted/50 rounded-lg">
|
||||
<h4 class="text-sm font-medium mb-2">
|
||||
清理策略说明
|
||||
</h4>
|
||||
<div class="text-xs text-muted-foreground space-y-1">
|
||||
<p>1. <strong>详细日志阶段</strong>: 保留完整的 request_body 和 response_body</p>
|
||||
<p>2. <strong>压缩日志阶段</strong>: body 字段被压缩存储,节省空间</p>
|
||||
<p>3. <strong>统计阶段</strong>: 仅保留 tokens、成本等统计信息</p>
|
||||
<p>4. <strong>归档删除</strong>: 超过保留期限后完全删除记录</p>
|
||||
<p>5. <strong>审计日志</strong>: 独立清理,记录用户登录、操作等安全事件</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
import Switch from '@/components/ui/switch.vue'
|
||||
import { CardSection } from '@/components/layout'
|
||||
|
||||
defineProps<{
|
||||
enableAutoCleanup: boolean
|
||||
detailLogRetentionDays: number
|
||||
compressedLogRetentionDays: number
|
||||
headerRetentionDays: number
|
||||
logRetentionDays: number
|
||||
cleanupBatchSize: number
|
||||
auditLogRetentionDays: number
|
||||
loading: boolean
|
||||
hasChanges: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
save: []
|
||||
toggleAutoCleanup: [enabled: boolean]
|
||||
'update:detailLogRetentionDays': [value: number]
|
||||
'update:compressedLogRetentionDays': [value: number]
|
||||
'update:headerRetentionDays': [value: number]
|
||||
'update:logRetentionDays': [value: number]
|
||||
'update:cleanupBatchSize': [value: number]
|
||||
'update:auditLogRetentionDays': [value: number]
|
||||
}>()
|
||||
</script>
|
||||
228
frontend/src/views/admin/system-settings/ConfigImportDialog.vue
Normal file
228
frontend/src/views/admin/system-settings/ConfigImportDialog.vue
Normal file
@@ -0,0 +1,228 @@
|
||||
<template>
|
||||
<!-- 导入配置对话框 -->
|
||||
<Dialog
|
||||
:open="importDialogOpen"
|
||||
title="导入配置"
|
||||
description="选择冲突处理模式并确认导入"
|
||||
@update:open="$emit('update:importDialogOpen', $event)"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-if="importPreview"
|
||||
class="text-sm"
|
||||
>
|
||||
<p class="font-medium mb-2">
|
||||
配置预览
|
||||
</p>
|
||||
<ul class="space-y-1 text-muted-foreground">
|
||||
<li>全局模型: {{ importPreview.global_models?.length || 0 }} 个</li>
|
||||
<li>提供商: {{ importPreview.providers?.length || 0 }} 个</li>
|
||||
<li>
|
||||
端点: {{ importPreview.providers?.reduce((sum: number, p: any) => sum + (p.endpoints?.length || 0), 0) }} 个
|
||||
</li>
|
||||
<li>
|
||||
API Keys: {{ importPreview.providers?.reduce((sum: number, p: any) => sum + (p.api_keys?.length || 0), 0) }} 个
|
||||
</li>
|
||||
<li v-if="importPreview.ldap_config">
|
||||
LDAP 配置: 1 个
|
||||
</li>
|
||||
<li v-if="importPreview.oauth_providers?.length">
|
||||
OAuth Providers: {{ importPreview.oauth_providers.length }} 个
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="block text-sm font-medium mb-2">冲突处理模式</Label>
|
||||
<Select
|
||||
:model-value="mergeMode"
|
||||
:open="mergeModeSelectOpen"
|
||||
@update:model-value="$emit('update:mergeMode', $event)"
|
||||
@update:open="$emit('update:mergeModeSelectOpen', $event)"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="skip">
|
||||
跳过 - 保留现有配置
|
||||
</SelectItem>
|
||||
<SelectItem value="overwrite">
|
||||
覆盖 - 用导入配置替换
|
||||
</SelectItem>
|
||||
<SelectItem value="error">
|
||||
报错 - 遇到冲突时中止
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
<template v-if="mergeMode === 'skip'">
|
||||
已存在的配置将被保留,仅导入新配置
|
||||
</template>
|
||||
<template v-else-if="mergeMode === 'overwrite'">
|
||||
已存在的配置将被导入的配置覆盖
|
||||
</template>
|
||||
<template v-else>
|
||||
如果发现任何冲突,导入将中止并回滚
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-muted-foreground">
|
||||
注意:相同的 API Keys 会自动跳过,不会创建重复记录。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="$emit('update:importDialogOpen', false); $emit('update:mergeModeSelectOpen', false)"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="importLoading"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
{{ importLoading ? '导入中...' : '确认导入' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- 导入结果对话框 -->
|
||||
<Dialog
|
||||
:open="importResultDialogOpen"
|
||||
title="导入完成"
|
||||
@update:open="$emit('update:importResultDialogOpen', $event)"
|
||||
>
|
||||
<div
|
||||
v-if="importResult"
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
全局模型
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
创建: {{ importResult.stats.global_models.created }},
|
||||
更新: {{ importResult.stats.global_models.updated }},
|
||||
跳过: {{ importResult.stats.global_models.skipped }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
提供商
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
创建: {{ importResult.stats.providers.created }},
|
||||
更新: {{ importResult.stats.providers.updated }},
|
||||
跳过: {{ importResult.stats.providers.skipped }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
端点
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
创建: {{ importResult.stats.endpoints.created }},
|
||||
更新: {{ importResult.stats.endpoints.updated }},
|
||||
跳过: {{ importResult.stats.endpoints.skipped }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
API Keys
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
创建: {{ importResult.stats.keys.created }},
|
||||
跳过: {{ importResult.stats.keys.skipped }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<p class="font-medium">
|
||||
模型配置
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
创建: {{ importResult.stats.models.created }},
|
||||
更新: {{ importResult.stats.models.updated }},
|
||||
跳过: {{ importResult.stats.models.skipped }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="importResult.stats.ldap">
|
||||
<p class="font-medium">
|
||||
LDAP 配置
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
创建: {{ importResult.stats.ldap.created }},
|
||||
更新: {{ importResult.stats.ldap.updated }},
|
||||
跳过: {{ importResult.stats.ldap.skipped }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="importResult.stats.oauth">
|
||||
<p class="font-medium">
|
||||
OAuth Providers
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
创建: {{ importResult.stats.oauth.created }},
|
||||
更新: {{ importResult.stats.oauth.updated }},
|
||||
跳过: {{ importResult.stats.oauth.skipped }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="importResult.stats.errors.length > 0"
|
||||
class="p-3 bg-destructive/10 rounded-lg"
|
||||
>
|
||||
<p class="font-medium text-destructive mb-2">
|
||||
警告信息
|
||||
</p>
|
||||
<ul class="text-sm text-destructive space-y-1">
|
||||
<li
|
||||
v-for="(err, index) in importResult.stats.errors"
|
||||
:key="index"
|
||||
>
|
||||
{{ err }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button @click="$emit('update:importResultDialogOpen', false)">
|
||||
确定
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
import Select from '@/components/ui/select.vue'
|
||||
import SelectTrigger from '@/components/ui/select-trigger.vue'
|
||||
import SelectValue from '@/components/ui/select-value.vue'
|
||||
import SelectContent from '@/components/ui/select-content.vue'
|
||||
import SelectItem from '@/components/ui/select-item.vue'
|
||||
import { Dialog } from '@/components/ui'
|
||||
import type { ConfigExportData, ConfigImportResponse } from '@/api/admin'
|
||||
|
||||
defineProps<{
|
||||
importDialogOpen: boolean
|
||||
importResultDialogOpen: boolean
|
||||
importPreview: ConfigExportData | null
|
||||
importResult: ConfigImportResponse | null
|
||||
mergeMode: 'skip' | 'overwrite' | 'error'
|
||||
mergeModeSelectOpen: boolean
|
||||
importLoading: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
confirm: []
|
||||
'update:importDialogOpen': [value: boolean]
|
||||
'update:importResultDialogOpen': [value: boolean]
|
||||
'update:mergeMode': [value: 'skip' | 'overwrite' | 'error']
|
||||
'update:mergeModeSelectOpen': [value: boolean]
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<CardSection
|
||||
title="配置管理"
|
||||
description="导出或导入提供商和模型配置,便于备份或迁移"
|
||||
>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<p class="text-sm text-muted-foreground mb-3">
|
||||
导出当前所有提供商、端点、API Key 和模型配置到 JSON 文件
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="exportLoading"
|
||||
@click="$emit('export')"
|
||||
>
|
||||
<Download class="w-4 h-4 mr-2" />
|
||||
{{ exportLoading ? '导出中...' : '导出配置' }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<p class="text-sm text-muted-foreground mb-3">
|
||||
从 JSON 文件导入配置,支持跳过、覆盖或报错三种冲突处理模式
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
ref="configFileInput"
|
||||
type="file"
|
||||
accept=".json"
|
||||
class="hidden"
|
||||
@change="$emit('fileSelect', $event)"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="importLoading"
|
||||
@click="triggerFileSelect"
|
||||
>
|
||||
<Upload class="w-4 h-4 mr-2" />
|
||||
{{ importLoading ? '导入中...' : '导入配置' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Download, Upload } from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import { CardSection } from '@/components/layout'
|
||||
|
||||
defineProps<{
|
||||
exportLoading: boolean
|
||||
importLoading: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
export: []
|
||||
fileSelect: [event: Event]
|
||||
}>()
|
||||
|
||||
const configFileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function triggerFileSelect() {
|
||||
configFileInput.value?.click()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<CardSection
|
||||
title="网络代理"
|
||||
description="配置提供商出站请求的默认代理,仅影响大模型 API、余额查询、OAuth 等提供商请求"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="loading || !hasChanges"
|
||||
@click="$emit('save')"
|
||||
>
|
||||
{{ loading ? '保存中...' : '保存' }}
|
||||
</Button>
|
||||
</template>
|
||||
<div class="max-w-md">
|
||||
<Label class="block text-sm font-medium mb-1">
|
||||
默认代理节点
|
||||
</Label>
|
||||
<Select
|
||||
:model-value="proxyNodeId || '__direct__'"
|
||||
@update:model-value="(v: string) => $emit('update:proxyNodeId', v === '__direct__' ? null : v)"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="直连(不使用代理)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__direct__">
|
||||
直连(不使用代理)
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
v-for="node in onlineNodes"
|
||||
:key="node.id"
|
||||
:value="node.id"
|
||||
>
|
||||
{{ node.name }}{{ node.region ? ` · ${node.region}` : '' }} ({{ node.ip }}:{{ node.port }})
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
对未单独配置代理的提供商生效,覆盖大模型 API 请求、余额查询、OAuth 刷新等。不影响系统内部接口。
|
||||
</p>
|
||||
</div>
|
||||
</CardSection>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
import Select from '@/components/ui/select.vue'
|
||||
import SelectTrigger from '@/components/ui/select-trigger.vue'
|
||||
import SelectValue from '@/components/ui/select-value.vue'
|
||||
import SelectContent from '@/components/ui/select-content.vue'
|
||||
import SelectItem from '@/components/ui/select-item.vue'
|
||||
import { CardSection } from '@/components/layout'
|
||||
|
||||
interface ProxyNode {
|
||||
id: string
|
||||
name: string
|
||||
region?: string | null
|
||||
ip: string
|
||||
port: number
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
proxyNodeId: string | null
|
||||
onlineNodes: ProxyNode[]
|
||||
loading: boolean
|
||||
hasChanges: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
save: []
|
||||
'update:proxyNodeId': [value: string | null]
|
||||
}>()
|
||||
</script>
|
||||
139
frontend/src/views/admin/system-settings/RequestLogSection.vue
Normal file
139
frontend/src/views/admin/system-settings/RequestLogSection.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<CardSection
|
||||
title="请求记录"
|
||||
description="控制请求/响应详情的入库方式和内容"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="loading || !hasChanges"
|
||||
@click="$emit('save')"
|
||||
>
|
||||
{{ loading ? '保存中...' : '保存' }}
|
||||
</Button>
|
||||
</template>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<Label
|
||||
for="request-log-level"
|
||||
class="block text-sm font-medium mb-2"
|
||||
>
|
||||
记录详细程度
|
||||
</Label>
|
||||
<Select
|
||||
:model-value="requestRecordLevel"
|
||||
@update:model-value="$emit('update:requestRecordLevel', $event)"
|
||||
>
|
||||
<SelectTrigger
|
||||
id="request-log-level"
|
||||
class="mt-1"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="basic">
|
||||
BASIC - 基本信息 (~1KB/条)
|
||||
</SelectItem>
|
||||
<SelectItem value="headers">
|
||||
HEADERS - 含请求头 (~2-3KB/条)
|
||||
</SelectItem>
|
||||
<SelectItem value="full">
|
||||
FULL - 完整请求响应 (~50KB/条)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
敏感信息会自动脱敏
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="max-request-body-size"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
最大请求体大小 (KB)
|
||||
</Label>
|
||||
<Input
|
||||
id="max-request-body-size"
|
||||
:model-value="maxRequestBodySizeKB"
|
||||
type="number"
|
||||
placeholder="512"
|
||||
class="mt-1"
|
||||
@update:model-value="$emit('update:maxRequestBodySizeKB', Number($event))"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
超过此大小的请求体将被截断记录
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="max-response-body-size"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
最大响应体大小 (KB)
|
||||
</Label>
|
||||
<Input
|
||||
id="max-response-body-size"
|
||||
:model-value="maxResponseBodySizeKB"
|
||||
type="number"
|
||||
placeholder="512"
|
||||
class="mt-1"
|
||||
@update:model-value="$emit('update:maxResponseBodySizeKB', Number($event))"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
超过此大小的响应体将被截断记录
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="sensitive-headers"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
敏感请求头
|
||||
</Label>
|
||||
<Input
|
||||
id="sensitive-headers"
|
||||
:model-value="sensitiveHeadersStr"
|
||||
placeholder="authorization, x-api-key, cookie"
|
||||
class="mt-1"
|
||||
@update:model-value="$emit('update:sensitiveHeadersStr', $event)"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
逗号分隔,这些请求头会被脱敏处理
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
import Select from '@/components/ui/select.vue'
|
||||
import SelectTrigger from '@/components/ui/select-trigger.vue'
|
||||
import SelectValue from '@/components/ui/select-value.vue'
|
||||
import SelectContent from '@/components/ui/select-content.vue'
|
||||
import SelectItem from '@/components/ui/select-item.vue'
|
||||
import { CardSection } from '@/components/layout'
|
||||
|
||||
defineProps<{
|
||||
requestRecordLevel: string
|
||||
maxRequestBodySizeKB: number
|
||||
maxResponseBodySizeKB: number
|
||||
sensitiveHeadersStr: string
|
||||
loading: boolean
|
||||
hasChanges: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
save: []
|
||||
'update:requestRecordLevel': [value: string]
|
||||
'update:maxRequestBodySizeKB': [value: number]
|
||||
'update:maxResponseBodySizeKB': [value: number]
|
||||
'update:sensitiveHeadersStr': [value: string]
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<CardSection
|
||||
title="定时任务"
|
||||
description="配置系统后台定时任务"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<template
|
||||
v-for="task in scheduledTasks"
|
||||
:key="task.id"
|
||||
>
|
||||
<div
|
||||
class="group relative rounded-xl border transition-all duration-300"
|
||||
:class="task.enabled
|
||||
? 'border-primary/30 bg-primary/[0.02] shadow-sm shadow-primary/5'
|
||||
: 'border-border bg-card hover:border-border/80'"
|
||||
>
|
||||
<!-- 主行 -->
|
||||
<div class="flex items-center gap-4 p-4">
|
||||
<!-- 左侧:开关 -->
|
||||
<div class="shrink-0">
|
||||
<Switch
|
||||
:id="`enable-${task.id}`"
|
||||
:model-value="task.enabled"
|
||||
@update:model-value="task.onToggle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 中间:图标、标题、描述 -->
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div
|
||||
class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 transition-colors duration-300"
|
||||
:class="task.enabled
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground'"
|
||||
>
|
||||
<component
|
||||
:is="task.icon"
|
||||
class="w-4.5 h-4.5"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="font-medium text-sm">
|
||||
{{ task.title }}
|
||||
</h4>
|
||||
<p class="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
{{ task.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:时间选择器 + 保存按钮 -->
|
||||
<div
|
||||
v-if="task.enabled && task.hasTimeConfig"
|
||||
class="flex items-center gap-2 shrink-0"
|
||||
>
|
||||
<Clock class="w-4 h-4 text-muted-foreground" />
|
||||
<Select
|
||||
:model-value="task.hour"
|
||||
@update:model-value="(val: string) => task.updateTime(val, task.minute)"
|
||||
>
|
||||
<SelectTrigger class="w-14 h-8 text-xs">
|
||||
<SelectValue placeholder="时" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="h in 24"
|
||||
:key="h - 1"
|
||||
:value="String(h - 1).padStart(2, '0')"
|
||||
>
|
||||
{{ String(h - 1).padStart(2, '0') }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span class="text-sm text-muted-foreground">:</span>
|
||||
<Select
|
||||
:model-value="task.minute"
|
||||
@update:model-value="(val: string) => task.updateTime(task.hour, val)"
|
||||
>
|
||||
<SelectTrigger class="w-14 h-8 text-xs">
|
||||
<SelectValue placeholder="分" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="m in 60"
|
||||
:key="m - 1"
|
||||
:value="String(m - 1).padStart(2, '0')"
|
||||
>
|
||||
{{ String(m - 1).padStart(2, '0') }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
v-if="task.hasChanges"
|
||||
variant="default"
|
||||
size="sm"
|
||||
class="h-8 px-2.5 text-xs"
|
||||
:disabled="task.loading"
|
||||
@click="task.onSave"
|
||||
>
|
||||
<Check
|
||||
v-if="!task.loading"
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
<Loader2
|
||||
v-else
|
||||
class="w-3.5 h-3.5 animate-spin"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 额外配置区域(仅用户配额重置任务有) -->
|
||||
<div
|
||||
v-if="task.id === 'user-quota-reset' && task.enabled"
|
||||
class="px-4 pb-4 pt-0"
|
||||
>
|
||||
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/30 border border-border/50">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-muted-foreground">重置周期</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-muted-foreground">每</span>
|
||||
<Input
|
||||
:model-value="quotaResetIntervalDays"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
class="w-14 h-7 text-xs text-center px-2"
|
||||
@update:model-value="$emit('update:quotaResetIntervalDays', Number($event))"
|
||||
/>
|
||||
<span class="text-muted-foreground">天</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[11px] text-muted-foreground mt-2 ml-1">
|
||||
滚动计算:距离上次成功执行满 N 天后再次执行
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</CardSection>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Clock, Check, Loader2 } from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Switch from '@/components/ui/switch.vue'
|
||||
import Select from '@/components/ui/select.vue'
|
||||
import SelectTrigger from '@/components/ui/select-trigger.vue'
|
||||
import SelectValue from '@/components/ui/select-value.vue'
|
||||
import SelectContent from '@/components/ui/select-content.vue'
|
||||
import SelectItem from '@/components/ui/select-item.vue'
|
||||
import { CardSection } from '@/components/layout'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
interface ScheduledTask {
|
||||
id: string
|
||||
icon: Component
|
||||
title: string
|
||||
description: string
|
||||
enabled: boolean
|
||||
hasTimeConfig: boolean
|
||||
hour: string
|
||||
minute: string
|
||||
updateTime: (hour: string, minute: string) => void
|
||||
hasChanges: boolean
|
||||
loading: boolean
|
||||
onToggle: (enabled: boolean) => void
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
scheduledTasks: ScheduledTask[]
|
||||
quotaResetIntervalDays: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:quotaResetIntervalDays': [value: number]
|
||||
}>()
|
||||
</script>
|
||||
76
frontend/src/views/admin/system-settings/SiteInfoSection.vue
Normal file
76
frontend/src/views/admin/system-settings/SiteInfoSection.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<CardSection
|
||||
title="站点信息"
|
||||
description="自定义站点名称和副标题,影响导航栏、登录页、指南页面和邮件等全站显示"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="loading || !hasChanges"
|
||||
@click="$emit('save')"
|
||||
>
|
||||
{{ loading ? '保存中...' : '保存' }}
|
||||
</Button>
|
||||
</template>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<Label
|
||||
for="site-name"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
站点名称
|
||||
</Label>
|
||||
<Input
|
||||
id="site-name"
|
||||
:model-value="siteName"
|
||||
type="text"
|
||||
placeholder="Aether"
|
||||
class="mt-1"
|
||||
@update:model-value="$emit('update:siteName', $event)"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
显示在导航栏、登录页标题和邮件中
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label
|
||||
for="site-subtitle"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
站点副标题
|
||||
</Label>
|
||||
<Input
|
||||
id="site-subtitle"
|
||||
:model-value="siteSubtitle"
|
||||
type="text"
|
||||
placeholder="AI Gateway"
|
||||
class="mt-1"
|
||||
@update:model-value="$emit('update:siteSubtitle', $event)"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
显示在导航栏品牌名称下方
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
import { CardSection } from '@/components/layout'
|
||||
|
||||
defineProps<{
|
||||
siteName: string
|
||||
siteSubtitle: string
|
||||
loading: boolean
|
||||
hasChanges: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
save: []
|
||||
'update:siteName': [value: string]
|
||||
'update:siteSubtitle': [value: string]
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<CardSection
|
||||
title="系统信息"
|
||||
description="当前系统版本和构建信息"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Label class="text-sm font-medium text-muted-foreground">版本:</Label>
|
||||
<span
|
||||
v-if="systemVersion"
|
||||
class="text-sm font-mono"
|
||||
>
|
||||
{{ systemVersion }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-sm text-muted-foreground"
|
||||
>
|
||||
加载中...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Label from '@/components/ui/label.vue'
|
||||
import { CardSection } from '@/components/layout'
|
||||
|
||||
defineProps<{
|
||||
systemVersion: string
|
||||
}>()
|
||||
</script>
|
||||
67
frontend/src/views/admin/system-settings/UserDataSection.vue
Normal file
67
frontend/src/views/admin/system-settings/UserDataSection.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<CardSection
|
||||
title="用户数据管理"
|
||||
description="导出或导入用户及其 API Keys 数据(不含管理员)"
|
||||
>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<p class="text-sm text-muted-foreground mb-3">
|
||||
导出所有普通用户及其 API Keys 到 JSON 文件
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="exportLoading"
|
||||
@click="$emit('export')"
|
||||
>
|
||||
<Download class="w-4 h-4 mr-2" />
|
||||
{{ exportLoading ? '导出中...' : '导出用户数据' }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<p class="text-sm text-muted-foreground mb-3">
|
||||
从 JSON 文件导入用户数据(需相同 ENCRYPTION_KEY)
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
ref="usersFileInput"
|
||||
type="file"
|
||||
accept=".json"
|
||||
class="hidden"
|
||||
@change="$emit('fileSelect', $event)"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="importLoading"
|
||||
@click="triggerFileSelect"
|
||||
>
|
||||
<Upload class="w-4 h-4 mr-2" />
|
||||
{{ importLoading ? '导入中...' : '导入用户数据' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Download, Upload } from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import { CardSection } from '@/components/layout'
|
||||
|
||||
defineProps<{
|
||||
exportLoading: boolean
|
||||
importLoading: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
export: []
|
||||
fileSelect: [event: Event]
|
||||
}>()
|
||||
|
||||
const usersFileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function triggerFileSelect() {
|
||||
usersFileInput.value?.click()
|
||||
}
|
||||
</script>
|
||||
183
frontend/src/views/admin/system-settings/UsersImportDialog.vue
Normal file
183
frontend/src/views/admin/system-settings/UsersImportDialog.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<!-- 用户数据导入对话框 -->
|
||||
<Dialog
|
||||
:open="importUsersDialogOpen"
|
||||
title="导入用户数据"
|
||||
description="选择冲突处理模式并确认导入"
|
||||
@update:open="$emit('update:importUsersDialogOpen', $event)"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-if="importUsersPreview"
|
||||
class="text-sm"
|
||||
>
|
||||
<p class="font-medium mb-2">
|
||||
数据预览
|
||||
</p>
|
||||
<ul class="space-y-1 text-muted-foreground">
|
||||
<li>用户: {{ importUsersPreview.users?.length || 0 }} 个</li>
|
||||
<li>
|
||||
API Keys: {{ importUsersPreview.users?.reduce((sum: number, u: any) => sum + (u.api_keys?.length || 0), 0) }} 个
|
||||
</li>
|
||||
<li v-if="importUsersPreview.standalone_keys?.length">
|
||||
独立余额 Keys: {{ importUsersPreview.standalone_keys.length }} 个
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="block text-sm font-medium mb-2">冲突处理模式</Label>
|
||||
<Select
|
||||
:model-value="usersMergeMode"
|
||||
:open="usersMergeModeSelectOpen"
|
||||
@update:model-value="$emit('update:usersMergeMode', $event)"
|
||||
@update:open="$emit('update:usersMergeModeSelectOpen', $event)"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="skip">
|
||||
跳过 - 保留现有用户
|
||||
</SelectItem>
|
||||
<SelectItem value="overwrite">
|
||||
覆盖 - 用导入数据替换
|
||||
</SelectItem>
|
||||
<SelectItem value="error">
|
||||
报错 - 遇到冲突时中止
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
<template v-if="usersMergeMode === 'skip'">
|
||||
已存在的用户将被保留,仅导入新用户
|
||||
</template>
|
||||
<template v-else-if="usersMergeMode === 'overwrite'">
|
||||
已存在的用户将被导入的数据覆盖
|
||||
</template>
|
||||
<template v-else>
|
||||
如果发现任何冲突,导入将中止并回滚
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-muted-foreground">
|
||||
注意:用户 API Keys 需要目标系统使用相同的 ENCRYPTION_KEY 环境变量才能正常工作。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="$emit('update:importUsersDialogOpen', false); $emit('update:usersMergeModeSelectOpen', false)"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="importUsersLoading"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
{{ importUsersLoading ? '导入中...' : '确认导入' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- 用户数据导入结果对话框 -->
|
||||
<Dialog
|
||||
:open="importUsersResultDialogOpen"
|
||||
title="用户数据导入完成"
|
||||
@update:open="$emit('update:importUsersResultDialogOpen', $event)"
|
||||
>
|
||||
<div
|
||||
v-if="importUsersResult"
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
用户
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
创建: {{ importUsersResult.stats.users.created }},
|
||||
更新: {{ importUsersResult.stats.users.updated }},
|
||||
跳过: {{ importUsersResult.stats.users.skipped }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
API Keys
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
创建: {{ importUsersResult.stats.api_keys.created }},
|
||||
跳过: {{ importUsersResult.stats.api_keys.skipped }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="importUsersResult.stats.standalone_keys"
|
||||
class="col-span-2"
|
||||
>
|
||||
<p class="font-medium">
|
||||
独立余额 Keys
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
创建: {{ importUsersResult.stats.standalone_keys.created }},
|
||||
跳过: {{ importUsersResult.stats.standalone_keys.skipped }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="importUsersResult.stats.errors.length > 0"
|
||||
class="p-3 bg-destructive/10 rounded-lg"
|
||||
>
|
||||
<p class="font-medium text-destructive mb-2">
|
||||
警告信息
|
||||
</p>
|
||||
<ul class="text-sm text-destructive space-y-1">
|
||||
<li
|
||||
v-for="(err, index) in importUsersResult.stats.errors"
|
||||
:key="index"
|
||||
>
|
||||
{{ err }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button @click="$emit('update:importUsersResultDialogOpen', false)">
|
||||
确定
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
import Select from '@/components/ui/select.vue'
|
||||
import SelectTrigger from '@/components/ui/select-trigger.vue'
|
||||
import SelectValue from '@/components/ui/select-value.vue'
|
||||
import SelectContent from '@/components/ui/select-content.vue'
|
||||
import SelectItem from '@/components/ui/select-item.vue'
|
||||
import { Dialog } from '@/components/ui'
|
||||
import type { UsersExportData, UsersImportResponse } from '@/api/admin'
|
||||
|
||||
defineProps<{
|
||||
importUsersDialogOpen: boolean
|
||||
importUsersResultDialogOpen: boolean
|
||||
importUsersPreview: UsersExportData | null
|
||||
importUsersResult: UsersImportResponse | null
|
||||
usersMergeMode: 'skip' | 'overwrite' | 'error'
|
||||
usersMergeModeSelectOpen: boolean
|
||||
importUsersLoading: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
confirm: []
|
||||
'update:importUsersDialogOpen': [value: boolean]
|
||||
'update:importUsersResultDialogOpen': [value: boolean]
|
||||
'update:usersMergeMode': [value: 'skip' | 'overwrite' | 'error']
|
||||
'update:usersMergeModeSelectOpen': [value: boolean]
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,240 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import {
|
||||
adminApi,
|
||||
type ConfigExportData,
|
||||
type ConfigImportResponse,
|
||||
type UsersExportData,
|
||||
type UsersImportResponse,
|
||||
} from '@/api/admin'
|
||||
import { log } from '@/utils/logger'
|
||||
import type { SystemConfig } from './useSystemConfig'
|
||||
|
||||
// 文件大小限制 (10MB)
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
|
||||
export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
|
||||
const { success, error } = useToast()
|
||||
|
||||
// 配置导出/导入相关
|
||||
const exportLoading = ref(false)
|
||||
const importLoading = ref(false)
|
||||
const importDialogOpen = ref(false)
|
||||
const importResultDialogOpen = ref(false)
|
||||
const configFileInput = ref<HTMLInputElement | null>(null)
|
||||
const importPreview = ref<ConfigExportData | null>(null)
|
||||
const importResult = ref<ConfigImportResponse | null>(null)
|
||||
const mergeMode = ref<'skip' | 'overwrite' | 'error'>('skip')
|
||||
const mergeModeSelectOpen = ref(false)
|
||||
|
||||
// 用户数据导出/导入相关
|
||||
const exportUsersLoading = ref(false)
|
||||
const importUsersLoading = ref(false)
|
||||
const importUsersDialogOpen = ref(false)
|
||||
const importUsersResultDialogOpen = ref(false)
|
||||
const usersFileInput = ref<HTMLInputElement | null>(null)
|
||||
const importUsersPreview = ref<UsersExportData | null>(null)
|
||||
const importUsersResult = ref<UsersImportResponse | null>(null)
|
||||
const usersMergeMode = ref<'skip' | 'overwrite' | 'error'>('skip')
|
||||
const usersMergeModeSelectOpen = ref(false)
|
||||
|
||||
// 导出配置
|
||||
async function handleExportConfig() {
|
||||
exportLoading.value = true
|
||||
try {
|
||||
const data = await adminApi.exportConfig()
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${systemConfig.value.site_name.toLowerCase()}-config-${new Date().toISOString().slice(0, 10)}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
success('配置已导出')
|
||||
} catch (err) {
|
||||
error('导出配置失败')
|
||||
log.error('导出配置失败:', err)
|
||||
} finally {
|
||||
exportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 触发文件选择
|
||||
function triggerConfigFileSelect() {
|
||||
configFileInput.value?.click()
|
||||
}
|
||||
|
||||
// 处理文件选择
|
||||
function handleConfigFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
error('文件大小不能超过 10MB')
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const content = e.target?.result as string
|
||||
const data = JSON.parse(content) as ConfigExportData
|
||||
|
||||
if (!data.version) {
|
||||
error('无效的配置文件:缺少版本信息')
|
||||
return
|
||||
}
|
||||
|
||||
importPreview.value = data
|
||||
mergeMode.value = 'skip'
|
||||
importDialogOpen.value = true
|
||||
} catch (err) {
|
||||
error('解析配置文件失败,请确保是有效的 JSON 文件')
|
||||
log.error('解析配置文件失败:', err)
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
// 确认导入
|
||||
async function confirmImport() {
|
||||
if (!importPreview.value) return
|
||||
|
||||
importLoading.value = true
|
||||
try {
|
||||
const result = await adminApi.importConfig({
|
||||
...importPreview.value,
|
||||
merge_mode: mergeMode.value,
|
||||
})
|
||||
importResult.value = result
|
||||
importDialogOpen.value = false
|
||||
mergeModeSelectOpen.value = false
|
||||
importResultDialogOpen.value = true
|
||||
success('配置导入成功')
|
||||
} catch (err: any) {
|
||||
error(err.response?.data?.detail || '导入配置失败')
|
||||
log.error('导入配置失败:', err)
|
||||
} finally {
|
||||
importLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 导出用户数据
|
||||
async function handleExportUsers() {
|
||||
exportUsersLoading.value = true
|
||||
try {
|
||||
const data = await adminApi.exportUsers()
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${systemConfig.value.site_name.toLowerCase()}-users-${new Date().toISOString().slice(0, 10)}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
success('用户数据已导出')
|
||||
} catch (err) {
|
||||
error('导出用户数据失败')
|
||||
log.error('导出用户数据失败:', err)
|
||||
} finally {
|
||||
exportUsersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 触发用户数据文件选择
|
||||
function triggerUsersFileSelect() {
|
||||
usersFileInput.value?.click()
|
||||
}
|
||||
|
||||
// 处理用户数据文件选择
|
||||
function handleUsersFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
error('文件大小不能超过 10MB')
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const content = e.target?.result as string
|
||||
const data = JSON.parse(content) as UsersExportData
|
||||
|
||||
importUsersPreview.value = data
|
||||
usersMergeMode.value = 'skip'
|
||||
importUsersDialogOpen.value = true
|
||||
} catch (err) {
|
||||
error('解析用户数据文件失败,请确保是有效的 JSON 文件')
|
||||
log.error('解析用户数据文件失败:', err)
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
// 确认导入用户数据
|
||||
async function confirmImportUsers() {
|
||||
if (!importUsersPreview.value) return
|
||||
|
||||
importUsersLoading.value = true
|
||||
try {
|
||||
const result = await adminApi.importUsers({
|
||||
...importUsersPreview.value,
|
||||
merge_mode: usersMergeMode.value,
|
||||
})
|
||||
importUsersResult.value = result
|
||||
importUsersDialogOpen.value = false
|
||||
usersMergeModeSelectOpen.value = false
|
||||
importUsersResultDialogOpen.value = true
|
||||
success('用户数据导入成功')
|
||||
} catch (err: any) {
|
||||
error(err.response?.data?.detail || '导入用户数据失败')
|
||||
log.error('导入用户数据失败:', err)
|
||||
} finally {
|
||||
importUsersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 配置导出/导入
|
||||
exportLoading,
|
||||
importLoading,
|
||||
importDialogOpen,
|
||||
importResultDialogOpen,
|
||||
configFileInput,
|
||||
importPreview,
|
||||
importResult,
|
||||
mergeMode,
|
||||
mergeModeSelectOpen,
|
||||
handleExportConfig,
|
||||
triggerConfigFileSelect,
|
||||
handleConfigFileSelect,
|
||||
confirmImport,
|
||||
// 用户数据导出/导入
|
||||
exportUsersLoading,
|
||||
importUsersLoading,
|
||||
importUsersDialogOpen,
|
||||
importUsersResultDialogOpen,
|
||||
usersFileInput,
|
||||
importUsersPreview,
|
||||
importUsersResult,
|
||||
usersMergeMode,
|
||||
usersMergeModeSelectOpen,
|
||||
handleExportUsers,
|
||||
triggerUsersFileSelect,
|
||||
handleUsersFileSelect,
|
||||
confirmImportUsers,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { ref, computed, type Ref } from 'vue'
|
||||
import { CalendarCheck, RotateCcw, RefreshCw } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { log } from '@/utils/logger'
|
||||
import type { SystemConfig } from './useSystemConfig'
|
||||
|
||||
export function useScheduledTasks(systemConfig: Ref<SystemConfig>) {
|
||||
const { success, error } = useToast()
|
||||
|
||||
const checkinConfigLoading = ref(false)
|
||||
const quotaResetConfigLoading = ref(false)
|
||||
|
||||
// 签到时间的原始值(用于回滚)
|
||||
const previousCheckinTime = ref('')
|
||||
// 用户配额重置时间的原始值
|
||||
const previousUserQuotaResetTime = ref('')
|
||||
const previousUserQuotaResetIntervalDays = ref(1)
|
||||
|
||||
// 初始化原始值(在配置加载完成后调用)
|
||||
function initPreviousValues() {
|
||||
previousCheckinTime.value = systemConfig.value.provider_checkin_time
|
||||
previousUserQuotaResetTime.value = systemConfig.value.user_quota_reset_time
|
||||
previousUserQuotaResetIntervalDays.value = systemConfig.value.user_quota_reset_interval_days
|
||||
}
|
||||
|
||||
// 签到时间
|
||||
const checkinHour = computed(() => {
|
||||
const time = systemConfig.value.provider_checkin_time
|
||||
if (!time || !time.includes(':')) return '01'
|
||||
return time.split(':')[0]
|
||||
})
|
||||
|
||||
const checkinMinute = computed(() => {
|
||||
const time = systemConfig.value.provider_checkin_time
|
||||
if (!time || !time.includes(':')) return '05'
|
||||
return time.split(':')[1]
|
||||
})
|
||||
|
||||
function updateCheckinTime(hour: string, minute: string) {
|
||||
systemConfig.value.provider_checkin_time = `${hour}:${minute}`
|
||||
}
|
||||
|
||||
const hasCheckinTimeChanged = computed(() => {
|
||||
return systemConfig.value.provider_checkin_time !== previousCheckinTime.value
|
||||
})
|
||||
|
||||
// 用户配额重置时间
|
||||
const userQuotaResetHour = computed(() => {
|
||||
const time = systemConfig.value.user_quota_reset_time
|
||||
if (!time || !time.includes(':')) return '05'
|
||||
return time.split(':')[0]
|
||||
})
|
||||
|
||||
const userQuotaResetMinute = computed(() => {
|
||||
const time = systemConfig.value.user_quota_reset_time
|
||||
if (!time || !time.includes(':')) return '00'
|
||||
return time.split(':')[1]
|
||||
})
|
||||
|
||||
function updateUserQuotaResetTime(hour: string, minute: string) {
|
||||
systemConfig.value.user_quota_reset_time = `${hour}:${minute}`
|
||||
}
|
||||
|
||||
const hasUserQuotaResetTimeChanged = computed(() => {
|
||||
return systemConfig.value.user_quota_reset_time !== previousUserQuotaResetTime.value
|
||||
})
|
||||
|
||||
const hasUserQuotaResetIntervalChanged = computed(() => {
|
||||
return (
|
||||
systemConfig.value.user_quota_reset_interval_days !==
|
||||
previousUserQuotaResetIntervalDays.value
|
||||
)
|
||||
})
|
||||
|
||||
const hasQuotaResetConfigChanged = computed(() => {
|
||||
return hasUserQuotaResetTimeChanged.value || hasUserQuotaResetIntervalChanged.value
|
||||
})
|
||||
|
||||
// Toggle handlers
|
||||
async function handleProviderCheckinToggle(enabled: boolean) {
|
||||
const previousValue = systemConfig.value.enable_provider_checkin
|
||||
systemConfig.value.enable_provider_checkin = enabled
|
||||
try {
|
||||
await adminApi.updateSystemConfig(
|
||||
'enable_provider_checkin',
|
||||
enabled,
|
||||
'是否启用 Provider 自动签到任务'
|
||||
)
|
||||
success(enabled ? '已启用自动签到' : '已禁用自动签到')
|
||||
} catch (err) {
|
||||
error('保存配置失败')
|
||||
log.error('保存自动签到配置失败:', err)
|
||||
systemConfig.value.enable_provider_checkin = previousValue
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUserQuotaResetToggle(enabled: boolean) {
|
||||
const previousValue = systemConfig.value.enable_user_quota_reset
|
||||
systemConfig.value.enable_user_quota_reset = enabled
|
||||
try {
|
||||
await adminApi.updateSystemConfig(
|
||||
'enable_user_quota_reset',
|
||||
enabled,
|
||||
'是否启用用户配额自动重置任务'
|
||||
)
|
||||
success(enabled ? '已启用用户配额自动重置' : '已禁用用户配额自动重置')
|
||||
} catch (err) {
|
||||
error('保存配置失败')
|
||||
log.error('保存用户配额自动重置配置失败:', err)
|
||||
systemConfig.value.enable_user_quota_reset = previousValue
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOAuthTokenRefreshToggle(enabled: boolean) {
|
||||
const previousValue = systemConfig.value.enable_oauth_token_refresh
|
||||
systemConfig.value.enable_oauth_token_refresh = enabled
|
||||
try {
|
||||
await adminApi.updateSystemConfig(
|
||||
'enable_oauth_token_refresh',
|
||||
enabled,
|
||||
'是否启用 OAuth Token 自动刷新任务'
|
||||
)
|
||||
success(enabled ? '已启用 OAuth Token 自动刷新' : '已禁用 OAuth Token 自动刷新')
|
||||
} catch (err) {
|
||||
error('保存配置失败')
|
||||
log.error('保存 OAuth Token 自动刷新配置失败:', err)
|
||||
systemConfig.value.enable_oauth_token_refresh = previousValue
|
||||
}
|
||||
}
|
||||
|
||||
// Save handlers
|
||||
async function handleCheckinTimeSave() {
|
||||
const newTime = systemConfig.value.provider_checkin_time
|
||||
if (!newTime || !/^\d{2}:\d{2}$/.test(newTime)) {
|
||||
error('请输入有效的时间格式 (HH:MM)')
|
||||
return
|
||||
}
|
||||
|
||||
checkinConfigLoading.value = true
|
||||
try {
|
||||
await adminApi.updateSystemConfig(
|
||||
'provider_checkin_time',
|
||||
newTime,
|
||||
'Provider 自动签到执行时间(HH:MM 格式)'
|
||||
)
|
||||
previousCheckinTime.value = newTime
|
||||
success(`签到时间已设置为 ${newTime}`)
|
||||
} catch (err) {
|
||||
error('保存签到时间失败')
|
||||
log.error('保存签到时间失败:', err)
|
||||
} finally {
|
||||
checkinConfigLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleQuotaResetConfigSave() {
|
||||
const configItems: Array<{
|
||||
key: string
|
||||
value: any
|
||||
description: string
|
||||
onSuccess: () => void
|
||||
}> = []
|
||||
|
||||
if (hasUserQuotaResetTimeChanged.value) {
|
||||
const newTime = systemConfig.value.user_quota_reset_time
|
||||
if (!newTime || !/^\d{2}:\d{2}$/.test(newTime)) {
|
||||
error('请输入有效的时间格式 (HH:MM)')
|
||||
return
|
||||
}
|
||||
configItems.push({
|
||||
key: 'user_quota_reset_time',
|
||||
value: newTime,
|
||||
description: '用户配额自动重置执行时间(HH:MM 格式)',
|
||||
onSuccess: () => {
|
||||
previousUserQuotaResetTime.value = newTime
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (hasUserQuotaResetIntervalChanged.value) {
|
||||
let intervalDays = Number(systemConfig.value.user_quota_reset_interval_days)
|
||||
if (!Number.isFinite(intervalDays) || intervalDays < 1) intervalDays = 1
|
||||
intervalDays = Math.trunc(intervalDays)
|
||||
|
||||
systemConfig.value.user_quota_reset_interval_days = intervalDays
|
||||
|
||||
configItems.push({
|
||||
key: 'user_quota_reset_interval_days',
|
||||
value: intervalDays,
|
||||
description: '用户配额重置周期(天数),滚动计算',
|
||||
onSuccess: () => {
|
||||
previousUserQuotaResetIntervalDays.value = intervalDays
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (configItems.length === 0) return
|
||||
|
||||
quotaResetConfigLoading.value = true
|
||||
const failedKeys: string[] = []
|
||||
|
||||
try {
|
||||
for (const item of configItems) {
|
||||
try {
|
||||
await adminApi.updateSystemConfig(item.key, item.value, item.description)
|
||||
item.onSuccess()
|
||||
} catch (err) {
|
||||
failedKeys.push(item.key)
|
||||
log.error(`保存配额重置配置失败: ${item.key}`, err)
|
||||
}
|
||||
}
|
||||
|
||||
if (failedKeys.length > 0) {
|
||||
error(`部分配置保存失败: ${failedKeys.join(', ')}`)
|
||||
return
|
||||
}
|
||||
|
||||
success('配额重置配置已保存')
|
||||
} finally {
|
||||
quotaResetConfigLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 定时任务配置列表
|
||||
const scheduledTasks = computed(() => [
|
||||
{
|
||||
id: 'provider-checkin',
|
||||
icon: CalendarCheck,
|
||||
title: 'Provider 自动签到',
|
||||
description: '自动执行已配置 Provider 的签到任务',
|
||||
enabled: systemConfig.value.enable_provider_checkin,
|
||||
hasTimeConfig: true,
|
||||
hour: checkinHour.value,
|
||||
minute: checkinMinute.value,
|
||||
updateTime: updateCheckinTime,
|
||||
hasChanges: hasCheckinTimeChanged.value,
|
||||
loading: checkinConfigLoading.value,
|
||||
onToggle: handleProviderCheckinToggle,
|
||||
onSave: handleCheckinTimeSave,
|
||||
},
|
||||
{
|
||||
id: 'user-quota-reset',
|
||||
icon: RotateCcw,
|
||||
title: '用户配额自动重置',
|
||||
description: '定时将用户已使用配额重置为零',
|
||||
enabled: systemConfig.value.enable_user_quota_reset,
|
||||
hasTimeConfig: true,
|
||||
hour: userQuotaResetHour.value,
|
||||
minute: userQuotaResetMinute.value,
|
||||
updateTime: updateUserQuotaResetTime,
|
||||
hasChanges: hasQuotaResetConfigChanged.value,
|
||||
loading: quotaResetConfigLoading.value,
|
||||
onToggle: handleUserQuotaResetToggle,
|
||||
onSave: handleQuotaResetConfigSave,
|
||||
},
|
||||
{
|
||||
id: 'oauth-token-refresh',
|
||||
icon: RefreshCw,
|
||||
title: 'OAuth Token 自动刷新',
|
||||
description: '主动刷新即将过期的 OAuth Token(动态调度)',
|
||||
enabled: systemConfig.value.enable_oauth_token_refresh,
|
||||
hasTimeConfig: false,
|
||||
hour: '',
|
||||
minute: '',
|
||||
updateTime: () => {},
|
||||
hasChanges: false,
|
||||
loading: false,
|
||||
onToggle: handleOAuthTokenRefreshToggle,
|
||||
onSave: () => {},
|
||||
},
|
||||
])
|
||||
|
||||
return {
|
||||
checkinConfigLoading,
|
||||
quotaResetConfigLoading,
|
||||
scheduledTasks,
|
||||
initPreviousValues,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { log } from '@/utils/logger'
|
||||
import { useSiteInfo } from '@/composables/useSiteInfo'
|
||||
|
||||
export interface SystemConfig {
|
||||
// 站点信息
|
||||
site_name: string
|
||||
site_subtitle: string
|
||||
// 网络代理
|
||||
system_proxy_node_id: string | null
|
||||
// 基础配置
|
||||
default_user_quota_usd: number
|
||||
rate_limit_per_minute: number
|
||||
enable_registration: boolean
|
||||
// 独立余额 Key 过期管理
|
||||
auto_delete_expired_keys: boolean
|
||||
// 格式转换
|
||||
enable_format_conversion: boolean
|
||||
// 请求记录
|
||||
request_record_level: string
|
||||
max_request_body_size: number
|
||||
max_response_body_size: number
|
||||
sensitive_headers: string[]
|
||||
// 请求记录清理
|
||||
enable_auto_cleanup: boolean
|
||||
detail_log_retention_days: number
|
||||
compressed_log_retention_days: number
|
||||
header_retention_days: number
|
||||
log_retention_days: number
|
||||
cleanup_batch_size: number
|
||||
audit_log_retention_days: number
|
||||
// 定时任务
|
||||
enable_provider_checkin: boolean
|
||||
provider_checkin_time: string
|
||||
enable_user_quota_reset: boolean
|
||||
user_quota_reset_time: string
|
||||
user_quota_reset_interval_days: number
|
||||
enable_oauth_token_refresh: boolean
|
||||
}
|
||||
|
||||
const CONFIG_KEYS = [
|
||||
// 站点信息
|
||||
'site_name',
|
||||
'site_subtitle',
|
||||
// 网络代理
|
||||
'system_proxy_node_id',
|
||||
// 基础配置
|
||||
'default_user_quota_usd',
|
||||
'rate_limit_per_minute',
|
||||
'enable_registration',
|
||||
// 独立余额 Key 过期管理
|
||||
'auto_delete_expired_keys',
|
||||
// 格式转换
|
||||
'enable_format_conversion',
|
||||
// 请求记录
|
||||
'request_record_level',
|
||||
'max_request_body_size',
|
||||
'max_response_body_size',
|
||||
'sensitive_headers',
|
||||
// 请求记录清理
|
||||
'enable_auto_cleanup',
|
||||
'detail_log_retention_days',
|
||||
'compressed_log_retention_days',
|
||||
'header_retention_days',
|
||||
'log_retention_days',
|
||||
'cleanup_batch_size',
|
||||
'audit_log_retention_days',
|
||||
// 定时任务
|
||||
'enable_provider_checkin',
|
||||
'provider_checkin_time',
|
||||
'enable_user_quota_reset',
|
||||
'user_quota_reset_time',
|
||||
'user_quota_reset_interval_days',
|
||||
'enable_oauth_token_refresh',
|
||||
]
|
||||
|
||||
function createDefaultConfig(): SystemConfig {
|
||||
return {
|
||||
// 站点信息
|
||||
site_name: 'Aether',
|
||||
site_subtitle: 'AI Gateway',
|
||||
// 网络代理
|
||||
system_proxy_node_id: null,
|
||||
// 基础配置
|
||||
default_user_quota_usd: 10.0,
|
||||
rate_limit_per_minute: 0,
|
||||
enable_registration: false,
|
||||
// 独立余额 Key 过期管理
|
||||
auto_delete_expired_keys: false,
|
||||
// 格式转换
|
||||
enable_format_conversion: false,
|
||||
// 请求记录
|
||||
request_record_level: 'basic',
|
||||
max_request_body_size: 1048576,
|
||||
max_response_body_size: 1048576,
|
||||
sensitive_headers: ['authorization', 'x-api-key', 'api-key', 'cookie', 'set-cookie'],
|
||||
// 请求记录清理
|
||||
enable_auto_cleanup: true,
|
||||
detail_log_retention_days: 7,
|
||||
compressed_log_retention_days: 90,
|
||||
header_retention_days: 90,
|
||||
log_retention_days: 365,
|
||||
cleanup_batch_size: 1000,
|
||||
audit_log_retention_days: 30,
|
||||
// 定时任务
|
||||
enable_provider_checkin: true,
|
||||
provider_checkin_time: '01:05',
|
||||
enable_user_quota_reset: false,
|
||||
user_quota_reset_time: '05:00',
|
||||
user_quota_reset_interval_days: 1,
|
||||
enable_oauth_token_refresh: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function useSystemConfig() {
|
||||
const { success, error } = useToast()
|
||||
const { refreshSiteInfo } = useSiteInfo()
|
||||
|
||||
const systemConfig = ref<SystemConfig>(createDefaultConfig())
|
||||
const originalConfig = ref<SystemConfig | null>(null)
|
||||
const systemVersion = ref<string>('')
|
||||
|
||||
// 各模块 loading 状态
|
||||
const siteInfoLoading = ref(false)
|
||||
const proxyConfigLoading = ref(false)
|
||||
const basicConfigLoading = ref(false)
|
||||
const logConfigLoading = ref(false)
|
||||
const cleanupConfigLoading = ref(false)
|
||||
|
||||
// 变动检测
|
||||
const hasSiteInfoChanges = computed(() => {
|
||||
if (!originalConfig.value) return false
|
||||
return (
|
||||
systemConfig.value.site_name !== originalConfig.value.site_name ||
|
||||
systemConfig.value.site_subtitle !== originalConfig.value.site_subtitle
|
||||
)
|
||||
})
|
||||
|
||||
const hasProxyConfigChanges = computed(() => {
|
||||
if (!originalConfig.value) return false
|
||||
return systemConfig.value.system_proxy_node_id !== originalConfig.value.system_proxy_node_id
|
||||
})
|
||||
|
||||
const hasBasicConfigChanges = computed(() => {
|
||||
if (!originalConfig.value) return false
|
||||
return (
|
||||
systemConfig.value.default_user_quota_usd !== originalConfig.value.default_user_quota_usd ||
|
||||
systemConfig.value.rate_limit_per_minute !== originalConfig.value.rate_limit_per_minute ||
|
||||
systemConfig.value.enable_registration !== originalConfig.value.enable_registration ||
|
||||
systemConfig.value.auto_delete_expired_keys !== originalConfig.value.auto_delete_expired_keys ||
|
||||
systemConfig.value.enable_format_conversion !== originalConfig.value.enable_format_conversion
|
||||
)
|
||||
})
|
||||
|
||||
const hasLogConfigChanges = computed(() => {
|
||||
if (!originalConfig.value) return false
|
||||
return (
|
||||
systemConfig.value.request_record_level !== originalConfig.value.request_record_level ||
|
||||
systemConfig.value.max_request_body_size !== originalConfig.value.max_request_body_size ||
|
||||
systemConfig.value.max_response_body_size !== originalConfig.value.max_response_body_size ||
|
||||
JSON.stringify(systemConfig.value.sensitive_headers) !==
|
||||
JSON.stringify(originalConfig.value.sensitive_headers)
|
||||
)
|
||||
})
|
||||
|
||||
const hasCleanupConfigChanges = computed(() => {
|
||||
if (!originalConfig.value) return false
|
||||
return (
|
||||
systemConfig.value.detail_log_retention_days !==
|
||||
originalConfig.value.detail_log_retention_days ||
|
||||
systemConfig.value.compressed_log_retention_days !==
|
||||
originalConfig.value.compressed_log_retention_days ||
|
||||
systemConfig.value.header_retention_days !== originalConfig.value.header_retention_days ||
|
||||
systemConfig.value.log_retention_days !== originalConfig.value.log_retention_days ||
|
||||
systemConfig.value.cleanup_batch_size !== originalConfig.value.cleanup_batch_size ||
|
||||
systemConfig.value.audit_log_retention_days !==
|
||||
originalConfig.value.audit_log_retention_days
|
||||
)
|
||||
})
|
||||
|
||||
// KB 和字节之间的转换
|
||||
const maxRequestBodySizeKB = computed({
|
||||
get: () => Math.round(systemConfig.value.max_request_body_size / 1024),
|
||||
set: (val: number) => {
|
||||
systemConfig.value.max_request_body_size = val * 1024
|
||||
},
|
||||
})
|
||||
|
||||
const maxResponseBodySizeKB = computed({
|
||||
get: () => Math.round(systemConfig.value.max_response_body_size / 1024),
|
||||
set: (val: number) => {
|
||||
systemConfig.value.max_response_body_size = val * 1024
|
||||
},
|
||||
})
|
||||
|
||||
// 敏感请求头数组和字符串之间的转换
|
||||
const sensitiveHeadersStr = computed({
|
||||
get: () => systemConfig.value.sensitive_headers.join(', '),
|
||||
set: (val: string) => {
|
||||
systemConfig.value.sensitive_headers = val
|
||||
.split(',')
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter((s) => s.length > 0)
|
||||
},
|
||||
})
|
||||
|
||||
// 加载配置
|
||||
async function loadSystemConfig() {
|
||||
try {
|
||||
for (const key of CONFIG_KEYS) {
|
||||
try {
|
||||
const response = await adminApi.getSystemConfig(key)
|
||||
if (response.value !== null && response.value !== undefined) {
|
||||
;(systemConfig.value as any)[key] = response.value
|
||||
}
|
||||
} catch {
|
||||
// 配置不存在时使用默认值,无需处理
|
||||
}
|
||||
}
|
||||
originalConfig.value = JSON.parse(JSON.stringify(systemConfig.value))
|
||||
} catch (err) {
|
||||
error('加载系统配置失败')
|
||||
log.error('加载系统配置失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSystemVersion() {
|
||||
try {
|
||||
const data = await adminApi.getSystemVersion()
|
||||
systemVersion.value = data.version
|
||||
} catch (err) {
|
||||
log.error('加载系统版本失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存函数
|
||||
async function saveSiteInfo() {
|
||||
siteInfoLoading.value = true
|
||||
try {
|
||||
const configItems = [
|
||||
{ key: 'site_name', value: systemConfig.value.site_name, description: '站点名称' },
|
||||
{
|
||||
key: 'site_subtitle',
|
||||
value: systemConfig.value.site_subtitle,
|
||||
description: '站点副标题',
|
||||
},
|
||||
]
|
||||
await Promise.all(
|
||||
configItems.map((item) =>
|
||||
adminApi.updateSystemConfig(item.key, item.value, item.description)
|
||||
)
|
||||
)
|
||||
if (originalConfig.value) {
|
||||
originalConfig.value.site_name = systemConfig.value.site_name
|
||||
originalConfig.value.site_subtitle = systemConfig.value.site_subtitle
|
||||
}
|
||||
await refreshSiteInfo()
|
||||
success('站点信息已保存')
|
||||
} catch (err) {
|
||||
error('保存站点信息失败')
|
||||
log.error('保存站点信息失败:', err)
|
||||
} finally {
|
||||
siteInfoLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProxyConfig() {
|
||||
proxyConfigLoading.value = true
|
||||
try {
|
||||
await adminApi.updateSystemConfig(
|
||||
'system_proxy_node_id',
|
||||
systemConfig.value.system_proxy_node_id || null,
|
||||
'系统默认代理节点 ID'
|
||||
)
|
||||
if (originalConfig.value) {
|
||||
originalConfig.value.system_proxy_node_id = systemConfig.value.system_proxy_node_id
|
||||
}
|
||||
success('网络代理配置已保存')
|
||||
} catch (err) {
|
||||
error('保存代理配置失败')
|
||||
log.error('保存代理配置失败:', err)
|
||||
} finally {
|
||||
proxyConfigLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveBasicConfig() {
|
||||
basicConfigLoading.value = true
|
||||
try {
|
||||
const configItems = [
|
||||
{
|
||||
key: 'default_user_quota_usd',
|
||||
value: systemConfig.value.default_user_quota_usd,
|
||||
description: '默认用户配额(美元)',
|
||||
},
|
||||
{
|
||||
key: 'rate_limit_per_minute',
|
||||
value: systemConfig.value.rate_limit_per_minute,
|
||||
description: '每分钟请求限制',
|
||||
},
|
||||
{
|
||||
key: 'enable_registration',
|
||||
value: systemConfig.value.enable_registration,
|
||||
description: '是否开放用户注册',
|
||||
},
|
||||
{
|
||||
key: 'auto_delete_expired_keys',
|
||||
value: systemConfig.value.auto_delete_expired_keys,
|
||||
description: '是否自动删除过期的API Key',
|
||||
},
|
||||
{
|
||||
key: 'enable_format_conversion',
|
||||
value: systemConfig.value.enable_format_conversion,
|
||||
description: '全局格式转换开关:开启时强制允许所有提供商的格式转换',
|
||||
},
|
||||
]
|
||||
|
||||
await Promise.all(
|
||||
configItems.map((item) =>
|
||||
adminApi.updateSystemConfig(item.key, item.value, item.description)
|
||||
)
|
||||
)
|
||||
if (originalConfig.value) {
|
||||
originalConfig.value.default_user_quota_usd = systemConfig.value.default_user_quota_usd
|
||||
originalConfig.value.rate_limit_per_minute = systemConfig.value.rate_limit_per_minute
|
||||
originalConfig.value.enable_registration = systemConfig.value.enable_registration
|
||||
originalConfig.value.auto_delete_expired_keys =
|
||||
systemConfig.value.auto_delete_expired_keys
|
||||
originalConfig.value.enable_format_conversion =
|
||||
systemConfig.value.enable_format_conversion
|
||||
}
|
||||
success('基础配置已保存')
|
||||
} catch (err) {
|
||||
error('保存配置失败')
|
||||
log.error('保存基础配置失败:', err)
|
||||
} finally {
|
||||
basicConfigLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveLogConfig() {
|
||||
logConfigLoading.value = true
|
||||
try {
|
||||
const configItems = [
|
||||
{
|
||||
key: 'request_record_level',
|
||||
value: systemConfig.value.request_record_level,
|
||||
description: '请求记录级别',
|
||||
},
|
||||
{
|
||||
key: 'max_request_body_size',
|
||||
value: systemConfig.value.max_request_body_size,
|
||||
description: '最大请求体记录大小(字节)',
|
||||
},
|
||||
{
|
||||
key: 'max_response_body_size',
|
||||
value: systemConfig.value.max_response_body_size,
|
||||
description: '最大响应体记录大小(字节)',
|
||||
},
|
||||
{
|
||||
key: 'sensitive_headers',
|
||||
value: systemConfig.value.sensitive_headers,
|
||||
description: '敏感请求头列表',
|
||||
},
|
||||
]
|
||||
|
||||
await Promise.all(
|
||||
configItems.map((item) =>
|
||||
adminApi.updateSystemConfig(item.key, item.value, item.description)
|
||||
)
|
||||
)
|
||||
if (originalConfig.value) {
|
||||
originalConfig.value.request_record_level = systemConfig.value.request_record_level
|
||||
originalConfig.value.max_request_body_size = systemConfig.value.max_request_body_size
|
||||
originalConfig.value.max_response_body_size = systemConfig.value.max_response_body_size
|
||||
originalConfig.value.sensitive_headers = [...systemConfig.value.sensitive_headers]
|
||||
}
|
||||
success('请求记录配置已保存')
|
||||
} catch (err) {
|
||||
error('保存配置失败')
|
||||
log.error('保存请求记录配置失败:', err)
|
||||
} finally {
|
||||
logConfigLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCleanupConfig() {
|
||||
cleanupConfigLoading.value = true
|
||||
try {
|
||||
const configItems = [
|
||||
{
|
||||
key: 'detail_log_retention_days',
|
||||
value: systemConfig.value.detail_log_retention_days,
|
||||
description: '详细记录保留天数',
|
||||
},
|
||||
{
|
||||
key: 'compressed_log_retention_days',
|
||||
value: systemConfig.value.compressed_log_retention_days,
|
||||
description: '压缩记录保留天数',
|
||||
},
|
||||
{
|
||||
key: 'header_retention_days',
|
||||
value: systemConfig.value.header_retention_days,
|
||||
description: '请求头保留天数',
|
||||
},
|
||||
{
|
||||
key: 'log_retention_days',
|
||||
value: systemConfig.value.log_retention_days,
|
||||
description: '完整记录保留天数',
|
||||
},
|
||||
{
|
||||
key: 'cleanup_batch_size',
|
||||
value: systemConfig.value.cleanup_batch_size,
|
||||
description: '每批次清理的记录数',
|
||||
},
|
||||
{
|
||||
key: 'audit_log_retention_days',
|
||||
value: systemConfig.value.audit_log_retention_days,
|
||||
description: '审计日志保留天数',
|
||||
},
|
||||
]
|
||||
|
||||
await Promise.all(
|
||||
configItems.map((item) =>
|
||||
adminApi.updateSystemConfig(item.key, item.value, item.description)
|
||||
)
|
||||
)
|
||||
if (originalConfig.value) {
|
||||
originalConfig.value.detail_log_retention_days =
|
||||
systemConfig.value.detail_log_retention_days
|
||||
originalConfig.value.compressed_log_retention_days =
|
||||
systemConfig.value.compressed_log_retention_days
|
||||
originalConfig.value.header_retention_days = systemConfig.value.header_retention_days
|
||||
originalConfig.value.log_retention_days = systemConfig.value.log_retention_days
|
||||
originalConfig.value.cleanup_batch_size = systemConfig.value.cleanup_batch_size
|
||||
originalConfig.value.audit_log_retention_days =
|
||||
systemConfig.value.audit_log_retention_days
|
||||
}
|
||||
success('请求记录清理配置已保存')
|
||||
} catch (err) {
|
||||
error('保存配置失败')
|
||||
log.error('保存请求记录清理配置失败:', err)
|
||||
} finally {
|
||||
cleanupConfigLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAutoCleanupToggle(enabled: boolean) {
|
||||
const previousValue = systemConfig.value.enable_auto_cleanup
|
||||
systemConfig.value.enable_auto_cleanup = enabled
|
||||
try {
|
||||
await adminApi.updateSystemConfig(
|
||||
'enable_auto_cleanup',
|
||||
enabled,
|
||||
'是否启用自动清理任务'
|
||||
)
|
||||
success(enabled ? '已启用自动清理' : '已禁用自动清理')
|
||||
} catch (err) {
|
||||
error('保存配置失败')
|
||||
log.error('保存自动清理配置失败:', err)
|
||||
systemConfig.value.enable_auto_cleanup = previousValue
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
systemConfig,
|
||||
originalConfig,
|
||||
systemVersion,
|
||||
// loading 状态
|
||||
siteInfoLoading,
|
||||
proxyConfigLoading,
|
||||
basicConfigLoading,
|
||||
logConfigLoading,
|
||||
cleanupConfigLoading,
|
||||
// 变动检测
|
||||
hasSiteInfoChanges,
|
||||
hasProxyConfigChanges,
|
||||
hasBasicConfigChanges,
|
||||
hasLogConfigChanges,
|
||||
hasCleanupConfigChanges,
|
||||
// 计算属性
|
||||
maxRequestBodySizeKB,
|
||||
maxResponseBodySizeKB,
|
||||
sensitiveHeadersStr,
|
||||
// 加载函数
|
||||
loadSystemConfig,
|
||||
loadSystemVersion,
|
||||
// 保存函数
|
||||
saveSiteInfo,
|
||||
saveProxyConfig,
|
||||
saveBasicConfig,
|
||||
saveLogConfig,
|
||||
saveCleanupConfig,
|
||||
handleAutoCleanupToggle,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user