feat(admin): unify system data management and aggregate import/export

This commit is contained in:
fawney19
2026-05-16 02:09:01 +08:00
parent 48da062251
commit f59cf1090d
16 changed files with 872 additions and 206 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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