Fix system data import export flows

This commit is contained in:
fawney19
2026-05-22 02:46:49 +08:00
parent 504c1ccb37
commit 2b32b9a445
12 changed files with 269 additions and 48 deletions

View File

@@ -1,12 +1,16 @@
import apiClient from './client'
import type { ModelTestCapabilities } from './endpoints/types'
import axios from 'axios'
import axios, { type AxiosRequestConfig } from 'axios'
import { cachedRequest, buildCacheKey } from '@/utils/cache'
import type { BillingSummary } from './auth'
import type { ApiKeyInstallSession, InstallSessionTargetSystem, InstallTargetCli } from './me'
const SYSTEM_DATA_IMPORT_TIMEOUT_MS = 10 * 60 * 1000
export interface SystemDataImportOptions {
onUploadProgress?: AxiosRequestConfig['onUploadProgress']
}
function extractConflictPayload(error: unknown): ManualUsageCleanupConflict | null {
if (!axios.isAxiosError(error) || error.response?.status !== 409) {
return null
@@ -859,11 +863,11 @@ export const adminApi = {
},
// 导入配置
async importConfig(data: ConfigImportRequest): Promise<ConfigImportResponse> {
async importConfig(data: ConfigImportRequest, options: SystemDataImportOptions = {}): Promise<ConfigImportResponse> {
const response = await apiClient.post<ConfigImportResponse>(
'/api/admin/system/config/import',
data,
{ timeout: SYSTEM_DATA_IMPORT_TIMEOUT_MS }
{ timeout: SYSTEM_DATA_IMPORT_TIMEOUT_MS, ...options }
)
return response.data
},
@@ -875,27 +879,27 @@ export const adminApi = {
},
// 导入用户数据
async importUsers(data: UsersImportRequest): Promise<UsersImportResponse> {
async importUsers(data: UsersImportRequest, options: SystemDataImportOptions = {}): Promise<UsersImportResponse> {
const response = await apiClient.post<UsersImportResponse>(
'/api/admin/system/users/import',
data,
{ timeout: SYSTEM_DATA_IMPORT_TIMEOUT_MS }
{ timeout: SYSTEM_DATA_IMPORT_TIMEOUT_MS, ...options }
)
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> {
// 导入完整备份(配置数据 + 用户数据)
async importAggregateData(data: AggregateImportRequest, options: SystemDataImportOptions = {}): Promise<AggregateImportResponse> {
const response = await apiClient.post<AggregateImportResponse>(
'/api/admin/system/data/import',
data,
{ timeout: SYSTEM_DATA_IMPORT_TIMEOUT_MS }
{ timeout: SYSTEM_DATA_IMPORT_TIMEOUT_MS, ...options }
)
return response.data
},

View File

@@ -198,6 +198,7 @@
:merge-mode="mergeMode"
:merge-mode-select-open="mergeModeSelectOpen"
:import-loading="importLoading"
:import-progress="importProgress"
@confirm="confirmImport"
@update:import-dialog-open="importDialogOpen = $event"
@update:import-result-dialog-open="importResultDialogOpen = $event"
@@ -214,6 +215,7 @@
:users-merge-mode="usersMergeMode"
:users-merge-mode-select-open="usersMergeModeSelectOpen"
:import-users-loading="importUsersLoading"
:import-users-progress="importUsersProgress"
@confirm="confirmImportUsers"
@update:import-users-dialog-open="importUsersDialogOpen = $event"
@update:import-users-result-dialog-open="importUsersResultDialogOpen = $event"
@@ -221,7 +223,7 @@
@update:users-merge-mode-select-open="usersMergeModeSelectOpen = $event"
/>
<!-- 聚合数据导入对话框 -->
<!-- 完整备份导入对话框 -->
<AggregateImportDialog
:aggregate-import-dialog-open="aggregateImportDialogOpen"
:aggregate-import-result-dialog-open="aggregateImportResultDialogOpen"
@@ -230,6 +232,7 @@
:aggregate-merge-mode="aggregateMergeMode"
:aggregate-merge-mode-select-open="aggregateMergeModeSelectOpen"
:import-aggregate-loading="importAggregateLoading"
:import-aggregate-progress="importAggregateProgress"
@confirm="confirmImportAggregate"
@update:aggregate-import-dialog-open="aggregateImportDialogOpen = $event"
@update:aggregate-import-result-dialog-open="aggregateImportResultDialogOpen = $event"
@@ -364,6 +367,7 @@ const {
importResult,
mergeMode,
mergeModeSelectOpen,
importProgress,
handleExportConfig,
handleConfigFileSelect,
confirmImport,
@@ -375,6 +379,7 @@ const {
importUsersResult,
usersMergeMode,
usersMergeModeSelectOpen,
importUsersProgress,
handleExportUsers,
handleUsersFileSelect,
confirmImportUsers,
@@ -386,6 +391,7 @@ const {
aggregateImportResult,
aggregateMergeMode,
aggregateMergeModeSelectOpen,
importAggregateProgress,
handleExportAggregate,
handleAggregateFileSelect,
confirmImportAggregate,

View File

@@ -1,8 +1,8 @@
<template>
<!-- 聚合数据导入对话框 -->
<!-- 完整备份导入对话框 -->
<Dialog
:open="aggregateImportDialogOpen"
title="导入聚合数据"
title="导入完整备份"
description="选择冲突处理模式并确认导入"
@update:open="$emit('update:aggregateImportDialogOpen', $event)"
>
@@ -12,7 +12,7 @@
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>
@@ -84,8 +84,24 @@
</div>
<p class="text-xs text-muted-foreground">
注意聚合数据会先导入配置数据再导入用户数据用户 API Keys 需要目标系统使用相同的 ENCRYPTION_KEY
注意完整备份会先导入配置数据再导入用户数据文件包含用户用户组API Keys 与钱包快照用户 API Keys 需要目标系统使用相同的 ENCRYPTION_KEY
</p>
<div
v-if="importAggregateProgress"
class="space-y-2 rounded-md border border-border p-3"
>
<div class="flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span>{{ importAggregateProgress.message }}</span>
<span>{{ importAggregateProgress.percent }}%</span>
</div>
<div class="h-1.5 overflow-hidden rounded-full bg-muted">
<div
class="h-full bg-primary transition-all"
:style="{ width: `${importAggregateProgress.percent}%` }"
/>
</div>
</div>
</div>
<template #footer>
@@ -104,10 +120,10 @@
</template>
</Dialog>
<!-- 聚合数据导入结果对话框 -->
<!-- 完整备份导入结果对话框 -->
<Dialog
:open="aggregateImportResultDialogOpen"
title="聚合数据导入完成"
title="完整备份导入完成"
@update:open="$emit('update:aggregateImportResultDialogOpen', $event)"
>
<div
@@ -174,6 +190,7 @@ 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'
import type { ImportProgressState } from './composables/useConfigExportImport'
const props = defineProps<{
aggregateImportDialogOpen: boolean
@@ -183,6 +200,7 @@ const props = defineProps<{
aggregateMergeMode: 'skip' | 'overwrite' | 'error'
aggregateMergeModeSelectOpen: boolean
importAggregateLoading: boolean
importAggregateProgress: ImportProgressState | null
}>()
defineEmits<{

View File

@@ -74,6 +74,22 @@
<p class="text-xs text-muted-foreground">
注意相同的 API Keys 会自动跳过不会创建重复记录
</p>
<div
v-if="importProgress"
class="space-y-2 rounded-md border border-border p-3"
>
<div class="flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span>{{ importProgress.message }}</span>
<span>{{ importProgress.percent }}%</span>
</div>
<div class="h-1.5 overflow-hidden rounded-full bg-muted">
<div
class="h-full bg-primary transition-all"
:style="{ width: `${importProgress.percent}%` }"
/>
</div>
</div>
</div>
<template #footer>
@@ -220,6 +236,7 @@ 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'
import type { ImportProgressState } from './composables/useConfigExportImport'
defineProps<{
importDialogOpen: boolean
@@ -229,6 +246,7 @@ defineProps<{
mergeMode: 'skip' | 'overwrite' | 'error'
mergeModeSelectOpen: boolean
importLoading: boolean
importProgress: ImportProgressState | null
}>()
defineEmits<{

View File

@@ -209,10 +209,10 @@ const dataItems = computed<DataItem[]>(() => [
},
{
key: 'aggregate',
title: '聚合数据',
description: '配置数据和用户数据的一体化备份文件',
exportLabel: '导出聚合',
importLabel: '导入聚合',
title: '完整备份',
description: '配置用户数据的一体化备份包含用户、用户组、API Keys 与钱包快照',
exportLabel: '导出备份',
importLabel: '导入备份',
icon: markRaw(Layers3),
exportLoading: props.aggregateExportLoading,
importLoading: props.aggregateImportLoading,

View File

@@ -67,6 +67,22 @@
<p class="text-xs text-muted-foreground">
注意用户 API Keys 需要目标系统使用相同的 ENCRYPTION_KEY 环境变量才能正常工作
</p>
<div
v-if="importUsersProgress"
class="space-y-2 rounded-md border border-border p-3"
>
<div class="flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span>{{ importUsersProgress.message }}</span>
<span>{{ importUsersProgress.percent }}%</span>
</div>
<div class="h-1.5 overflow-hidden rounded-full bg-muted">
<div
class="h-full bg-primary transition-all"
:style="{ width: `${importUsersProgress.percent}%` }"
/>
</div>
</div>
</div>
<template #footer>
@@ -175,6 +191,7 @@ 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'
import type { ImportProgressState } from './composables/useConfigExportImport'
defineProps<{
importUsersDialogOpen: boolean
@@ -184,6 +201,7 @@ defineProps<{
usersMergeMode: 'skip' | 'overwrite' | 'error'
usersMergeModeSelectOpen: boolean
importUsersLoading: boolean
importUsersProgress: ImportProgressState | null
}>()
defineEmits<{

View File

@@ -1,4 +1,5 @@
import { ref } from 'vue'
import { nextTick, ref } from 'vue'
import type { AxiosProgressEvent } from 'axios'
import { useToast } from '@/composables/useToast'
import {
adminApi,
@@ -22,6 +23,11 @@ const MAX_AGGREGATE_FILE_SIZE = MAX_AGGREGATE_FILE_SIZE_MB * BYTES_PER_MB
type JsonObject = Record<string, unknown>
export interface ImportProgressState {
percent: number
message: string
}
function asJsonObject(value: unknown): JsonObject | null {
return value && typeof value === 'object' && !Array.isArray(value)
? value as JsonObject
@@ -56,6 +62,47 @@ function fileSizeLimitMessage(limitMb: number): string {
return `文件大小不能超过 ${limitMb}MB`
}
function formatBytes(bytes: number): string {
if (bytes >= BYTES_PER_MB) {
return `${(bytes / BYTES_PER_MB).toFixed(1)}MB`
}
if (bytes >= 1024) {
return `${Math.round(bytes / 1024)}KB`
}
return `${bytes}B`
}
function setImportProgress(
target: { value: ImportProgressState | null },
percent: number,
message: string,
) {
target.value = {
percent: Math.max(0, Math.min(100, Math.round(percent))),
message,
}
}
function buildUploadProgressHandler(
target: { value: ImportProgressState | null },
label: string,
) {
return (event: AxiosProgressEvent) => {
if (!event.total) {
setImportProgress(target, 15, `${label}上传中:${formatBytes(event.loaded)}`)
return
}
const uploadPercent = Math.min(85, 10 + (event.loaded / event.total) * 75)
const loaded = formatBytes(event.loaded)
const total = formatBytes(event.total)
const message = event.loaded >= event.total
? `${label}已上传,服务端正在校验并写入数据`
: `${label}上传中:${loaded} / ${total}`
setImportProgress(target, uploadPercent, message)
}
}
function downloadJson(data: unknown, filename: string) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
@@ -81,6 +128,7 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
const importResult = ref<ConfigImportResponse | null>(null)
const mergeMode = ref<'skip' | 'overwrite' | 'error'>('skip')
const mergeModeSelectOpen = ref(false)
const importProgress = ref<ImportProgressState | null>(null)
// 用户数据导出/导入相关
const exportUsersLoading = ref(false)
@@ -92,8 +140,9 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
const importUsersResult = ref<UsersImportResponse | null>(null)
const usersMergeMode = ref<'skip' | 'overwrite' | 'error'>('skip')
const usersMergeModeSelectOpen = ref(false)
const importUsersProgress = ref<ImportProgressState | null>(null)
// 聚合数据导出/导入相关
// 完整备份导出/导入相关
const exportAggregateLoading = ref(false)
const importAggregateLoading = ref(false)
const aggregateImportDialogOpen = ref(false)
@@ -102,6 +151,7 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
const aggregateImportResult = ref<AggregateImportResponse | null>(null)
const aggregateMergeMode = ref<'skip' | 'overwrite' | 'error'>('skip')
const aggregateMergeModeSelectOpen = ref(false)
const importAggregateProgress = ref<ImportProgressState | null>(null)
// 导出配置
async function handleExportConfig() {
@@ -182,11 +232,16 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
if (!importPreview.value) return
importLoading.value = true
setImportProgress(importProgress, 5, '准备提交配置数据')
await nextTick()
try {
const result = await adminApi.importConfig({
...importPreview.value,
merge_mode: mergeMode.value,
}, {
onUploadProgress: buildUploadProgressHandler(importProgress, '配置数据'),
})
setImportProgress(importProgress, 100, '配置数据导入完成')
importResult.value = result
importDialogOpen.value = false
mergeModeSelectOpen.value = false
@@ -197,6 +252,7 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
log.error('导入配置失败:', err)
} finally {
importLoading.value = false
importProgress.value = null
}
}
@@ -289,11 +345,16 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
if (!importUsersPreview.value) return
importUsersLoading.value = true
setImportProgress(importUsersProgress, 5, '准备提交用户数据')
await nextTick()
try {
const result = await adminApi.importUsers({
...importUsersPreview.value,
merge_mode: usersMergeMode.value,
}, {
onUploadProgress: buildUploadProgressHandler(importUsersProgress, '用户数据'),
})
setImportProgress(importUsersProgress, 100, '用户数据导入完成')
importUsersResult.value = result
importUsersDialogOpen.value = false
usersMergeModeSelectOpen.value = false
@@ -304,10 +365,11 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
log.error('导入用户数据失败:', err)
} finally {
importUsersLoading.value = false
importUsersProgress.value = null
}
}
// 导出聚合数据
// 导出完整备份
async function handleExportAggregate() {
exportAggregateLoading.value = true
try {
@@ -316,16 +378,16 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
data,
`${systemConfig.value.site_name.toLowerCase()}-data-${new Date().toISOString().slice(0, 10)}.json`,
)
success('聚合数据已导出')
success('完整备份已导出')
} catch (err) {
error('导出聚合数据失败')
log.error('导出聚合数据失败:', err)
error('导出完整备份失败')
log.error('导出完整备份失败:', err)
} finally {
exportAggregateLoading.value = false
}
}
// 处理聚合数据文件选择
// 处理完整备份文件选择
function handleAggregateFileSelect(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
@@ -343,7 +405,7 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
const content = e.target?.result as string
const root = asJsonObject(JSON.parse(content))
if (!root) {
error('无效的聚合数据文件JSON 顶层必须是对象')
error('无效的完整备份文件JSON 顶层必须是对象')
return
}
@@ -353,24 +415,24 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
} else if (looksLikeUsersExport(root)) {
error('这是用户数据导出文件,请使用“导入用户数据”')
} else {
error('无效的聚合数据文件:未找到配置数据和用户数据')
error('无效的完整备份文件:未找到配置数据和用户数据')
}
return
}
if (!root.version) {
error('无效的聚合数据文件:缺少版本信息')
error('无效的完整备份文件:缺少版本信息')
return
}
const configData = asJsonObject(root.config_data)
const userData = asJsonObject(root.user_data)
if (!configData || !looksLikeConfigExport(configData)) {
error('无效的聚合数据文件config_data 格式不正确')
error('无效的完整备份文件config_data 格式不正确')
return
}
if (!userData || !looksLikeUsersExport(userData)) {
error('无效的聚合数据文件user_data 格式不正确')
error('无效的完整备份文件user_data 格式不正确')
return
}
@@ -379,8 +441,8 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
aggregateMergeMode.value = 'skip'
aggregateImportDialogOpen.value = true
} catch (err) {
error('解析聚合数据文件失败,请确保是有效的 JSON 文件')
log.error('解析聚合数据文件失败:', err)
error('解析完整备份文件失败,请确保是有效的 JSON 文件')
log.error('解析完整备份文件失败:', err)
}
}
reader.readAsText(file)
@@ -388,26 +450,32 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
input.value = ''
}
// 确认导入聚合数据
// 确认导入完整备份
async function confirmImportAggregate() {
if (!aggregateImportPreview.value) return
importAggregateLoading.value = true
setImportProgress(importAggregateProgress, 5, '准备提交完整备份')
await nextTick()
try {
const result = await adminApi.importAggregateData({
...aggregateImportPreview.value,
merge_mode: aggregateMergeMode.value,
}, {
onUploadProgress: buildUploadProgressHandler(importAggregateProgress, '完整备份'),
})
setImportProgress(importAggregateProgress, 100, '完整备份导入完成')
aggregateImportResult.value = result
aggregateImportDialogOpen.value = false
aggregateMergeModeSelectOpen.value = false
aggregateImportResultDialogOpen.value = true
success('聚合数据导入成功')
success('完整备份导入成功')
} catch (err: unknown) {
error(parseApiError(err, '导入聚合数据失败'))
log.error('导入聚合数据失败:', err)
error(parseApiError(err, '导入完整备份失败'))
log.error('导入完整备份失败:', err)
} finally {
importAggregateLoading.value = false
importAggregateProgress.value = null
}
}
@@ -422,6 +490,7 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
importResult,
mergeMode,
mergeModeSelectOpen,
importProgress,
handleExportConfig,
triggerConfigFileSelect,
handleConfigFileSelect,
@@ -436,11 +505,12 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
importUsersResult,
usersMergeMode,
usersMergeModeSelectOpen,
importUsersProgress,
handleExportUsers,
triggerUsersFileSelect,
handleUsersFileSelect,
confirmImportUsers,
// 聚合数据导出/导入
// 完整备份导出/导入
exportAggregateLoading,
importAggregateLoading,
aggregateImportDialogOpen,
@@ -449,6 +519,7 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
aggregateImportResult,
aggregateMergeMode,
aggregateMergeModeSelectOpen,
importAggregateProgress,
handleExportAggregate,
handleAggregateFileSelect,
confirmImportAggregate,