feat: 支持更多密码管理器csv格式的备份数据导入

This commit is contained in:
nap0o committed 2026-03-17 17:41:46 +08:00
1 parent 1b809cfc24
commit d8daff4f0c
10 files changed
+130 -25

No files matched your search

@@ -63,6 +63,12 @@
<el-button plain class="migration-button-with-icon" @click="triggerUpload"><el-icon><iconBitwardenPass /></el-icon> Bitwarden Pass (.json/.csv)</el-button>
<el-button plain class="migration-button-with-icon" @click="triggerUpload"><el-icon><icon1Password /></el-icon> 1Password (.1pux/.csv)</el-button>
<el-button plain class="migration-button-with-icon" @click="triggerUpload"><el-icon><iconProtonPass /></el-icon> Proton Pass (.pgp/.csv)</el-button>
<el-button plain class="migration-button-with-icon" @click="triggerUpload"><el-icon><iconKeeper /></el-icon> Keeper (.csv)</el-button>
<el-button plain class="migration-button-with-icon" @click="triggerUpload"><el-icon><iconNordPass /></el-icon> NordPass (.csv)</el-button>
<el-button plain class="migration-button-with-icon" @click="triggerUpload"><el-icon><iconEnpass /></el-icon> Enpass (.csv)</el-button>
<el-button plain class="migration-button-with-icon" @click="triggerUpload"><el-icon><iconRoboForm /></el-icon> Roboform (.csv)</el-button>
<el-button plain class="migration-button-with-icon" @click="triggerUpload"><el-icon><iconKeePass /></el-icon> KeePassXC (.csv)</el-button>
<el-button plain class="migration-button-with-icon" @click="triggerUpload"><el-icon><iconApple /></el-icon> iCloud Keychain (.csv)</el-button>
<el-button plain class="migration-button-with-icon" @click="triggerUpload"><el-icon><iconDashlanePass /></el-icon> Dashlane (.csv)</el-button>
</div>
</div>
@@ -197,6 +203,12 @@ import iconProtonPass from '@/shared/components/icons/iconProtonPass.vue'
import iconEnte from '@/shared/components/icons/iconEnte.vue'
import icon1Password from '@/shared/components/icons/icon1Password.vue'
import iconLastPassAuth from '@/shared/components/icons/iconLastPassAuth.vue'
import iconKeeper from '@/shared/components/icons/iconKeeper.vue'
import iconNordPass from '@/shared/components/icons/iconNordPass.vue'
import iconRoboForm from '@/shared/components/icons/iconRoboForm.vue'
import iconEnpass from '@/shared/components/icons/iconEnpass.vue'
import iconKeePass from '@/shared/components/icons/iconKeePass.vue'
import iconApple from '@/shared/components/icons/iconApple.vue'
const emit = defineEmits(['success'])
@@ -0,0 +1,8 @@
<template>
<svg viewBox="6 6 36 36" xmlns="http://www.w3.org/2000/svg" height="24" width="24">
<path d="M36.1,26.5c0-4.9,4-7.3,4.2-7.5c-2.3-3.3-5.8-3.8-7-3.8c-2.9-0.3-5.7,1.7-7.2,1.7c-1.5,0-3.8-1.7-6.2-1.7 c-3.1,0-6,1.8-7.6,4.6c-3.2,5.6-0.8,13.9,2.3,18.4c1.5,2.2,3.3,4.6,5.7,4.5c2.3-0.1,3.2-1.5,6-1.5c2.8,0,3.6,1.5,6,1.4 c2.5-0.1,4-2.2,5.5-4.4c1.8-2.6,2.5-5.1,2.6-5.2C40.3,33.1,36.1,31.5,36.1,26.5z M30.8,11.3c1.3-1.6,2.2-3.8,1.9-6.1 c-2,0.1-4.4,1.3-5.8,3c-1.3,1.5-2.4,3.7-2.1,5.9C27.1,14.2,29.4,12.9,30.8,11.3z" fill="currentColor"/>
</svg>
</template>
<script>
export default { name: 'iconApple' }
</script>
@@ -0,0 +1,8 @@
<template>
<svg viewBox="0 0 192 192" xmlns="http://www.w3.org/2000/svg" fill="none">
<path fill="currentColor" fill-rule="evenodd" d="M45.264 36.76c-10.63 0-18.583 9.76-16.435 20.173l15.646 75.828c2.701 13.089 14.226 22.479 27.59 22.479h47.87c13.364 0 24.889-9.39 27.59-22.479l15.646-75.828c2.148-10.413-5.804-20.174-16.435-20.174H45.264ZM17.077 59.357C13.392 41.5 27.03 24.76 45.264 24.76h101.472c18.234 0 31.872 16.741 28.187 34.6l-15.646 75.827c-3.851 18.664-20.285 32.054-39.342 32.054h-47.87c-19.057 0-35.491-13.39-39.342-32.054L17.077 59.358Zm43.927 21.108c0-18.859 15.54-33.96 34.478-33.96 18.938 0 34.478 15.101 34.478 33.96 0 11.459-5.756 21.546-14.498 27.679v25.135c0 6.745-5.468 12.214-12.213 12.214H88.751c-6.745 0-12.213-5.469-12.213-12.214v-24.436c-9.322-6.05-15.534-16.472-15.534-28.378Zm34.478-21.96c-12.518 0-22.478 9.935-22.478 21.96 0 8.467 4.914 15.88 12.225 19.549a6.001 6.001 0 0 1 3.309 5.363v27.902c0 .118.095.214.213.214h14.498a.213.213 0 0 0 .213-.214V104.83a6 6 0 0 1 3.092-5.247c6.859-3.802 11.406-10.97 11.406-19.118 0-12.025-9.959-21.96-22.478-21.96Z" clip-rule="evenodd"/>
</svg>
</template>
<script>
export default { name: 'iconEnpass' }
</script>
@@ -0,0 +1,8 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path fill="currentColor" d="M12 0C5.378 0 0 5.378 0 12c0 6.623 5.378 12 12 12c6.623 0 12-5.377 12-12c0-6.622-5.377-12-12-12m0 .791a11.203 11.203 0 0 1 11.209 11.21A11.2 11.2 0 0 1 12 23.208A11.203 11.203 0 0 1 .791 12A11.203 11.203 0 0 1 12.001.791zm0 1.871a9.4 9.4 0 0 0-1.705.162A1.746 1.746 0 0 0 12 4.95a1.746 1.746 0 0 0 1.706-2.126A9.3 9.3 0 0 0 12 2.662M7.2 4a9.34 9.34 0 0 0-4.54 8a9.338 9.338 0 0 0 18.677 0a9.34 9.34 0 0 0-4.54-7.998a4.89 4.89 0 0 1-2.701 5.365l-.12 2.066l1.482 1.483l-1.483 1.482l.989.989l-.989.988l.12 1.857L12 20.328l-2.096-2.096V9.367A4.89 4.89 0 0 1 7.2 4m3.4 6.543v6.99h.7v-6.99z"/>
</svg>
</template>
<script>
export default { name: 'iconKeePass' }
</script>
@@ -0,0 +1,8 @@
<template>
<svg viewBox="250 250 512 512" xmlns="http://www.w3.org/2000/svg">
<path d="m739.1 548.2-44-36.2 43.9-36.2-25.2-30.5L633 512l80.9 66.8 25.2-30.6zm-31.3 74.3-109-87h-135v199.8h39.5V575H585l98.2 78.4 24.6-30.9zm-50.1 59.4-84.2-69.3-32.3.2v122.4l39.5-10.1v-55.4l44.2 36.4 32.8-24.2zM425.2 354.5l-39.5-34.4v387.3l39.5-34.4V354.5zm-77.5 50.8-39.5 36.3V592l39.5 33.7V405.3zm116.1 83.2h135l109-87-24.7-30.9L585 449h-81.6V288.7h-39.5v199.8h-.1zm77.3-71.9 32.3.2 84.2-69.3-32.8-24.2-44.2 36.4v-55.4l-39.5-10.1v122.4zM768 512c0 141.4-114.6 256-256 256S256 653.4 256 512s114.6-256 256-256 256 114.6 256 256z" fill="currentColor"></path>
</svg>
</template>
<script>
export default { name: 'iconKeeper' }
</script>
@@ -0,0 +1,8 @@
<template>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" height="24" width="24">
<path d="M2 6H22M2 12H22M2 18H22" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</template>
<script>
export default { name: 'iconMenu' }
</script>
@@ -0,0 +1,12 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M6.59 36.89c-7.003-9.732-4.792-23.299 4.94-30.303A21.7 21.7 0 0 1 24 2.5c11.99.117 21.614 9.93 21.498 21.92a21.7 21.7 0 0 1-4.088 12.47L31.08 20l-1.86 3.17l1.88 3.23L24 14.17l-5.27 9l1.9 3.27L16.91 20z"/>
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="3">
<circle cx="20.605" cy="35" r="3.105"/>
<path d="M30.5 35.011h-6.79m4.515 1.858v-1.858"/>
</g>
</svg>
</template>
<script>
export default { name: 'iconNordPass' }
</script>
@@ -0,0 +1,9 @@
<template>
<svg width="800px" height="800px" viewBox="200 200 600 600" xmlns="http://www.w3.org/2000/svg">
<path d="M308 656c-28.3 0-52 25.1-52 56s23.7 56 52 56h408c28.3 0 52-25.1 52-56s-23.7-56-52-56H308zm392-400H324c-37.2 0-68 30.8-68 68v232c0 37.1 30.9 68 68 68h376c37.2 0 68-30.8 68-68V324c0-37.2-30.8-68-68-68zm12 276c0 19.9-16.1 36-36 36H348c-19.9 0-36-16.1-36-36V348c0-19.9 16.1-36 36-36h328c19.9 0 36 16.1 36 36v184z" fill="currentColor"></path>
<path fill="currentColor" d="M604.1 384.2c-30.2 0-54.6 24.5-54.6 54.6 0 30.2 24.5 54.6 54.6 54.6 30.2 0 54.6-24.5 54.6-54.6s-24.4-54.6-54.6-54.6m-181.4 0c-30.2 0-54.6 24.5-54.6 54.6 0 30.2 24.5 54.6 54.6 54.6 30.2 0 54.6-24.5 54.6-54.6s-24.4-54.6-54.6-54.6"></path>
</svg>
</template>
<script>
export default { name: 'iconRoboForm' }
</script>
+2 -5
View File
@@ -32,11 +32,7 @@
link
class="header-menu-btn-refined"
>
<el-icon :size="20">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 6H20M4 12H20M4 18H20" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</el-icon>
<el-icon :size="24"><iconMenu /></el-icon>
</el-button>
</div>
</header>
@@ -50,6 +46,7 @@ import { useAuthUserStore } from '@/features/auth/store/authUserStore'
import { useThemeStore } from '@/shared/stores/themeStore'
import { Sunny, Moon } from '@element-plus/icons-vue'
import iconLocales from '@/shared/components/icons/iconLocales.vue'
import iconMenu from '@/shared/components/icons/iconMenu.vue'
import { useI18n } from 'vue-i18n'
import { setLanguage } from '@/locales'
@@ -63,6 +63,7 @@ export const csvStrategy = {
const headers = this._splitCsvLine(lines[0]).map(h => h.toLowerCase())
const rawVault = []
// 1. 定义已知格式的列名特征
const isBitwardenPass = headers.includes('login_totp')
const isBitwardenAuth = headers.includes('otpauth') && !headers.includes('title')
const is1Password = headers.includes('otpauth') && headers.includes('title')
@@ -70,13 +71,28 @@ export const csvStrategy = {
const isDashlane = headers.includes('otpurl') && headers.includes('title') && headers.includes('username')
const isGeneric = headers.includes('issuer') || headers.includes('secret') || headers.includes('name')
if (!isBitwardenPass && !isBitwardenAuth && !is1Password && !isProtonPass && !isDashlane && !isGeneric) return []
// 2. 增强型同义词库(用于识别未知 App 导出的文件)
const TOTP_SYNONYMS = ['otpauth', 'login_totp', 'totp', 'mfa', 'two_factor_code', 'secret', 'otpurl', '2fa', 'authenticator']
const NAME_SYNONYMS = ['name', 'title', 'item name', 'issuer', 'label']
const USER_SYNONYMS = ['username', 'login', 'login_username', 'account', 'email']
// 3. 辅助函数:清洗密钥内容
const cleanSecret = (str) => {
if (!str) return ''
return str.toString().trim().replace(/[\s-]/g, '').toUpperCase()
}
// 如果连通用识别都不过,且没有同义词表头,则返回
const hasAnyTotpHeader = headers.some(h => TOTP_SYNONYMS.includes(h))
if (!isBitwardenPass && !isBitwardenAuth && !is1Password && !isProtonPass && !isDashlane && !isGeneric && !hasAnyTotpHeader) return []
for (let i = 1; i < lines.length; i++) {
const row = this._splitCsvLine(lines[i])
const rowData = {}
headers.forEach((h, index) => { rowData[h] = row[index] || '' })
// --- 优先级路径:已知 App 格式 ---
if (is1Password) {
const totpStr = (rowData['otpauth'] || '').trim()
if (totpStr && totpStr.startsWith('otpauth://')) {
@@ -94,8 +110,7 @@ export const csvStrategy = {
if (totpStr.startsWith('otpauth://')) {
accData = parseOtpUri(totpStr)
} else {
// Check if it's a plain Base32 secret
const secret = totpStr.replace(/\s/g, '').toUpperCase()
const secret = cleanSecret(totpStr)
const base32Re = /^[A-Z2-7]+=*$/
if (base32Re.test(secret)) {
accData = {
@@ -109,7 +124,6 @@ export const csvStrategy = {
}
}
}
if (accData) {
accData.service = rowData['name'] || accData.service
accData.account = rowData['login_username'] || accData.account
@@ -138,22 +152,43 @@ export const csvStrategy = {
rawVault.push(accData)
}
}
} else {
const secret = rowData['secret'] || ''
if (secret) {
// 本系统导出的 generic csv: name=account, issuer=service
// 兼容其他工具: 若有独立的 account 列则优先
const service = rowData['issuer'] || rowData['name'] || 'Unknown'
const account = rowData['account'] || rowData['name'] || ''
rawVault.push({
service,
account,
secret: secret.replace(/\s/g, '').toUpperCase(),
algorithm: (rowData['algorithm'] || 'SHA1').toUpperCase().replace(/^SHA-?1$/, 'SHA-1').replace(/^SHA-?256$/, 'SHA-256').replace(/^SHA-?512$/, 'SHA-512'),
digits: parseInt(rowData['digits'] || '6', 10),
period: parseInt(rowData['period'] || '30', 10),
category: ''
})
}
// --- 兜底路径:智能同义词识别 (支持 Keeper, NordPass, RoboForm, KeePassXC 等) ---
else {
// 查找包含密钥数据的列
const totpHeader = headers.find(h => TOTP_SYNONYMS.includes(h))
const totpVal = totpHeader ? (rowData[totpHeader] || '').trim() : ''
if (totpVal) {
// 情况 A: 包含 otpauth:// 的 URI
if (totpVal.toLowerCase().startsWith('otpauth://')) {
const accData = parseOtpUri(totpVal)
if (accData) {
const serviceHeader = headers.find(h => NAME_SYNONYMS.includes(h))
const userHeader = headers.find(h => USER_SYNONYMS.includes(h))
accData.service = rowData[serviceHeader] || accData.service
accData.account = rowData[userHeader] || accData.account
rawVault.push(accData)
}
}
// 情况 B: 纯 Base32 密钥内容
else {
const secret = cleanSecret(totpVal)
const base32Re = /^[A-Z2-7]+=*$/
if (base32Re.test(secret)) {
const serviceHeader = headers.find(h => NAME_SYNONYMS.includes(h))
const userHeader = headers.find(h => USER_SYNONYMS.includes(h))
rawVault.push({
service: rowData[serviceHeader] || 'Unknown',
account: rowData[userHeader] || 'Unknown Account',
secret: secret,
algorithm: (rowData['algorithm'] || 'SHA1').toUpperCase().replace(/^SHA-?1$/, 'SHA-1').replace(/^SHA-?256$/, 'SHA-256').replace(/^SHA-?512$/, 'SHA-512'),
digits: parseInt(rowData['digits'] || '6', 10),
period: parseInt(rowData['period'] || '30', 10),
category: ''
})
}
}
}
}
}