mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-02 15:52:26 +08:00
feat(admin): add config and user data import/export functionality
Add comprehensive import/export endpoints for: - Provider and model configuration (with key decryption for export) - User data and API keys (preserving encrypted data) Includes merge modes (skip/overwrite/error) for conflict handling, 10MB size limit for imports, and automatic cache invalidation. Also fix optional field in GlobalModelResponse tiered_pricing.
This commit is contained in:
@@ -1,5 +1,158 @@
|
||||
import apiClient from './client'
|
||||
|
||||
// 配置导出数据结构
|
||||
export interface ConfigExportData {
|
||||
version: string
|
||||
exported_at: string
|
||||
global_models: GlobalModelExport[]
|
||||
providers: ProviderExport[]
|
||||
}
|
||||
|
||||
// 用户导出数据结构
|
||||
export interface UsersExportData {
|
||||
version: string
|
||||
exported_at: string
|
||||
users: UserExport[]
|
||||
}
|
||||
|
||||
export interface UserExport {
|
||||
email: string
|
||||
username: string
|
||||
password_hash: string
|
||||
role: string
|
||||
allowed_providers?: string[] | null
|
||||
allowed_endpoints?: string[] | null
|
||||
allowed_models?: string[] | null
|
||||
model_capability_settings?: any
|
||||
quota_usd?: number | null
|
||||
used_usd?: number
|
||||
total_usd?: number
|
||||
is_active: boolean
|
||||
api_keys: UserApiKeyExport[]
|
||||
}
|
||||
|
||||
export interface UserApiKeyExport {
|
||||
key_hash: string
|
||||
key_encrypted?: string | null
|
||||
name?: string | null
|
||||
is_standalone: boolean
|
||||
balance_used_usd?: number
|
||||
current_balance_usd?: number | null
|
||||
allowed_providers?: string[] | null
|
||||
allowed_endpoints?: string[] | null
|
||||
allowed_api_formats?: string[] | null
|
||||
allowed_models?: string[] | null
|
||||
rate_limit?: number
|
||||
concurrent_limit?: number | null
|
||||
force_capabilities?: any
|
||||
is_active: boolean
|
||||
auto_delete_on_expiry?: boolean
|
||||
total_requests?: number
|
||||
total_cost_usd?: number
|
||||
}
|
||||
|
||||
export interface GlobalModelExport {
|
||||
name: string
|
||||
display_name: string
|
||||
default_price_per_request?: number | null
|
||||
default_tiered_pricing: any
|
||||
supported_capabilities?: string[] | null
|
||||
config?: any
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export interface ProviderExport {
|
||||
name: string
|
||||
display_name: string
|
||||
description?: string | null
|
||||
website?: string | null
|
||||
billing_type?: string | null
|
||||
monthly_quota_usd?: number | null
|
||||
quota_reset_day?: number
|
||||
rpm_limit?: number | null
|
||||
provider_priority?: number
|
||||
is_active: boolean
|
||||
rate_limit?: number | null
|
||||
concurrent_limit?: number | null
|
||||
config?: any
|
||||
endpoints: EndpointExport[]
|
||||
models: ModelExport[]
|
||||
}
|
||||
|
||||
export interface EndpointExport {
|
||||
api_format: string
|
||||
base_url: string
|
||||
headers?: any
|
||||
timeout?: number
|
||||
max_retries?: number
|
||||
max_concurrent?: number | null
|
||||
rate_limit?: number | null
|
||||
is_active: boolean
|
||||
custom_path?: string | null
|
||||
config?: any
|
||||
keys: KeyExport[]
|
||||
}
|
||||
|
||||
export interface KeyExport {
|
||||
api_key: string
|
||||
name?: string | null
|
||||
note?: string | null
|
||||
rate_multiplier?: number
|
||||
internal_priority?: number
|
||||
global_priority?: number | null
|
||||
max_concurrent?: number | null
|
||||
rate_limit?: number | null
|
||||
daily_limit?: number | null
|
||||
monthly_limit?: number | null
|
||||
allowed_models?: string[] | null
|
||||
capabilities?: any
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export interface ModelExport {
|
||||
global_model_name: string | null
|
||||
provider_model_name: string
|
||||
provider_model_aliases?: any
|
||||
price_per_request?: number | null
|
||||
tiered_pricing?: any
|
||||
supports_vision?: boolean | null
|
||||
supports_function_calling?: boolean | null
|
||||
supports_streaming?: boolean | null
|
||||
supports_extended_thinking?: boolean | null
|
||||
supports_image_generation?: boolean | null
|
||||
is_active: boolean
|
||||
config?: any
|
||||
}
|
||||
|
||||
export interface ConfigImportRequest extends ConfigExportData {
|
||||
merge_mode: 'skip' | 'overwrite' | 'error'
|
||||
}
|
||||
|
||||
export interface UsersImportRequest extends UsersExportData {
|
||||
merge_mode: 'skip' | 'overwrite' | 'error'
|
||||
}
|
||||
|
||||
export interface UsersImportResponse {
|
||||
message: string
|
||||
stats: {
|
||||
users: { created: number; updated: number; skipped: number }
|
||||
api_keys: { created: number; skipped: number }
|
||||
errors: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface ConfigImportResponse {
|
||||
message: string
|
||||
stats: {
|
||||
global_models: { created: number; updated: number; skipped: number }
|
||||
providers: { created: number; updated: number; skipped: number }
|
||||
endpoints: { created: number; updated: number; skipped: number }
|
||||
keys: { created: number; updated: number; skipped: number }
|
||||
models: { created: number; updated: number; skipped: number }
|
||||
errors: string[]
|
||||
}
|
||||
}
|
||||
|
||||
// API密钥管理相关接口定义
|
||||
export interface AdminApiKey {
|
||||
id: string // UUID
|
||||
@@ -173,5 +326,35 @@ export const adminApi = {
|
||||
'/api/admin/system/api-formats'
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 导出配置
|
||||
async exportConfig(): Promise<ConfigExportData> {
|
||||
const response = await apiClient.get<ConfigExportData>('/api/admin/system/config/export')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 导入配置
|
||||
async importConfig(data: ConfigImportRequest): Promise<ConfigImportResponse> {
|
||||
const response = await apiClient.post<ConfigImportResponse>(
|
||||
'/api/admin/system/config/import',
|
||||
data
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 导出用户数据
|
||||
async exportUsers(): Promise<UsersExportData> {
|
||||
const response = await apiClient.get<UsersExportData>('/api/admin/system/users/export')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 导入用户数据
|
||||
async importUsers(data: UsersImportRequest): Promise<UsersImportResponse> {
|
||||
const response = await apiClient.post<UsersImportResponse>(
|
||||
'/api/admin/system/users/import',
|
||||
data
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,7 +415,7 @@ const groupedModels = computed(() => {
|
||||
}
|
||||
|
||||
// 转换为数组并排序
|
||||
let result = Array.from(groups.values())
|
||||
const result = Array.from(groups.values())
|
||||
|
||||
// 如果有搜索词,把提供商名称/ID匹配的排在前面
|
||||
if (searchQuery.value) {
|
||||
|
||||
@@ -15,6 +15,94 @@
|
||||
</PageHeader>
|
||||
|
||||
<div class="mt-6 space-y-6">
|
||||
<!-- 配置导出/导入 -->
|
||||
<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="handleExportConfig"
|
||||
>
|
||||
<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="handleConfigFileSelect"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="importLoading"
|
||||
@click="triggerConfigFileSelect"
|
||||
>
|
||||
<Upload class="w-4 h-4 mr-2" />
|
||||
{{ importLoading ? '导入中...' : '导入配置' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
|
||||
<!-- 用户数据导出/导入 -->
|
||||
<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="exportUsersLoading"
|
||||
@click="handleExportUsers"
|
||||
>
|
||||
<Download class="w-4 h-4 mr-2" />
|
||||
{{ exportUsersLoading ? '导出中...' : '导出用户数据' }}
|
||||
</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="handleUsersFileSelect"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="importUsersLoading"
|
||||
@click="triggerUsersFileSelect"
|
||||
>
|
||||
<Upload class="w-4 h-4 mr-2" />
|
||||
{{ importUsersLoading ? '导入中...' : '导入用户数据' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
|
||||
<!-- 基础配置 -->
|
||||
<CardSection
|
||||
title="基础配置"
|
||||
@@ -375,11 +463,326 @@
|
||||
</div>
|
||||
</CardSection>
|
||||
</div>
|
||||
|
||||
<!-- 导入配置对话框 -->
|
||||
<Dialog v-model:open="importDialogOpen">
|
||||
<DialogContent class="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>导入配置</DialogTitle>
|
||||
<DialogDescription>
|
||||
选择冲突处理模式并确认导入
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="space-y-4 py-4">
|
||||
<div
|
||||
v-if="importPreview"
|
||||
class="p-3 bg-muted rounded-lg 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.endpoints?.reduce((s: number, e: any) => s + (e.keys?.length || 0), 0), 0) }} 个
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="block text-sm font-medium mb-2">冲突处理模式</Label>
|
||||
<Select v-model="mergeMode">
|
||||
<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>
|
||||
|
||||
<div class="p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
|
||||
<p class="text-sm text-yellow-600 dark:text-yellow-400">
|
||||
注意:相同的 API Keys 会自动跳过,不会创建重复记录。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="importDialogOpen = false"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="importLoading"
|
||||
@click="confirmImport"
|
||||
>
|
||||
{{ importLoading ? '导入中...' : '确认导入' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- 导入结果对话框 -->
|
||||
<Dialog v-model:open="importResultDialogOpen">
|
||||
<DialogContent class="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>导入完成</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div
|
||||
v-if="importResult"
|
||||
class="space-y-4 py-4"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div class="p-3 bg-muted rounded-lg">
|
||||
<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 class="p-3 bg-muted rounded-lg">
|
||||
<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 class="p-3 bg-muted rounded-lg">
|
||||
<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 class="p-3 bg-muted rounded-lg">
|
||||
<p class="font-medium">
|
||||
API Keys
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
创建: {{ importResult.stats.keys.created }},
|
||||
跳过: {{ importResult.stats.keys.skipped }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-3 bg-muted rounded-lg 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>
|
||||
|
||||
<div
|
||||
v-if="importResult.stats.errors.length > 0"
|
||||
class="p-3 bg-red-500/10 border border-red-500/20 rounded-lg"
|
||||
>
|
||||
<p class="font-medium text-red-600 dark:text-red-400 mb-2">
|
||||
警告信息
|
||||
</p>
|
||||
<ul class="text-sm text-red-600 dark:text-red-400 space-y-1">
|
||||
<li
|
||||
v-for="(err, index) in importResult.stats.errors"
|
||||
:key="index"
|
||||
>
|
||||
{{ err }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button @click="importResultDialogOpen = false">
|
||||
确定
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- 用户数据导入对话框 -->
|
||||
<Dialog v-model:open="importUsersDialogOpen">
|
||||
<DialogContent class="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>导入用户数据</DialogTitle>
|
||||
<DialogDescription>
|
||||
选择冲突处理模式并确认导入
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="space-y-4 py-4">
|
||||
<div
|
||||
v-if="importUsersPreview"
|
||||
class="p-3 bg-muted rounded-lg 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>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="block text-sm font-medium mb-2">冲突处理模式</Label>
|
||||
<Select v-model="usersMergeMode">
|
||||
<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>
|
||||
|
||||
<div class="p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
|
||||
<p class="text-sm text-yellow-600 dark:text-yellow-400">
|
||||
注意:用户 API Keys 需要目标系统使用相同的 ENCRYPTION_KEY 环境变量才能正常工作。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="importUsersDialogOpen = false"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="importUsersLoading"
|
||||
@click="confirmImportUsers"
|
||||
>
|
||||
{{ importUsersLoading ? '导入中...' : '确认导入' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- 用户数据导入结果对话框 -->
|
||||
<Dialog v-model:open="importUsersResultDialogOpen">
|
||||
<DialogContent class="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>用户数据导入完成</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div
|
||||
v-if="importUsersResult"
|
||||
class="space-y-4 py-4"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div class="p-3 bg-muted rounded-lg">
|
||||
<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 class="p-3 bg-muted rounded-lg">
|
||||
<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>
|
||||
|
||||
<div
|
||||
v-if="importUsersResult.stats.errors.length > 0"
|
||||
class="p-3 bg-red-500/10 border border-red-500/20 rounded-lg"
|
||||
>
|
||||
<p class="font-medium text-red-600 dark:text-red-400 mb-2">
|
||||
警告信息
|
||||
</p>
|
||||
<ul class="text-sm text-red-600 dark:text-red-400 space-y-1">
|
||||
<li
|
||||
v-for="(err, index) in importUsersResult.stats.errors"
|
||||
:key="index"
|
||||
>
|
||||
{{ err }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button @click="importUsersResultDialogOpen = false">
|
||||
确定
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Download, Upload } from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
@@ -389,9 +792,17 @@ 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,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter
|
||||
} from '@/components/ui'
|
||||
import { PageHeader, PageContainer, CardSection } from '@/components/layout'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { adminApi, type ConfigExportData, type ConfigImportResponse, type UsersExportData, type UsersImportResponse } from '@/api/admin'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
const { success, error } = useToast()
|
||||
@@ -423,6 +834,26 @@ interface SystemConfig {
|
||||
const loading = ref(false)
|
||||
const logLevelSelectOpen = ref(false)
|
||||
|
||||
// 导出/导入相关
|
||||
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 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 systemConfig = ref<SystemConfig>({
|
||||
// 基础配置
|
||||
default_user_quota_usd: 10.0,
|
||||
@@ -623,4 +1054,183 @@ async function saveSystemConfig() {
|
||||
loading.value = 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 = `aether-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()
|
||||
}
|
||||
|
||||
// 文件大小限制 (10MB)
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
|
||||
// 处理文件选择
|
||||
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 !== '1.0') {
|
||||
error(`不支持的配置版本: ${data.version}`)
|
||||
return
|
||||
}
|
||||
|
||||
importPreview.value = data
|
||||
mergeMode.value = 'skip'
|
||||
importDialogOpen.value = true
|
||||
} catch (err) {
|
||||
error('解析配置文件失败,请确保是有效的 JSON 文件')
|
||||
log.error('解析配置文件失败:', err)
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
|
||||
// 重置 input 以便能再次选择同一文件
|
||||
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
|
||||
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 = `aether-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
|
||||
|
||||
// 验证版本
|
||||
if (data.version !== '1.0') {
|
||||
error(`不支持的配置版本: ${data.version}`)
|
||||
return
|
||||
}
|
||||
|
||||
importUsersPreview.value = data
|
||||
usersMergeMode.value = 'skip'
|
||||
importUsersDialogOpen.value = true
|
||||
} catch (err) {
|
||||
error('解析用户数据文件失败,请确保是有效的 JSON 文件')
|
||||
log.error('解析用户数据文件失败:', err)
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
|
||||
// 重置 input 以便能再次选择同一文件
|
||||
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
|
||||
importUsersResultDialogOpen.value = true
|
||||
success('用户数据导入成功')
|
||||
} catch (err: any) {
|
||||
error(err.response?.data?.detail || '导入用户数据失败')
|
||||
log.error('导入用户数据失败:', err)
|
||||
} finally {
|
||||
importUsersLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user