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:
fawney19
2026-02-14 20:06:38 +08:00
parent 676e918edc
commit 8a670f5524
51 changed files with 7095 additions and 5359 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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)}%`
}
}
}

View File

@@ -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 状态的 provider3秒后自动重试
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,
}
}

View File

@@ -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,
}
}

View 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
}

View 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
}

View 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'
}

View File

@@ -0,0 +1,4 @@
export { ensureUserLoaded } from './authGuard'
export { resolveHomeRedirect } from './homeGuard'
export { checkAdminAccess } from './adminGuard'
export { checkModuleAccess } from './moduleGuard'

View 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
}

View File

@@ -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

View 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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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>

View 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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -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,
}
}

View File

@@ -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,
}
}

View File

@@ -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,
}
}