mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat(admin): unify system data management and aggregate import/export
This commit is contained in:
@@ -91,6 +91,13 @@ export interface UsersExportData {
|
||||
standalone_keys?: StandaloneKeyExport[]
|
||||
}
|
||||
|
||||
export interface AggregateExportData {
|
||||
version: string
|
||||
exported_at: string
|
||||
config_data: ConfigExportData
|
||||
user_data: UsersExportData
|
||||
}
|
||||
|
||||
export interface UserGroupExport {
|
||||
id?: string
|
||||
name: string
|
||||
@@ -421,6 +428,16 @@ export interface UsersImportResponse {
|
||||
}
|
||||
}
|
||||
|
||||
export interface AggregateImportRequest extends AggregateExportData {
|
||||
merge_mode: 'skip' | 'overwrite' | 'error'
|
||||
}
|
||||
|
||||
export interface AggregateImportResponse {
|
||||
message: string
|
||||
config: ConfigImportResponse
|
||||
users: UsersImportResponse
|
||||
}
|
||||
|
||||
export interface ConfigImportResponse {
|
||||
message: string
|
||||
stats: {
|
||||
@@ -837,6 +854,21 @@ export const adminApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 导出聚合数据(配置数据 + 用户数据)
|
||||
async exportAggregateData(): Promise<AggregateExportData> {
|
||||
const response = await apiClient.get<AggregateExportData>('/api/admin/system/data/export')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 导入聚合数据(配置数据 + 用户数据)
|
||||
async importAggregateData(data: AggregateImportRequest): Promise<AggregateImportResponse> {
|
||||
const response = await apiClient.post<AggregateImportResponse>(
|
||||
'/api/admin/system/data/import',
|
||||
data
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 查询 Provider 可用模型(从上游 API 获取)
|
||||
async queryProviderModels(providerId: string, apiKeyId?: string, forceRefresh = false): Promise<ProviderModelsQueryResponse> {
|
||||
const response = await apiClient.post<ProviderModelsQueryResponse>(
|
||||
|
||||
@@ -21,26 +21,18 @@
|
||||
@update:site-subtitle="systemConfig.site_subtitle = $event"
|
||||
/>
|
||||
|
||||
<!-- 配置导出/导入 -->
|
||||
<ConfigManagementSection
|
||||
id="section-config-mgmt"
|
||||
:export-loading="exportLoading"
|
||||
:import-loading="importLoading"
|
||||
@export="handleExportConfig"
|
||||
@file-select="handleConfigFileSelect"
|
||||
/>
|
||||
|
||||
<!-- 用户数据导出/导入 -->
|
||||
<UserDataSection
|
||||
id="section-user-data"
|
||||
:export-loading="exportUsersLoading"
|
||||
:import-loading="importUsersLoading"
|
||||
@export="handleExportUsers"
|
||||
@file-select="handleUsersFileSelect"
|
||||
/>
|
||||
|
||||
<!-- 数据管理 -->
|
||||
<DataManagementSection id="section-data-mgmt" />
|
||||
<DataManagementSection
|
||||
id="section-data-mgmt"
|
||||
:config-export-loading="exportLoading"
|
||||
:config-import-loading="importLoading"
|
||||
:users-export-loading="exportUsersLoading"
|
||||
:users-import-loading="importUsersLoading"
|
||||
:aggregate-export-loading="exportAggregateLoading"
|
||||
:aggregate-import-loading="importAggregateLoading"
|
||||
@export="handleDataExport"
|
||||
@file-select="handleDataFileSelect"
|
||||
/>
|
||||
|
||||
<!-- 网络代理 -->
|
||||
<ProxyConfigSection
|
||||
@@ -210,6 +202,22 @@
|
||||
@update:users-merge-mode="usersMergeMode = $event"
|
||||
@update:users-merge-mode-select-open="usersMergeModeSelectOpen = $event"
|
||||
/>
|
||||
|
||||
<!-- 聚合数据导入对话框 -->
|
||||
<AggregateImportDialog
|
||||
:aggregate-import-dialog-open="aggregateImportDialogOpen"
|
||||
:aggregate-import-result-dialog-open="aggregateImportResultDialogOpen"
|
||||
:aggregate-import-preview="aggregateImportPreview"
|
||||
:aggregate-import-result="aggregateImportResult"
|
||||
:aggregate-merge-mode="aggregateMergeMode"
|
||||
:aggregate-merge-mode-select-open="aggregateMergeModeSelectOpen"
|
||||
:import-aggregate-loading="importAggregateLoading"
|
||||
@confirm="confirmImportAggregate"
|
||||
@update:aggregate-import-dialog-open="aggregateImportDialogOpen = $event"
|
||||
@update:aggregate-import-result-dialog-open="aggregateImportResultDialogOpen = $event"
|
||||
@update:aggregate-merge-mode="aggregateMergeMode = $event"
|
||||
@update:aggregate-merge-mode-select-open="aggregateMergeModeSelectOpen = $event"
|
||||
/>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
@@ -225,8 +233,6 @@ import { useScheduledTasks } from './system-settings/composables/useScheduledTas
|
||||
|
||||
// Section components
|
||||
import SiteInfoSection from './system-settings/SiteInfoSection.vue'
|
||||
import ConfigManagementSection from './system-settings/ConfigManagementSection.vue'
|
||||
import UserDataSection from './system-settings/UserDataSection.vue'
|
||||
import DataManagementSection from './system-settings/DataManagementSection.vue'
|
||||
import ProxyConfigSection from './system-settings/ProxyConfigSection.vue'
|
||||
import BasicConfigSection from './system-settings/BasicConfigSection.vue'
|
||||
@@ -238,14 +244,13 @@ import SystemInfoSection from './system-settings/SystemInfoSection.vue'
|
||||
// Dialog components
|
||||
import ConfigImportDialog from './system-settings/ConfigImportDialog.vue'
|
||||
import UsersImportDialog from './system-settings/UsersImportDialog.vue'
|
||||
import AggregateImportDialog from './system-settings/AggregateImportDialog.vue'
|
||||
|
||||
const proxyNodesStore = useProxyNodesStore()
|
||||
|
||||
// TOC 目录导航
|
||||
const tocItems = [
|
||||
{ id: 'section-site-info', label: '站点信息' },
|
||||
{ id: 'section-config-mgmt', label: '配置管理' },
|
||||
{ id: 'section-user-data', label: '用户数据管理' },
|
||||
{ id: 'section-data-mgmt', label: '数据管理' },
|
||||
{ id: 'section-proxy', label: '网络代理' },
|
||||
{ id: 'section-basic', label: '基础配置' },
|
||||
@@ -331,7 +336,7 @@ const {
|
||||
handleAutoCleanupToggle,
|
||||
} = useSystemConfig()
|
||||
|
||||
// Config export/import composable
|
||||
// 数据导出/导入 composable
|
||||
const {
|
||||
exportLoading,
|
||||
importLoading,
|
||||
@@ -355,8 +360,41 @@ const {
|
||||
handleExportUsers,
|
||||
handleUsersFileSelect,
|
||||
confirmImportUsers,
|
||||
exportAggregateLoading,
|
||||
importAggregateLoading,
|
||||
aggregateImportDialogOpen,
|
||||
aggregateImportResultDialogOpen,
|
||||
aggregateImportPreview,
|
||||
aggregateImportResult,
|
||||
aggregateMergeMode,
|
||||
aggregateMergeModeSelectOpen,
|
||||
handleExportAggregate,
|
||||
handleAggregateFileSelect,
|
||||
confirmImportAggregate,
|
||||
} = useConfigExportImport(systemConfig)
|
||||
|
||||
type DataManagementKind = 'config' | 'users' | 'aggregate'
|
||||
|
||||
function handleDataExport(kind: DataManagementKind) {
|
||||
if (kind === 'config') {
|
||||
handleExportConfig()
|
||||
} else if (kind === 'users') {
|
||||
handleExportUsers()
|
||||
} else {
|
||||
handleExportAggregate()
|
||||
}
|
||||
}
|
||||
|
||||
function handleDataFileSelect(kind: DataManagementKind, event: Event) {
|
||||
if (kind === 'config') {
|
||||
handleConfigFileSelect(event)
|
||||
} else if (kind === 'users') {
|
||||
handleUsersFileSelect(event)
|
||||
} else {
|
||||
handleAggregateFileSelect(event)
|
||||
}
|
||||
}
|
||||
|
||||
// Scheduled tasks composable
|
||||
const {
|
||||
scheduledTasks,
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<!-- 聚合数据导入对话框 -->
|
||||
<Dialog
|
||||
:open="aggregateImportDialogOpen"
|
||||
title="导入聚合数据"
|
||||
description="选择冲突处理模式并确认导入"
|
||||
@update:open="$emit('update:aggregateImportDialogOpen', $event)"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-if="aggregateImportPreview"
|
||||
class="text-sm"
|
||||
>
|
||||
<p class="font-medium mb-2">
|
||||
聚合数据预览
|
||||
</p>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 text-muted-foreground">
|
||||
<div>
|
||||
<p class="font-medium text-foreground mb-1">
|
||||
配置数据
|
||||
</p>
|
||||
<ul class="space-y-1">
|
||||
<li>全局模型: {{ aggregateImportPreview.config_data.global_models?.length || 0 }} 个</li>
|
||||
<li>提供商: {{ aggregateImportPreview.config_data.providers?.length || 0 }} 个</li>
|
||||
<li>
|
||||
API Keys: {{ aggregateImportPreview.config_data.providers?.reduce((sum: number, p: { api_keys?: unknown[] }) => sum + (p.api_keys?.length || 0), 0) }} 个
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-foreground mb-1">
|
||||
用户数据
|
||||
</p>
|
||||
<ul class="space-y-1">
|
||||
<li v-if="aggregateImportPreview.user_data.user_groups?.length">
|
||||
用户组: {{ aggregateImportPreview.user_data.user_groups.length }} 个
|
||||
</li>
|
||||
<li>用户: {{ aggregateImportPreview.user_data.users?.length || 0 }} 个</li>
|
||||
<li>
|
||||
API Keys: {{ aggregateImportPreview.user_data.users?.reduce((sum: number, u: { api_keys?: unknown[] }) => sum + (u.api_keys?.length || 0), 0) }} 个
|
||||
</li>
|
||||
<li v-if="aggregateImportPreview.user_data.standalone_keys?.length">
|
||||
独立余额 Keys: {{ aggregateImportPreview.user_data.standalone_keys.length }} 个
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="block text-sm font-medium mb-2">冲突处理模式</Label>
|
||||
<Select
|
||||
:model-value="aggregateMergeMode"
|
||||
:open="aggregateMergeModeSelectOpen"
|
||||
@update:model-value="$emit('update:aggregateMergeMode', $event)"
|
||||
@update:open="$emit('update:aggregateMergeModeSelectOpen', $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="aggregateMergeMode === 'skip'">
|
||||
已存在的数据将被保留,仅导入新数据
|
||||
</template>
|
||||
<template v-else-if="aggregateMergeMode === '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:aggregateImportDialogOpen', false); $emit('update:aggregateMergeModeSelectOpen', false)"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="importAggregateLoading"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
{{ importAggregateLoading ? '导入中...' : '确认导入' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- 聚合数据导入结果对话框 -->
|
||||
<Dialog
|
||||
:open="aggregateImportResultDialogOpen"
|
||||
title="聚合数据导入完成"
|
||||
@update:open="$emit('update:aggregateImportResultDialogOpen', $event)"
|
||||
>
|
||||
<div
|
||||
v-if="aggregateImportResult"
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
配置数据
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
全局模型创建 {{ aggregateImportResult.config.stats.global_models.created }},
|
||||
提供商创建 {{ aggregateImportResult.config.stats.providers.created }},
|
||||
API Keys 创建 {{ aggregateImportResult.config.stats.keys.created }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
用户数据
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
用户创建 {{ aggregateImportResult.users.stats.users.created }},
|
||||
API Keys 创建 {{ aggregateImportResult.users.stats.api_keys.created }},
|
||||
跳过 {{ aggregateImportResult.users.stats.users.skipped }} 个用户
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="warningMessages.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="(message, index) in warningMessages"
|
||||
:key="index"
|
||||
>
|
||||
{{ message }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button @click="$emit('update:aggregateImportResultDialogOpen', false)">
|
||||
确定
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
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 { AggregateExportData, AggregateImportResponse } from '@/api/admin'
|
||||
|
||||
const props = defineProps<{
|
||||
aggregateImportDialogOpen: boolean
|
||||
aggregateImportResultDialogOpen: boolean
|
||||
aggregateImportPreview: AggregateExportData | null
|
||||
aggregateImportResult: AggregateImportResponse | null
|
||||
aggregateMergeMode: 'skip' | 'overwrite' | 'error'
|
||||
aggregateMergeModeSelectOpen: boolean
|
||||
importAggregateLoading: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
confirm: []
|
||||
'update:aggregateImportDialogOpen': [value: boolean]
|
||||
'update:aggregateImportResultDialogOpen': [value: boolean]
|
||||
'update:aggregateMergeMode': [value: 'skip' | 'overwrite' | 'error']
|
||||
'update:aggregateMergeModeSelectOpen': [value: boolean]
|
||||
}>()
|
||||
|
||||
const warningMessages = computed(() => {
|
||||
if (!props.aggregateImportResult) return []
|
||||
const configErrors = props.aggregateImportResult.config.stats.errors.map((message) => `配置数据: ${message}`)
|
||||
const userErrors = props.aggregateImportResult.users.stats.errors.map((message) => `用户数据: ${message}`)
|
||||
return [...configErrors, ...userErrors]
|
||||
})
|
||||
</script>
|
||||
@@ -1,67 +0,0 @@
|
||||
<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>
|
||||
@@ -1,49 +1,160 @@
|
||||
<template>
|
||||
<CardSection
|
||||
title="数据管理"
|
||||
description="清空系统数据,操作不可逆,请谨慎使用"
|
||||
description="导出、导入或清空系统数据"
|
||||
>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div
|
||||
v-for="item in purgeItems"
|
||||
:key="item.key"
|
||||
class="flex flex-col gap-2 p-4 rounded-lg border border-border"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<component
|
||||
:is="item.icon"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
<span class="text-sm font-medium">{{ item.title }}</span>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<Database class="w-4 h-4 text-muted-foreground" />
|
||||
<h4 class="text-sm font-medium">
|
||||
导出 / 导入
|
||||
</h4>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground flex-1">
|
||||
{{ item.description }}
|
||||
</p>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
class="w-full mt-1"
|
||||
:disabled="loadingKey === item.key"
|
||||
@click="handlePurge(item)"
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div
|
||||
v-for="item in dataItems"
|
||||
:key="item.key"
|
||||
class="flex flex-col gap-3 p-4 rounded-lg border border-border"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<component
|
||||
:is="item.icon"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
<span class="text-sm font-medium">{{ item.title }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground flex-1">
|
||||
{{ item.description }}
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
:disabled="item.exportLoading"
|
||||
@click="$emit('export', item.key)"
|
||||
>
|
||||
<Download class="w-3.5 h-3.5 mr-1.5" />
|
||||
{{ item.exportLoading ? '导出中...' : item.exportLabel }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
:disabled="item.importLoading"
|
||||
@click="triggerDataFileSelect(item.key)"
|
||||
>
|
||||
<Upload class="w-3.5 h-3.5 mr-1.5" />
|
||||
{{ item.importLoading ? '导入中...' : item.importLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref="configFileInput"
|
||||
type="file"
|
||||
accept=".json"
|
||||
class="hidden"
|
||||
@change="$emit('fileSelect', 'config', $event)"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5 mr-1.5" />
|
||||
{{ loadingKey === item.key ? '清空中...' : item.buttonText }}
|
||||
</Button>
|
||||
<input
|
||||
ref="usersFileInput"
|
||||
type="file"
|
||||
accept=".json"
|
||||
class="hidden"
|
||||
@change="$emit('fileSelect', 'users', $event)"
|
||||
>
|
||||
<input
|
||||
ref="aggregateFileInput"
|
||||
type="file"
|
||||
accept=".json"
|
||||
class="hidden"
|
||||
@change="$emit('fileSelect', 'aggregate', $event)"
|
||||
>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<Trash2 class="w-4 h-4 text-muted-foreground" />
|
||||
<h4 class="text-sm font-medium">
|
||||
清空数据
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div
|
||||
v-for="item in purgeItems"
|
||||
:key="item.key"
|
||||
class="flex flex-col gap-2 p-4 rounded-lg border border-border"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<component
|
||||
:is="item.icon"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
<span class="text-sm font-medium">{{ item.title }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground flex-1">
|
||||
{{ item.description }}
|
||||
</p>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
class="w-full mt-1"
|
||||
:disabled="loadingKey === item.key"
|
||||
@click="handlePurge(item)"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5 mr-1.5" />
|
||||
{{ loadingKey === item.key ? '清空中...' : item.buttonText }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, markRaw, type Component } from 'vue'
|
||||
import { Trash2, Settings, Users, BarChart3, Shield, FileText, PieChart } from 'lucide-vue-next'
|
||||
import { computed, ref, markRaw, type Component } from 'vue'
|
||||
import {
|
||||
Download,
|
||||
Upload,
|
||||
Settings,
|
||||
Users,
|
||||
Database,
|
||||
Layers3,
|
||||
Trash2,
|
||||
BarChart3,
|
||||
Shield,
|
||||
FileText,
|
||||
PieChart,
|
||||
} from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import { Separator } from '@/components/ui'
|
||||
import { CardSection } from '@/components/layout'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
|
||||
type DataItemKey = 'config' | 'users' | 'aggregate'
|
||||
|
||||
interface DataItem {
|
||||
key: DataItemKey
|
||||
title: string
|
||||
description: string
|
||||
exportLabel: string
|
||||
importLabel: string
|
||||
icon: Component
|
||||
exportLoading: boolean
|
||||
importLoading: boolean
|
||||
}
|
||||
|
||||
interface PurgeItem {
|
||||
key: string
|
||||
title: string
|
||||
@@ -54,9 +165,59 @@ interface PurgeItem {
|
||||
action: () => Promise<{ message: string }>
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
configExportLoading: boolean
|
||||
configImportLoading: boolean
|
||||
usersExportLoading: boolean
|
||||
usersImportLoading: boolean
|
||||
aggregateExportLoading: boolean
|
||||
aggregateImportLoading: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
export: [key: DataItemKey]
|
||||
fileSelect: [key: DataItemKey, event: Event]
|
||||
}>()
|
||||
|
||||
const { success, error } = useToast()
|
||||
const { confirmDanger } = useConfirm()
|
||||
const loadingKey = ref<string | null>(null)
|
||||
const configFileInput = ref<HTMLInputElement | null>(null)
|
||||
const usersFileInput = ref<HTMLInputElement | null>(null)
|
||||
const aggregateFileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const dataItems = computed<DataItem[]>(() => [
|
||||
{
|
||||
key: 'config',
|
||||
title: '配置数据',
|
||||
description: '提供商、端点、API Key、模型与系统配置',
|
||||
exportLabel: '导出配置',
|
||||
importLabel: '导入配置',
|
||||
icon: markRaw(Settings),
|
||||
exportLoading: props.configExportLoading,
|
||||
importLoading: props.configImportLoading,
|
||||
},
|
||||
{
|
||||
key: 'users',
|
||||
title: '用户数据',
|
||||
description: '普通用户、用户组、API Keys 与钱包快照(不含管理员)',
|
||||
exportLabel: '导出用户',
|
||||
importLabel: '导入用户',
|
||||
icon: markRaw(Users),
|
||||
exportLoading: props.usersExportLoading,
|
||||
importLoading: props.usersImportLoading,
|
||||
},
|
||||
{
|
||||
key: 'aggregate',
|
||||
title: '聚合数据',
|
||||
description: '配置数据和用户数据的一体化备份文件',
|
||||
exportLabel: '导出聚合',
|
||||
importLabel: '导入聚合',
|
||||
icon: markRaw(Layers3),
|
||||
exportLoading: props.aggregateExportLoading,
|
||||
importLoading: props.aggregateImportLoading,
|
||||
},
|
||||
])
|
||||
|
||||
const purgeItems: PurgeItem[] = [
|
||||
{
|
||||
@@ -115,6 +276,16 @@ const purgeItems: PurgeItem[] = [
|
||||
},
|
||||
]
|
||||
|
||||
function triggerDataFileSelect(key: DataItemKey) {
|
||||
if (key === 'config') {
|
||||
configFileInput.value?.click()
|
||||
} else if (key === 'users') {
|
||||
usersFileInput.value?.click()
|
||||
} else {
|
||||
aggregateFileInput.value?.click()
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePurge(item: PurgeItem) {
|
||||
const confirmed = await confirmDanger(item.confirmMessage, item.title)
|
||||
if (!confirmed) return
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
<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>
|
||||
@@ -2,6 +2,8 @@ import { ref } from 'vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import {
|
||||
adminApi,
|
||||
type AggregateExportData,
|
||||
type AggregateImportResponse,
|
||||
type ConfigExportData,
|
||||
type ConfigImportResponse,
|
||||
type UsersExportData,
|
||||
@@ -11,8 +13,9 @@ import { parseApiError } from '@/utils/errorParser'
|
||||
import { log } from '@/utils/logger'
|
||||
import type { SystemConfig } from './useSystemConfig'
|
||||
|
||||
// 文件大小限制 (10MB)
|
||||
// 文件大小限制:聚合数据包含配置和用户数据,允许更大的备份文件。
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
const MAX_AGGREGATE_FILE_SIZE = 20 * 1024 * 1024
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
|
||||
@@ -41,6 +44,23 @@ function looksLikeUsersExport(value: JsonObject): boolean {
|
||||
|| hasArrayField(value, 'user_groups')
|
||||
}
|
||||
|
||||
function looksLikeAggregateExport(value: JsonObject): boolean {
|
||||
return asJsonObject(value.config_data) != null
|
||||
&& asJsonObject(value.user_data) != null
|
||||
}
|
||||
|
||||
function downloadJson(data: unknown, filename: string) {
|
||||
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 = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
|
||||
const { success, error } = useToast()
|
||||
|
||||
@@ -66,20 +86,25 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
|
||||
const usersMergeMode = ref<'skip' | 'overwrite' | 'error'>('skip')
|
||||
const usersMergeModeSelectOpen = ref(false)
|
||||
|
||||
// 聚合数据导出/导入相关
|
||||
const exportAggregateLoading = ref(false)
|
||||
const importAggregateLoading = ref(false)
|
||||
const aggregateImportDialogOpen = ref(false)
|
||||
const aggregateImportResultDialogOpen = ref(false)
|
||||
const aggregateImportPreview = ref<AggregateExportData | null>(null)
|
||||
const aggregateImportResult = ref<AggregateImportResponse | null>(null)
|
||||
const aggregateMergeMode = ref<'skip' | 'overwrite' | 'error'>('skip')
|
||||
const aggregateMergeModeSelectOpen = 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)
|
||||
downloadJson(
|
||||
data,
|
||||
`${systemConfig.value.site_name.toLowerCase()}-config-${new Date().toISOString().slice(0, 10)}.json`,
|
||||
)
|
||||
success('配置已导出')
|
||||
} catch (err) {
|
||||
error('导出配置失败')
|
||||
@@ -173,15 +198,10 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
|
||||
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)
|
||||
downloadJson(
|
||||
data,
|
||||
`${systemConfig.value.site_name.toLowerCase()}-users-${new Date().toISOString().slice(0, 10)}.json`,
|
||||
)
|
||||
success('用户数据已导出')
|
||||
} catch (err) {
|
||||
error('导出用户数据失败')
|
||||
@@ -280,6 +300,110 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
|
||||
}
|
||||
}
|
||||
|
||||
// 导出聚合数据
|
||||
async function handleExportAggregate() {
|
||||
exportAggregateLoading.value = true
|
||||
try {
|
||||
const data = await adminApi.exportAggregateData()
|
||||
downloadJson(
|
||||
data,
|
||||
`${systemConfig.value.site_name.toLowerCase()}-data-${new Date().toISOString().slice(0, 10)}.json`,
|
||||
)
|
||||
success('聚合数据已导出')
|
||||
} catch (err) {
|
||||
error('导出聚合数据失败')
|
||||
log.error('导出聚合数据失败:', err)
|
||||
} finally {
|
||||
exportAggregateLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理聚合数据文件选择
|
||||
function handleAggregateFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (file.size > MAX_AGGREGATE_FILE_SIZE) {
|
||||
error('文件大小不能超过 20MB')
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const content = e.target?.result as string
|
||||
const root = asJsonObject(JSON.parse(content))
|
||||
if (!root) {
|
||||
error('无效的聚合数据文件:JSON 顶层必须是对象')
|
||||
return
|
||||
}
|
||||
|
||||
if (!looksLikeAggregateExport(root)) {
|
||||
if (looksLikeConfigExport(root)) {
|
||||
error('这是配置导出文件,请使用“导入配置数据”')
|
||||
} else if (looksLikeUsersExport(root)) {
|
||||
error('这是用户数据导出文件,请使用“导入用户数据”')
|
||||
} else {
|
||||
error('无效的聚合数据文件:未找到配置数据和用户数据')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!root.version) {
|
||||
error('无效的聚合数据文件:缺少版本信息')
|
||||
return
|
||||
}
|
||||
|
||||
const configData = asJsonObject(root.config_data)
|
||||
const userData = asJsonObject(root.user_data)
|
||||
if (!configData || !looksLikeConfigExport(configData)) {
|
||||
error('无效的聚合数据文件:config_data 格式不正确')
|
||||
return
|
||||
}
|
||||
if (!userData || !looksLikeUsersExport(userData)) {
|
||||
error('无效的聚合数据文件:user_data 格式不正确')
|
||||
return
|
||||
}
|
||||
|
||||
const data = root as unknown as AggregateExportData
|
||||
aggregateImportPreview.value = data
|
||||
aggregateMergeMode.value = 'skip'
|
||||
aggregateImportDialogOpen.value = true
|
||||
} catch (err) {
|
||||
error('解析聚合数据文件失败,请确保是有效的 JSON 文件')
|
||||
log.error('解析聚合数据文件失败:', err)
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
// 确认导入聚合数据
|
||||
async function confirmImportAggregate() {
|
||||
if (!aggregateImportPreview.value) return
|
||||
|
||||
importAggregateLoading.value = true
|
||||
try {
|
||||
const result = await adminApi.importAggregateData({
|
||||
...aggregateImportPreview.value,
|
||||
merge_mode: aggregateMergeMode.value,
|
||||
})
|
||||
aggregateImportResult.value = result
|
||||
aggregateImportDialogOpen.value = false
|
||||
aggregateMergeModeSelectOpen.value = false
|
||||
aggregateImportResultDialogOpen.value = true
|
||||
success('聚合数据导入成功')
|
||||
} catch (err: unknown) {
|
||||
error(parseApiError(err, '导入聚合数据失败'))
|
||||
log.error('导入聚合数据失败:', err)
|
||||
} finally {
|
||||
importAggregateLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 配置导出/导入
|
||||
exportLoading,
|
||||
@@ -309,5 +433,17 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
|
||||
triggerUsersFileSelect,
|
||||
handleUsersFileSelect,
|
||||
confirmImportUsers,
|
||||
// 聚合数据导出/导入
|
||||
exportAggregateLoading,
|
||||
importAggregateLoading,
|
||||
aggregateImportDialogOpen,
|
||||
aggregateImportResultDialogOpen,
|
||||
aggregateImportPreview,
|
||||
aggregateImportResult,
|
||||
aggregateMergeMode,
|
||||
aggregateMergeModeSelectOpen,
|
||||
handleExportAggregate,
|
||||
handleAggregateFileSelect,
|
||||
confirmImportAggregate,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user