@@ -19,8 +19,9 @@
@click="handleLogin(provider.id)"
>
-
-
+
+
+
通过 {{ provider.name }} 授权登录
@@ -41,68 +42,27 @@
\ No newline at end of file
diff --git a/frontend/src/features/auth/views/oauthCallback.vue b/frontend/src/features/auth/views/oauthCallback.vue
index df486bd..a763905 100644
--- a/frontend/src/features/auth/views/oauthCallback.vue
+++ b/frontend/src/features/auth/views/oauthCallback.vue
@@ -27,127 +27,12 @@
\ No newline at end of file
diff --git a/frontend/src/features/backup/components/backupSettings.vue b/frontend/src/features/backup/components/backupSettings.vue
index 4af44de..a3a9aac 100644
--- a/frontend/src/features/backup/components/backupSettings.vue
+++ b/frontend/src/features/backup/components/backupSettings.vue
@@ -193,67 +193,26 @@
-const openAddDialog = () => {
- isEditing.value = false
- form.value = { type: 'webdav', name: '', config: { url: '', username: '', password: '', saveDir: '/', endpoint: '', bucket: '', region: 'auto', accessKeyId: '', secretAccessKey: '' }, autoBackup: false, autoBackupPassword: '', autoBackupRetain: 30 }
- hasExistingAutoPwd.value = false
- configUseExistingAutoPwd.value = false
- showConfigDialog.value = true
+
\ No newline at end of file
diff --git a/frontend/src/features/backup/composables/useBackupActions.js b/frontend/src/features/backup/composables/useBackupActions.js
new file mode 100644
index 0000000..a91f8f9
--- /dev/null
+++ b/frontend/src/features/backup/composables/useBackupActions.js
@@ -0,0 +1,146 @@
+import { ref } from 'vue'
+import { ElMessage, ElNotification } from 'element-plus'
+import { backupService } from '@/features/backup/service/backupService'
+import { dataMigrationService } from '@/features/migration/service/dataMigrationService'
+import { useVaultStore } from '@/features/vault/store/vaultStore'
+
+export function useBackupActions(emit, fetchProviders) {
+ const vaultStore = useVaultStore()
+
+ // Backup Action State
+ const showBackupDialog = ref(false)
+ const backupPassword = ref('')
+ const isBackingUp = ref(false)
+ const useAutoPassword = ref(false)
+ const currentActionProvider = ref(null)
+
+ // Restore Action State
+ const showRestoreListDialog = ref(false)
+ const isLoadingFiles = ref(false)
+ const backupFiles = ref([])
+ const showRestoreConfirmDialog = ref(false)
+ const restorePassword = ref('')
+ const selectedFile = ref(null)
+ const isRestoring = ref(false)
+
+ // Backup methods
+ const openBackupDialog = (provider) => {
+ currentActionProvider.value = provider
+ backupPassword.value = ''
+ useAutoPassword.value = !!provider.auto_backup
+ showBackupDialog.value = true
+ }
+
+ const handleBackup = async () => {
+ if (!useAutoPassword.value && backupPassword.value.length < 12) {
+ return ElMessage.warning('密码至少12位')
+ }
+
+ const pwdToSend = useAutoPassword.value ? '' : backupPassword.value
+
+ isBackingUp.value = true
+ try {
+ const res = await backupService.triggerBackup(currentActionProvider.value.id, pwdToSend)
+ if (res.success) {
+ ElNotification({
+ title: '云端备份完成',
+ message: `
🎉 成功备份了数据
`,
+ dangerouslyUseHTMLString: true,
+ type: 'success',
+ duration: 5000
+ })
+ showBackupDialog.value = false
+ if (fetchProviders) await fetchProviders()
+ }
+ } catch (e) {
+ ElMessage.error(e.message || '备份失败')
+ } finally { isBackingUp.value = false }
+ }
+
+ // Restore methods
+ const openRestoreDialog = async (provider) => {
+ currentActionProvider.value = provider
+ showRestoreListDialog.value = true
+ isLoadingFiles.value = true
+ try {
+ const res = await backupService.getBackupFiles(provider.id)
+ if (res.success) backupFiles.value = res.files
+ } catch (e) {
+ ElMessage.error(e.message || '获取备份文件失败')
+ } finally { isLoadingFiles.value = false }
+ }
+
+ const selectRestoreFile = (file) => {
+ selectedFile.value = file
+ restorePassword.value = ''
+ showRestoreConfirmDialog.value = true
+ }
+
+ const handleRestore = async () => {
+ isRestoring.value = true
+ try {
+ const downloadRes = await backupService.downloadBackupFile(currentActionProvider.value.id, selectedFile.value.filename)
+
+ let contentToDecrypt = downloadRes.content
+ try {
+ const json = typeof contentToDecrypt === 'string' ? JSON.parse(contentToDecrypt) : contentToDecrypt
+ if (json && json.encrypted && json.data) {
+ contentToDecrypt = json.data
+ }
+ } catch (e) { }
+
+ const vault = await dataMigrationService.parseImportData(contentToDecrypt, 'encrypted', restorePassword.value)
+ const saveRes = await dataMigrationService.saveImportedVault(vault)
+
+ if (saveRes.success) {
+ let msgHtml = `
共处理 1 个备份文件 (${vault.length} 个基准账号)。
`
+ if (saveRes.count > 0) {
+ msgHtml += `
🎉 成功导入 ${saveRes.count} 个新账户!
`
+ } else {
+ msgHtml += `
⚠️ 恢复的账户皆已存在,无新添项。
`
+ }
+ if (saveRes.duplicates > 0) msgHtml += `
ℹ️ 自动跳过了 ${saveRes.duplicates} 个已有账户。
`
+
+ ElNotification({
+ title: '恢复数据结束',
+ message: msgHtml,
+ dangerouslyUseHTMLString: true,
+ type: 'success',
+ duration: 8000
+ })
+
+ showRestoreConfirmDialog.value = false
+ showRestoreListDialog.value = false
+ if (saveRes.count > 0) {
+ vaultStore.markDirty()
+ emit('restore-success')
+ }
+ }
+ } catch (e) {
+ ElMessage.error(e.message || '恢复失败')
+ } finally { isRestoring.value = false }
+ }
+
+ return {
+ // Backup
+ showBackupDialog,
+ backupPassword,
+ isBackingUp,
+ useAutoPassword,
+ currentActionProvider,
+ openBackupDialog,
+ handleBackup,
+
+ // Restore
+ showRestoreListDialog,
+ isLoadingFiles,
+ backupFiles,
+ showRestoreConfirmDialog,
+ restorePassword,
+ selectedFile,
+ isRestoring,
+ openRestoreDialog,
+ selectRestoreFile,
+ handleRestore
+ }
+}
diff --git a/frontend/src/features/backup/composables/useBackupProviders.js b/frontend/src/features/backup/composables/useBackupProviders.js
new file mode 100644
index 0000000..ebc55fd
--- /dev/null
+++ b/frontend/src/features/backup/composables/useBackupProviders.js
@@ -0,0 +1,166 @@
+import { ref, onMounted } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { useVaultStore } from '@/features/vault/store/vaultStore'
+import { backupService } from '@/features/backup/service/backupService'
+
+export function useBackupProviders() {
+ const vaultStore = useVaultStore()
+
+ const providers = ref([])
+ const isLoading = ref(false)
+
+ // Form and Dialog State
+ const showConfigDialog = ref(false)
+ const isEditing = ref(false)
+ const isTesting = ref(false)
+ const isSaving = ref(false)
+
+ const initialFormState = () => ({
+ type: 'webdav',
+ name: '',
+ config: { url: '', username: '', password: '', saveDir: '/', endpoint: '', bucket: '', region: 'auto', accessKeyId: '', secretAccessKey: '' },
+ autoBackup: false,
+ autoBackupPassword: '',
+ autoBackupRetain: 30
+ })
+
+ const form = ref(initialFormState())
+ const currentProviderId = ref(null)
+ const hasExistingAutoPwd = ref(false)
+ const configUseExistingAutoPwd = ref(false)
+
+ // Methods
+ const fetchProviders = async () => {
+ const cachedEncrypted = await vaultStore.getEncryptedBackupProviders()
+ if (cachedEncrypted && Array.isArray(cachedEncrypted)) {
+ providers.value = cachedEncrypted
+ } else {
+ isLoading.value = true
+ }
+
+ try {
+ const res = await backupService.getProviders()
+ if (res.success) {
+ providers.value = res.providers
+ await vaultStore.saveEncryptedBackupProviders(res.providers)
+ }
+ } finally { isLoading.value = false }
+ }
+
+ const openAddDialog = () => {
+ isEditing.value = false
+ form.value = initialFormState()
+ hasExistingAutoPwd.value = false
+ configUseExistingAutoPwd.value = false
+ showConfigDialog.value = true
+ }
+
+ const editProvider = (provider) => {
+ isEditing.value = true
+ currentProviderId.value = provider.id
+ form.value = JSON.parse(JSON.stringify({
+ type: provider.type,
+ name: provider.name,
+ config: provider.config,
+ autoBackup: !!provider.auto_backup,
+ autoBackupPassword: '',
+ autoBackupRetain: provider.auto_backup_retain ?? 30
+ }))
+ hasExistingAutoPwd.value = !!provider.auto_backup_password
+ configUseExistingAutoPwd.value = true
+ showConfigDialog.value = true
+ }
+
+ const validateForm = () => {
+ if (!form.value.name) return '请输入名称'
+ const c = form.value.config
+ if (form.value.type === 'webdav') {
+ if (!c.url) return '请输入 WebDAV 地址'
+ if (!c.username) return '请输入用户名'
+ if (!c.password) return '请输入密码'
+ } else if (form.value.type === 's3') {
+ if (!c.endpoint) return '请输入 Endpoint'
+ if (!c.bucket) return '请输入 Bucket'
+ if (!c.accessKeyId) return '请输入 Access Key ID'
+ if (!c.secretAccessKey) return '请输入 Secret Access Key'
+ }
+
+ if (form.value.autoBackup) {
+ if (isEditing.value && hasExistingAutoPwd.value && configUseExistingAutoPwd.value) {
+ return null
+ }
+ if (!form.value.autoBackupPassword || form.value.autoBackupPassword.length < 12) {
+ return '自动备份密码长度必须至少 12 位'
+ }
+ }
+ return null
+ }
+
+ const testConnection = async () => {
+ const error = validateForm()
+ if (error) return ElMessage.warning(error)
+
+ isTesting.value = true
+ try {
+ const res = await backupService.testConnection(form.value.type, form.value.config)
+ if (res.success) ElMessage.success('连接成功')
+ } catch (e) {
+ ElMessage.error(e.message || '连接失败')
+ } finally { isTesting.value = false }
+ }
+
+ const saveProvider = async () => {
+ const error = validateForm()
+ if (error) return ElMessage.warning(error)
+
+ if (isEditing.value && hasExistingAutoPwd.value && configUseExistingAutoPwd.value) {
+ form.value.autoBackupPassword = ''
+ }
+
+ isSaving.value = true
+ try {
+ const res = isEditing.value
+ ? await backupService.updateProvider(currentProviderId.value, form.value)
+ : await backupService.createProvider(form.value)
+ if (res.success) {
+ ElMessage.success('保存成功')
+ showConfigDialog.value = false
+ await fetchProviders()
+ }
+ } catch (e) {
+ ElMessage.error(e.message || '保存失败')
+ } finally { isSaving.value = false }
+ }
+
+ const deleteProvider = async (provider) => {
+ try {
+ await ElMessageBox.confirm('确定删除该备份源吗?', '警告', { type: 'warning' })
+ await backupService.deleteProvider(provider.id)
+ await fetchProviders()
+ } catch (e) {
+ if (e !== 'cancel') {
+ ElMessage.error(e.message || '删除失败')
+ }
+ }
+ }
+
+ onMounted(fetchProviders)
+
+ return {
+ providers,
+ isLoading,
+ showConfigDialog,
+ isEditing,
+ isTesting,
+ isSaving,
+ form,
+ hasExistingAutoPwd,
+ configUseExistingAutoPwd,
+ fetchProviders,
+ openAddDialog,
+ editProvider,
+ testConnection,
+ saveProvider,
+ deleteProvider
+ }
+}
diff --git a/frontend/src/features/backup/service/backupService.js b/frontend/src/features/backup/service/backupService.js
new file mode 100644
index 0000000..388c2f6
--- /dev/null
+++ b/frontend/src/features/backup/service/backupService.js
@@ -0,0 +1,158 @@
+import { request } from '@/shared/utils/request'
+import { backupError } from '@/shared/utils/errors/backupError'
+
+/**
+ * @typedef {Object} BackupProviderConfig
+ * @property {string} url - WebDAV URL
+ * @property {string} username - WebDAV Username
+ * @property {string} password - WebDAV Password
+ * @property {string} saveDir - Save Directory
+ * @property {string} endpoint - S3 Endpoint
+ * @property {string} bucket - S3 Bucket
+ * @property {string} region - S3 Region
+ * @property {string} accessKeyId - S3 Access Key ID
+ * @property {string} secretAccessKey - S3 Secret Access Key
+ */
+
+/**
+ * @typedef {Object} BackupProvider
+ * @property {string} [id] - Provider ID
+ * @property {string} name - Provider Name
+ * @property {string} type - Provider Type ('webdav' | 's3')
+ * @property {BackupProviderConfig} config - Provider Configuration
+ * @property {boolean} auto_backup - Auto backup enabled
+ * @property {string} [auto_backup_password] - Auto backup encryption password
+ * @property {number} [auto_backup_retain] - Number of backups to retain
+ */
+
+export const backupService = {
+ /**
+ * 获取所有备份源配置
+ * @returns {Promise<{success: boolean, providers: BackupProvider[]}>}
+ * @throws {backupError}
+ */
+ async getProviders() {
+ try {
+ return await request('/api/backups/providers')
+ } catch (e) {
+ throw new backupError('Failed to fetch backup providers', 'PROVIDERS_FETCH_FAILED', e)
+ }
+ },
+
+ /**
+ * 测试备份源连接
+ * @param {string} type - 'webdav' 或 's3'
+ * @param {BackupProviderConfig} config
+ * @returns {Promise<{success: boolean}>}
+ * @throws {backupError}
+ */
+ async testConnection(type, config) {
+ try {
+ return await request('/api/backups/providers/test', {
+ method: 'POST',
+ body: JSON.stringify({ type, config })
+ })
+ } catch (e) {
+ throw new backupError('Failed to test connection', 'CONNECTION_TEST_FAILED', e)
+ }
+ },
+
+ /**
+ * 创建新的备份源
+ * @param {BackupProvider} providerData
+ * @returns {Promise<{success: boolean, provider: BackupProvider}>}
+ * @throws {backupError}
+ */
+ async createProvider(providerData) {
+ try {
+ return await request('/api/backups/providers', {
+ method: 'POST',
+ body: JSON.stringify(providerData)
+ })
+ } catch (e) {
+ throw new backupError('Failed to create provider', 'PROVIDER_CREATE_FAILED', e)
+ }
+ },
+
+ /**
+ * 更新现有备份源
+ * @param {string} id
+ * @param {BackupProvider} providerData
+ * @returns {Promise<{success: boolean}>}
+ * @throws {backupError}
+ */
+ async updateProvider(id, providerData) {
+ try {
+ return await request(`/api/backups/providers/${id}`, {
+ method: 'PUT',
+ body: JSON.stringify(providerData)
+ })
+ } catch (e) {
+ throw new backupError('Failed to update provider', 'PROVIDER_UPDATE_FAILED', e)
+ }
+ },
+
+ /**
+ * 删除备份源
+ * @param {string} id
+ * @returns {Promise<{success: boolean}>}
+ * @throws {backupError}
+ */
+ async deleteProvider(id) {
+ try {
+ return await request(`/api/backups/providers/${id}`, { method: 'DELETE' })
+ } catch (e) {
+ throw new backupError('Failed to delete provider', 'PROVIDER_DELETE_FAILED', e)
+ }
+ },
+
+ /**
+ * 手动触发一次备份
+ * @param {string} id
+ * @param {string} password - 备份加密密码 (若使用自动备份密码则为空)
+ * @returns {Promise<{success: boolean}>}
+ * @throws {backupError}
+ */
+ async triggerBackup(id, password = '') {
+ try {
+ return await request(`/api/backups/providers/${id}/backup`, {
+ method: 'POST',
+ body: JSON.stringify({ password })
+ })
+ } catch (e) {
+ throw new backupError('Failed to trigger backup', 'BACKUP_TRIGGER_FAILED', e)
+ }
+ },
+
+ /**
+ * 获取备份历史文件列表
+ * @param {string} id
+ * @returns {Promise<{success: boolean, files: Object[]}>}
+ * @throws {backupError}
+ */
+ async getBackupFiles(id) {
+ try {
+ return await request(`/api/backups/providers/${id}/files`)
+ } catch (e) {
+ throw new backupError('Failed to fetch backup files', 'FILES_FETCH_FAILED', e)
+ }
+ },
+
+ /**
+ * 下载加密的备份文件
+ * @param {string} id
+ * @param {string} filename
+ * @returns {Promise<{success: boolean, content: string|Object}>}
+ * @throws {backupError}
+ */
+ async downloadBackupFile(id, filename) {
+ try {
+ return await request(`/api/backups/providers/${id}/download`, {
+ method: 'POST',
+ body: JSON.stringify({ filename })
+ })
+ } catch (e) {
+ throw new backupError('Failed to download backup file', 'FILE_DOWNLOAD_FAILED', e)
+ }
+ }
+}
diff --git a/frontend/src/features/backup/service/migrationService.js b/frontend/src/features/backup/service/migrationService.js
deleted file mode 100644
index 57a3cc0..0000000
--- a/frontend/src/features/backup/service/migrationService.js
+++ /dev/null
@@ -1,171 +0,0 @@
-import { request } from '@/shared/utils/request'
-import { encryptDataWithPassword, decryptDataWithPassword } from '@/shared/utils/crypto' // 现在已指向标准实现
-import { parseOtpUri } from '@/shared/utils/totp'
-
-export const migrationService = {
- // 1. 获取所有数据 (用于导出)
- async fetchAllVault() {
- // 请求后端获取所有数据 (limit=9999)
- const res = await request('/api/vault?limit=9999')
- if (!res.success) throw new Error('无法获取账号数据')
- return res.vault || []
- },
-
- // 2. 处理导出逻辑 (前端加密/格式化)
- async exportData(vault, type, password) {
- const timestamp = new Date().toISOString()
- const baseData = { version: "2.0", app: "2fauth", timestamp }
-
- // 格式 A: 本系统加密备份 (使用 crypto-vault AES-GCM)
- if (type === 'encrypted') {
- if (!password) throw new Error('加密导出需要密码')
- const payload = { ...baseData, accounts: vault }
- const encryptedData = await encryptDataWithPassword(payload, password)
- return JSON.stringify({
- ...baseData,
- encrypted: true,
- data: encryptedData,
- note: "This file is encrypted with your export password (AES-GCM-256 + PBKDF2)."
- }, null, 2)
- }
-
- // 格式 B: 标准 JSON
- if (type === 'json') {
- return JSON.stringify({ ...baseData, encrypted: false, accounts: vault }, null, 2)
- }
-
- // 格式 C: 2FAS 兼容格式 (schemaVersion 4 标准)
- if (type === '2fas') {
- const services = vault.map(acc => ({
- name: acc.service,
- secret: acc.secret,
- otp: {
- tokenType: 'TOTP',
- issuer: acc.service,
- account: acc.account,
- digits: acc.digits,
- period: acc.period,
- algorithm: (acc.algorithm || 'SHA1').replace('SHA-', 'SHA'),
- counter: 0,
- },
- order: { position: 0 },
- }))
- return JSON.stringify({ schemaVersion: 4, appOrigin: 'export', services }, null, 2)
- }
-
- // 格式 D: OTPAuth 纯文本格式
- if (type === 'text') {
- return vault.map(acc => {
- const label = encodeURIComponent(`${acc.service}:${acc.account}`)
- const issuer = encodeURIComponent(acc.service)
- return `otpauth://totp/${label}?secret=${acc.secret}&issuer=${issuer}&digits=${acc.digits}&period=${acc.period}`
- }).join('\n')
- }
-
- throw new Error('未知的导出类型')
- },
-
- // 3. 处理导入逻辑 (前端解密/解析)
- async parseImportData(content, type, password) {
- let rawVault = []
-
- // 场景 A: 加密备份
- if (type === 'encrypted') {
- if (!password) throw new Error('导入加密文件需要密码')
- try {
- let ciphertext = content
- const json = typeof content === 'string' ? tryParseJSON(content) : content
- if (json && typeof json === 'object' && json.data) {
- ciphertext = json.data
- }
- const input = typeof ciphertext === 'object' ? JSON.stringify(ciphertext) : ciphertext
- const decrypted = await decryptDataWithPassword(input, password)
- rawVault = decrypted.vault || decrypted.accounts || []
- } catch (e) {
- console.error(e)
- throw new Error('解密失败:密码错误或文件格式不兼容')
- }
- }
- // 场景 B: 标准 JSON / 2FAuth
- else if (type === 'json') {
- const json = typeof content === 'string' ? JSON.parse(content) : content
- // 本项目导出格式: data.accounts[] 或 data.vault[]
- if (Array.isArray(json.accounts)) {
- rawVault = json.accounts
- } else if (Array.isArray(json.vault)) {
- rawVault = json.vault
- // 旧导出格式: data.data[]
- } else if (Array.isArray(json.data)) {
- rawVault = json.data
- // 标准第三方格式: data.secrets[] (issuer + algorithm 字段)
- } else if (Array.isArray(json.secrets)) {
- rawVault = json.secrets.map(s => ({
- service: s.issuer || s.service || s.name || 'Unknown',
- account: s.account || s.label || '',
- secret: s.secret || '',
- algorithm: s.algorithm || 'SHA1',
- digits: s.digits || 6,
- period: s.period || 30,
- }))
- // 简单数组
- } else if (Array.isArray(json)) {
- rawVault = json
- // 误选 JSON 但实际是 2FAS 格式
- } else if (json.services) {
- rawVault = json.services.map(s => ({
- service: s.otp?.issuer || s.name || 'Unknown',
- account: s.otp?.account || s.account || s.username || '',
- secret: s.secret || '',
- algorithm: s.otp?.algorithm || s.algorithm || 'SHA1',
- digits: s.otp?.digits || s.digits || 6,
- period: s.otp?.period || s.period || 30,
- }))
- }
- }
- // 场景 C: 2FAS (schemaVersion 4, 字段在 otp 子对象中)
- else if (type === '2fas') {
- const json = typeof content === 'string' ? JSON.parse(content) : content
- if (Array.isArray(json.services)) {
- rawVault = json.services.map(s => ({
- service: s.otp?.issuer || s.name || 'Unknown',
- account: s.otp?.account || s.account || s.username || '',
- secret: s.secret || '',
- algorithm: (s.otp?.algorithm || s.algorithm || 'SHA1').toUpperCase(),
- digits: s.otp?.digits || s.digits || 6,
- period: s.otp?.period || s.period || 30,
- }))
- }
- }
- // 场景 D: OTPAuth 纯文本格式
- else if (type === 'text') {
- const lines = content.split('\n')
- lines.forEach(line => {
- const acc = parseOtpUri(line.trim())
- if (acc) rawVault.push(acc)
- })
- }
-
- // 过滤无效数据
- return rawVault.filter(acc => acc && acc.secret && acc.service && acc.account)
- },
-
-
- // 4. 批量保存到后端s
- async saveImportedVault(vault) {
- // 调用后端批量导入接口 (需要后端支持接收纯 JSON 数组)
- // 我们将复用 /api/vault/import 接口,但修改其实现以支持 'raw' 类型
- return await request('/api/vault/import', {
- method: 'POST',
- body: JSON.stringify({ type: 'raw', content: JSON.stringify(vault) })
- })
- }
-}
-
-// 辅助函数:安全解析 JSON,失败返回 null
-function tryParseJSON(str) {
- try {
- return JSON.parse(str)
- } catch (e) {
- return null
- }
-}
\ No newline at end of file
diff --git a/frontend/src/features/backup/views/dataExport.vue b/frontend/src/features/backup/views/dataExport.vue
deleted file mode 100644
index f801a10..0000000
--- a/frontend/src/features/backup/views/dataExport.vue
+++ /dev/null
@@ -1,119 +0,0 @@
-
-
-
-
-
数据导出
-
选择您需要的导出格式。请注意,明文导出存在安全风险。
-
-
-
-
- 🔒 本系统加密备份 (.json)
- 📄 标准 JSON / 2FAuth (.json)
- 📱 2FAS 备份 (.2fas)
- 📝 纯文本 URI (.txt)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 取消
- 开始加密并下载
-
-
-
-
-
-
- 确定要继续吗?
-
- 取消
- 确定导出
-
-
-
-
-
-
diff --git a/frontend/src/features/backup/views/dataImport.vue b/frontend/src/features/backup/views/dataImport.vue
deleted file mode 100644
index 1da19ce..0000000
--- a/frontend/src/features/backup/views/dataImport.vue
+++ /dev/null
@@ -1,117 +0,0 @@
-
-
-
-
-
数据导入
-
支持从各类 2FA 软件或本系统的备份文件中恢复数据。
-
-
-
-
- 🔒 本系统加密备份 (.json)
- 📄 标准 JSON / 2FAuth (.json)
- 📱 2FAS 备份 (.2fas)
- 📝 OTPAuth 纯文本格式 (.txt)
-
-
-
-
-
-
-
-
-
-
-
-
-
- 取消
- 确认解密并导入
-
-
-
-
-
-
\ No newline at end of file
diff --git a/frontend/src/features/migration/composables/useDataExport.js b/frontend/src/features/migration/composables/useDataExport.js
new file mode 100644
index 0000000..eb7b843
--- /dev/null
+++ b/frontend/src/features/migration/composables/useDataExport.js
@@ -0,0 +1,208 @@
+import { ref } from 'vue'
+import { ElMessage } from 'element-plus'
+import { dataMigrationService } from '@/features/migration/service/dataMigrationService'
+import { downloadBlob } from '@/shared/utils/common'
+
+export function useDataExport() {
+ const showPasswordDialog = ref(false)
+ const showWarningDialog = ref(false)
+ const showGaDialog = ref(false)
+ const isExporting = ref(false)
+ const exportForm = ref({ password: '', confirm: '' })
+ const loadingText = ref('')
+
+ const currentType = ref('')
+ const currentVariant = ref('generic')
+
+ // GA 分批导出相关
+ const gaQrDataUrls = ref([])
+ const gaCurrentIndex = ref(0)
+
+ // GA 按需导出选择器相关
+ const showAccountSelectDialog = ref(false)
+ const fullVault = ref([])
+ const searchKeyword = ref('')
+ const selectedAccountIds = ref([])
+
+ const openExportDialog = (type, variant = 'generic') => {
+ currentType.value = type
+ currentVariant.value = variant
+ exportForm.value = { password: '', confirm: '' }
+ showPasswordDialog.value = true
+ }
+
+ const openWarningDialog = (type, variant = 'generic') => {
+ currentType.value = type
+ currentVariant.value = variant
+ showWarningDialog.value = true
+ }
+
+ // 计算属性:过滤后的账号列表
+ const filteredVault = computed(() => {
+ const keyword = searchKeyword.value.toLowerCase().trim()
+ if (!keyword) return fullVault.value
+ return fullVault.value.filter(acc =>
+ (acc.service && acc.service.toLowerCase().includes(keyword)) ||
+ (acc.account && acc.account.toLowerCase().includes(keyword))
+ )
+ })
+
+ // 直接开启 Google Auth 导出流程(跳过明文警告弹窗)
+ const openGaDialogDirectly = () => {
+ currentType.value = 'gauth'
+ executeExport()
+ }
+
+ // 全选/取消全选 (仅作用于当前过滤结果)
+ const toggleSelectAll = (val) => {
+ const filteredIds = filteredVault.value.map(acc => acc.id)
+ if (val) {
+ // 将不在已选列表中的过滤结果加入已选列表
+ const newIds = filteredIds.filter(id => !selectedAccountIds.value.includes(id))
+ selectedAccountIds.value.push(...newIds)
+ } else {
+ // 将过滤结果从已选列表中移除
+ selectedAccountIds.value = selectedAccountIds.value.filter(id => !filteredIds.includes(id))
+ }
+ }
+
+ const executeExport = async () => {
+ const type = currentType.value
+ const variant = currentVariant.value
+ let password = ''
+
+ if (type === 'encrypted') {
+ if (exportForm.value.password !== exportForm.value.confirm) {
+ return ElMessage.error('两次输入的密码不一致!')
+ }
+ if (exportForm.value.password.length < 12) {
+ return ElMessage.error('密码太弱!至少需要 12 个字符。')
+ }
+ password = exportForm.value.password
+ }
+
+ isExporting.value = true
+ showPasswordDialog.value = false
+ showWarningDialog.value = false
+
+ try {
+ loadingText.value = '正在获取账号数据...'
+ const vault = await dataMigrationService.fetchAllVault()
+ if (!vault || vault.length === 0) {
+ throw new Error('没有可导出的账号')
+ }
+
+ loadingText.value = type === 'encrypted' ? '正在加密...' : '正在生成导出文件...'
+
+ // 特殊处理 Google Auth (不再直接生成,而是进入选择界面)
+ if (type === 'gauth') {
+ fullVault.value = vault
+ // 默认全不选 (由用户的偏好修改)
+ selectedAccountIds.value = []
+ searchKeyword.value = ''
+ showAccountSelectDialog.value = true
+ isExporting.value = false
+ return
+ }
+
+ // 特殊处理 HTML 报告导出
+ if (type === 'html') {
+ const htmlContent = await dataMigrationService.exportAsHtml(vault)
+ downloadBlob(htmlContent, `2fa-backup-report-${new Date().toISOString().split('T')[0]}.html`, 'text/html')
+ ElMessage.success('导出完成!请在浏览器中打开该文件。')
+ isExporting.value = false
+ return
+ }
+
+ const fileContent = await dataMigrationService.exportData(vault, type, password, variant)
+
+ let filename, mimeType
+ const date = new Date().toISOString().split('T')[0]
+
+ switch (type) {
+ case 'text':
+ mimeType = 'text/plain'
+ filename = `2fa-export-otpauth-${date}.txt`
+ break
+ case 'csv':
+ mimeType = 'text/csv'
+ filename = `2fa-export-csv-${date}.csv`
+ break
+ case 'bwauth':
+ mimeType = 'application/json'
+ filename = `2fa-export-bwauth-${date}.json`
+ break
+ case '2fas':
+ mimeType = 'application/json'
+ filename = `2fa-export-2fas-${date}.2fas`
+ break
+ case 'generic_json':
+ mimeType = 'application/json'
+ filename = `2fa-export-generic-json-${date}.json`
+ break
+ default:
+ // Covers 'encrypted', 'json', 'aegis'
+ mimeType = 'application/json'
+ filename = `2fa-export-${type}-${date}.json`
+ break
+ }
+
+ downloadBlob(fileContent, filename, mimeType)
+ ElMessage.success('导出成功!')
+ } catch (error) {
+ console.error('Export failed:', error)
+ ElMessage.error(error.message || '导出失败,请稍后重试')
+ } finally {
+ isExporting.value = false
+ }
+ }
+
+ // 执行生成所选的 Google Auth 二维码
+ const executeGaExport = async () => {
+ if (selectedAccountIds.value.length === 0) {
+ return ElMessage.warning('请至少选择一个账号')
+ }
+
+ isExporting.value = true
+ showAccountSelectDialog.value = false
+ loadingText.value = '正在生成二维码...'
+
+ try {
+ const selectedVault = fullVault.value.filter(acc => selectedAccountIds.value.includes(acc.id))
+ showGaDialog.value = true
+ gaQrDataUrls.value = []
+ gaCurrentIndex.value = 0
+ gaQrDataUrls.value = await dataMigrationService.exportAsGaMigration(selectedVault)
+ } catch (error) {
+ console.error('GA Export failed:', error)
+ ElMessage.error(error.message || '生成二维码失败')
+ } finally {
+ isExporting.value = false
+ }
+ }
+
+ return {
+ // State
+ showPasswordDialog,
+ showWarningDialog,
+ showAccountSelectDialog,
+ showGaDialog,
+ isExporting,
+ exportForm,
+ loadingText,
+ gaQrDataUrls,
+ gaCurrentIndex,
+ fullVault,
+ searchKeyword,
+ selectedAccountIds,
+ filteredVault,
+
+ // Methods
+ openExportDialog,
+ openWarningDialog,
+ openGaDialogDirectly,
+ executeExport,
+ executeGaExport,
+ toggleSelectAll
+ }
+}
diff --git a/frontend/src/features/migration/composables/useDataImport.js b/frontend/src/features/migration/composables/useDataImport.js
new file mode 100644
index 0000000..523545c
--- /dev/null
+++ b/frontend/src/features/migration/composables/useDataImport.js
@@ -0,0 +1,291 @@
+import { ref, computed, watch } from 'vue'
+import { ElMessage, ElNotification } from 'element-plus'
+import { dataMigrationService } from '@/features/migration/service/dataMigrationService'
+import { useVaultStore } from '@/features/vault/store/vaultStore'
+
+export function useDataImport(emitFn) {
+ const vaultStore = useVaultStore()
+
+ // --- State ---
+ const currentFileContent = ref('')
+ const currentFile = ref(null)
+ const currentImportType = ref('')
+ const showDecryptDialog = ref(false)
+ const importPassword = ref('')
+ const importingJobs = ref(0)
+ const isImporting = computed(() => importingJobs.value > 0)
+ const isDecrypting = ref(false)
+ const isDialogHandled = ref(false)
+
+ // Batch properties
+ const batchTotalJobs = ref(0)
+ const batchProcessedJobs = ref(0)
+ const batchAccumulatedVault = ref([])
+ const batchErrors = ref([])
+ const batchCurrentTaskName = ref('')
+ const showBatchProgress = ref(false)
+ const batchProgressPercent = computed(() => {
+ if (batchTotalJobs.value === 0) return 0
+ return Math.floor((batchProcessedJobs.value / batchTotalJobs.value) * 100)
+ })
+
+ let finalizeTimer = null
+
+ // --- Watchers ---
+ watch(importingJobs, (newVal) => {
+ if (newVal === 0 && batchTotalJobs.value > 0 && !showDecryptDialog.value) {
+ if (finalizeTimer) clearTimeout(finalizeTimer)
+ finalizeTimer = setTimeout(() => {
+ finishBatchImport()
+ }, 300)
+ }
+ })
+
+ // --- Core Logic ---
+ const initBatchTask = (taskCount) => {
+ if (batchTotalJobs.value === 0) {
+ batchAccumulatedVault.value = []
+ batchErrors.value = []
+ showBatchProgress.value = true
+ }
+ importingJobs.value += taskCount
+ batchTotalJobs.value += taskCount
+ }
+
+ const resetBatchState = () => {
+ batchTotalJobs.value = 0
+ batchProcessedJobs.value = 0
+ importingJobs.value = 0
+ batchAccumulatedVault.value = []
+ batchErrors.value = []
+ batchCurrentTaskName.value = ''
+ }
+
+ const handleFileUpload = async (uploadFile) => {
+ const file = uploadFile.raw
+ if (!file) return
+
+ if (file.size > 10 * 1024 * 1024) {
+ ElMessage.error(`文件 ${file.name} 太大,不能超过 10MB`)
+ return
+ }
+
+ initBatchTask(1)
+ batchCurrentTaskName.value = `准备解析:${file.name}`
+
+ if (file.type.startsWith('image/')) {
+ await handleImageFile(file)
+ } else {
+ handleNonImageFile(file)
+ }
+ }
+
+ const handleImageFile = async (file) => {
+ try {
+ batchCurrentTaskName.value = `截取图中二维码:${file.name}`
+ const vault = await dataMigrationService.parseGaQrImageFile(file)
+ batchAccumulatedVault.value.push(...vault)
+ } catch (err) {
+ console.error(err)
+ batchErrors.value.push(`[${file.name}] 解析图片失败: ${err.message}`)
+ } finally {
+ batchProcessedJobs.value++
+ importingJobs.value--
+ }
+ }
+
+ const handleNonImageFile = (file) => {
+ try {
+ currentFile.value = file
+ const reader = new FileReader()
+
+ // 无论文件名为何,都先读取成二进制,再由 detectFileType 判断类型
+ reader.onload = async (event) => {
+ const buffer = event.target.result
+ currentFileContent.value = buffer
+
+ const detectedType = dataMigrationService.detectFileType(buffer, file.name)
+ currentImportType.value = detectedType
+
+ if (detectedType === 'unknown') {
+ batchErrors.value.push(`[${file.name}] 无法识别该文件格式。`)
+ batchProcessedJobs.value++
+ importingJobs.value--
+ return
+ }
+
+ // 如果不是 phonefactor,后续解析需要字符串内容
+ let contentForParse = buffer
+ if (detectedType !== 'phonefactor') {
+ try {
+ contentForParse = new TextDecoder().decode(buffer)
+ } catch {
+ // fallback to array buffer if decoding fails; service handles it
+ }
+ }
+
+ if (detectedType === 'encrypted' || detectedType === 'aegis_encrypted') {
+ importPassword.value = ''
+ isDialogHandled.value = false
+ showDecryptDialog.value = true
+ // keep job active until unblocked by decrypt action
+ currentFileContent.value = contentForParse
+ return
+ }
+
+ batchCurrentTaskName.value = `构建数据:${file.name}`
+ try {
+ const vault = await dataMigrationService.parseImportData(contentForParse, detectedType)
+ batchAccumulatedVault.value.push(...vault)
+ } catch (err) {
+ console.error(`[${file.name}] parseImportData failed:`, err)
+ const detail = err && err.message ? err.message : String(err)
+ batchErrors.value.push(`[${file.name}] 解析失败: ${detail}`)
+ } finally {
+ batchProcessedJobs.value++
+ importingJobs.value--
+ }
+ }
+
+ reader.onerror = () => {
+ batchErrors.value.push(`[${file.name}] 读取失败`)
+ batchProcessedJobs.value++
+ importingJobs.value--
+ }
+
+ reader.readAsArrayBuffer(file)
+
+ } catch (error) {
+ console.error(error)
+ batchErrors.value.push(`[${file.name}] 读取异常`)
+ batchProcessedJobs.value++
+ importingJobs.value--
+ }
+ }
+
+ const submitEncryptedData = async () => {
+ if (!importPassword.value) {
+ return ElMessage.warning('请输入解密密码')
+ }
+
+ isDecrypting.value = true
+ try {
+ batchCurrentTaskName.value = '正在使用密码解密数据...'
+ const vault = await dataMigrationService.parseImportData(
+ currentFileContent.value,
+ currentImportType.value,
+ importPassword.value
+ )
+ isDialogHandled.value = true
+ showDecryptDialog.value = false
+ batchAccumulatedVault.value.push(...vault)
+ batchProcessedJobs.value++
+ importingJobs.value--
+ } catch (error) {
+ console.error(error)
+ ElMessage.error(`解密失败:密码错误或不支持加密标准`)
+ // do not decrement job, user can try again
+ } finally {
+ isDecrypting.value = false
+ }
+ }
+
+ const handleDecryptDialogClose = () => {
+ if (!isDialogHandled.value) {
+ batchErrors.value.push(`[${currentFile.value?.name || '加密文件'}] 用户取消密码输入`)
+ batchProcessedJobs.value++
+ importingJobs.value--
+ isDialogHandled.value = true
+ }
+ }
+
+ const finishBatchImport = async () => {
+ if (batchAccumulatedVault.value.length === 0) {
+ showBatchProgress.value = false
+ if (batchErrors.value.length > 0) {
+ ElNotification({
+ title: '导入异常',
+ message: batchErrors.value.join('
'),
+ type: 'error',
+ dangerouslyUseHTMLString: true,
+ duration: 0
+ })
+ } else {
+ ElMessage.warning('没有可导入的有效账户数据')
+ }
+ resetBatchState()
+ return
+ }
+
+ // 先在前端做简单的去重处理(大小写/空格不敏感),避免重复发送相同条目
+ const normalize = (s, a) => `${(s||'').toString().trim().toLowerCase()}:${(a||'').toString().trim().toLowerCase()}`;
+ const seen = new Set();
+ const filteredVault = [];
+ for (const acc of batchAccumulatedVault.value) {
+ const sig = normalize(acc.service, acc.account);
+ if (!seen.has(sig)) {
+ seen.add(sig);
+ filteredVault.push(acc);
+ }
+ }
+ batchAccumulatedVault.value = filteredVault;
+ batchCurrentTaskName.value = `合并保存中... (${batchAccumulatedVault.value.length} 个账号)`
+
+ try {
+ const data = await dataMigrationService.saveImportedVault(batchAccumulatedVault.value)
+ showBatchProgress.value = false
+
+ if (data.success) {
+ let msgHtml = `
共处理 ${batchTotalJobs.value} 个文件。
`
+ if (data.count > 0) {
+ msgHtml += `
🎉 成功导入 ${data.count} 个新账户!
`
+ } else {
+ msgHtml += `
⚠️ 导入账户皆已存在,无需添加。
`
+ }
+
+ if (data.duplicates > 0) msgHtml += `
ℹ️ 自动跳过了 ${data.duplicates} 个已拥有的账户。
`
+
+ if (batchErrors.value.length > 0) {
+ msgHtml += `
❌ 错误摘要:
${batchErrors.value.join('
')}
`
+ }
+
+ ElNotification({
+ title: '批量导入结束',
+ message: msgHtml,
+ dangerouslyUseHTMLString: true,
+ type: batchErrors.value.length > 0 ? 'warning' : 'success',
+ duration: batchErrors.value.length > 0 ? 0 : 8000
+ })
+
+ if (data.count > 0) {
+ vaultStore.markDirty()
+ emitFn('success')
+ }
+ } else {
+ ElMessage.error(`后端合并保存失败,请检查网络`)
+ }
+ } catch (err) {
+ showBatchProgress.value = false
+ ElMessage.error(`最终保存异常:${err.message}`)
+ } finally {
+ resetBatchState()
+ }
+ }
+
+ return {
+ // State
+ currentImportType,
+ showDecryptDialog,
+ importPassword,
+ isDecrypting,
+ showBatchProgress,
+ batchCurrentTaskName,
+ batchProcessedJobs,
+ batchTotalJobs,
+ batchProgressPercent,
+ // Handlers
+ handleFileUpload,
+ submitEncryptedData,
+ handleDecryptDialogClose
+ }
+}
diff --git a/frontend/src/features/migration/service/dataMigrationService.js b/frontend/src/features/migration/service/dataMigrationService.js
new file mode 100644
index 0000000..97e481c
--- /dev/null
+++ b/frontend/src/features/migration/service/dataMigrationService.js
@@ -0,0 +1,722 @@
+import { vaultService } from '@/features/vault/service/vaultService'
+import { encryptDataWithPassword, decryptDataWithPassword } from '@/shared/utils/crypto'
+import { parseOtpUri } from '@/shared/utils/totp'
+import { tryParseJSON, base64ToBytes } from '@/shared/utils/encoding'
+import { gaMigrationStrategy } from '@/shared/utils/serializers/gauthStrategy'
+import { csvStrategy } from '@/shared/utils/serializers/csvStrategy'
+import { aegisStrategy } from '@/shared/utils/serializers/aegisStrategy'
+import { migrationError } from '@/shared/utils/errors/migrationError'
+import jsQR from 'jsqr'
+import initSqlJs from 'sql.js'
+
+/**
+ * 数据迁移服务核心控制器
+ * 负责智能分发不同来源与格式的导入导出请求
+ */
+export const dataMigrationService = {
+ /**
+ * 智能识别导入内容或文件的类型
+ * @param {string|ArrayBuffer|Uint8Array} content - 文件文本内容或二进制数据
+ * @param {string} filename - 文件名
+ * @returns {'bwauth_csv'|'generic_csv'|'text'|'bwauth_json'|'encrypted'|'json'|'2fas'|'aegis'|'aegis_encrypted'|'phonefactor'|'unknown'} 返回类型标识
+ */
+ detectFileType(content, filename) {
+ // 如果是二进制数据,尝试用更宽容的方式判断 PhoneFactor
+ if (content instanceof ArrayBuffer || content instanceof Uint8Array) {
+ const bytes = content instanceof Uint8Array ? content : new Uint8Array(content)
+ // 1) 检查 SQLite 文件头
+ const header = 'SQLite format 3'
+ let headerMatch = true
+ for (let i = 0; i < header.length && i < bytes.length; i++) {
+ if (bytes[i] !== header.charCodeAt(i)) { headerMatch = false; break }
+ }
+ if (headerMatch) return 'phonefactor'
+
+ // 2) 快速 ASCII 搜索表/列名(容错,某些导出会保留表名字符串在文件内部)
+ try {
+ const hay = new TextDecoder('utf-8', { fatal: false }).decode(bytes)
+ if (hay.includes('accounts') && (hay.includes('oath_secret_key') || hay.includes('encrypted_oath_secret_key'))) return 'phonefactor'
+ } catch (e) { /* ignore decode errors */ }
+ }
+
+ // 宽容的文件名匹配(包含 phonefactor 字样)
+ if (filename && filename.toLowerCase().includes('phonefactor')) {
+ // 如果文件名包含 phonefactor,则谨慎标记为 phonefactor(上层读取时还会再校验头/表)
+ return 'phonefactor'
+ }
+
+ if (filename && filename.toLowerCase().endsWith('.csv')) {
+ const firstLine = content.split('\n')[0].toLowerCase()
+ if (firstLine.includes('login_totp')) return 'bwauth_csv'
+ return 'generic_csv'
+ }
+
+ if (typeof content === 'string' && content.trim().startsWith('otpauth://')) {
+ return 'text'
+ }
+
+ if (typeof content === 'string') {
+ const json = tryParseJSON(content)
+ if (json) {
+ if (Array.isArray(json.items) && json.items.length > 0 && json.items[0].login && json.items[0].login.totp) return 'bwauth_json'
+ if (json.encrypted === true && json.app === '2fauth') return 'encrypted'
+ if (json.app === '2fauth' || Array.isArray(json.accounts) || Array.isArray(json.vault) || Array.isArray(json.secrets)) return 'json'
+ if (json.schemaVersion && Array.isArray(json.services)) return '2fas'
+ if (json.version === 1 && json.db && typeof json.db === 'object' && Array.isArray(json.db.entries)) return 'aegis' // Aegis unencrypted
+ if (json.version === 1 && json.header && json.db && typeof json.db === 'string') return 'aegis_encrypted'
+ }
+ }
+
+ if (filename) {
+ const ext = filename.toLowerCase()
+ if (ext.endsWith('.2fas')) return '2fas'
+ if (ext.endsWith('.txt')) return 'text'
+ }
+
+ return 'unknown'
+ },
+
+ /**
+ * 1. 获取所有数据 (用于导出)
+ * @returns {Promise