feat: 支持 Proton Pass, Dashlane Pass 备份数据导入

This commit is contained in:
nap0o committed 2026-03-16 16:51:17 +08:00
1 parent 5e874c920a
commit d3a156bef8
14 files changed
+197 -53

No files matched your search

+2 -1
View File
@@ -21,6 +21,7 @@
"jspdf": "^4.2.0",
"jsqr": "^1.4.0",
"libsodium-wrappers-sumo": "^0.8.2",
"openpgp": "^6.3.0",
"pinia": "^3.0.4",
"qrcode": "^1.5.4",
"scrypt-js": "^3.0.1",
@@ -44,4 +45,4 @@
"overrides": {
"serialize-javascript": "^7.0.3"
}
}
}
@@ -377,41 +377,35 @@
background: var(--el-fill-color-light);
color: var(--el-text-color-regular);
padding: 3px 10px 4px 10px;
border-radius: 4px;
border-radius: 3px;
font-size: 13px;
white-space: nowrap;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
border: 1px solid transparent;
border: 1px solid var(--el-fill-color-light);
}
.category-tag:hover {
background: var(--el-fill-color);
}
/* Final Choice: Option 1 - Subtle Glassmorphism (毛玻璃高亮) */
/* 分类 Chips 选中状态对齐 .vault-card.is-selected */
.category-tag.is-active {
background-color: var(--el-color-primary-light-9) !important;
background-color: var(--vault-selected-bg) !important;
border: 1px solid #409EFF !important;
color: var(--el-color-primary) !important;
border-color: var(--el-color-primary-light-5) !important;
}
/* 适配深色模式下的透明度感 */
html.dark .category-tag.is-active {
background-color: rgba(64, 158, 255, 0.15) !important;
border-color: rgba(64, 158, 255, 0.4) !important;
color: var(--el-color-primary-light-3) !important;
}
.category-tag.is-active .tag-count {
color: var(--el-color-primary) !important;
opacity: 0.7;
opacity: 0.9;
}
html.dark .category-tag.is-active .tag-count {
color: var(--el-color-primary-light-3) !important;
/* 适配深色模式下的透明度感 */
html.dark .category-tag.is-active {
border: none !important;
background-color: rgba(64, 158, 255, 0.2) !important;
}
.tag-count {
@@ -182,7 +182,7 @@ export function useDataImport(emitFn) {
}
}
if (detectedType === '2fauth_encrypted' || detectedType === 'aegis_encrypted' || detectedType === 'proton_encrypted' || detectedType === '2fas_encrypted' || detectedType === 'ente_encrypted' || detectedType === 'bitwarden_vault_encrypted') {
if (detectedType === '2fauth_encrypted' || detectedType === 'aegis_encrypted' || detectedType === 'proton_auth_encrypted' || detectedType === 'proton_pass_pgp' || detectedType === '2fas_encrypted' || detectedType === 'ente_encrypted' || detectedType === 'bitwarden_vault_encrypted') {
importPassword.value = ''
isDialogHandled.value = false
showDecryptDialog.value = true
@@ -5,7 +5,8 @@ import { tryParseJSON } 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 protonStrategy from '@/shared/utils/serializers/protonStrategy'
import { protonAuthStrategy } from '@/shared/utils/serializers/protonAuthStrategy'
import { protonPassStrategy } from '@/shared/utils/serializers/protonPassStrategy'
import { enteStrategy } from '@/shared/utils/serializers/enteStrategy'
import { migrationError } from '@/shared/utils/errors/migrationError'
import { unzipSync } from 'fflate'
@@ -75,7 +76,9 @@ export const dataMigrationService = {
const firstLine = typeof textContent === 'string' ? textContent.split('\n')[0].toLowerCase() : ''
if (firstLine.includes('login_totp')) return 'bitwarden_vault_csv'
if (firstLine.includes('title') && firstLine.includes('otpauth')) return '1password_csv'
if (firstLine.includes('otpauth')) return 'bitwarden_auth_csv' // Bitwarden Auth standalone typically exports simpler CSV
if (firstLine.includes('otpauth')) return 'bitwarden_auth_csv'
if (firstLine.includes('totp') && firstLine.includes('vault') && firstLine.includes('createtime')) return 'proton_pass_csv'
if (firstLine.includes('otpurl') && firstLine.includes('title') && firstLine.includes('username')) return 'dashlane_csv'
return 'generic_csv'
}
@@ -99,7 +102,7 @@ export const dataMigrationService = {
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 (json.version === 1 && typeof json.salt === 'string' && typeof json.content === 'string') return 'proton_encrypted'
if (json.version === 1 && typeof json.salt === 'string' && typeof json.content === 'string') return 'proton_auth_encrypted'
// Ente Auth 加密导出:同时包含 kdfParams 和 encryptedData 两个关键字段
if (json.kdfParams && typeof json.encryptedData === 'string') return 'ente_encrypted'
// Bitwarden password-protected export (Vault)
@@ -107,6 +110,10 @@ export const dataMigrationService = {
}
}
if (typeof textContent === 'string' && textContent.includes('-----BEGIN PGP MESSAGE-----')) {
return 'proton_pass_pgp'
}
if (filename) {
const ext = filename.toLowerCase()
if (ext.endsWith('.2fas')) return '2fas'
@@ -1091,8 +1098,17 @@ export const dataMigrationService = {
type = 'raw'
}
if (type === 'proton_encrypted') {
rawVault = await protonStrategy.parse(content, password)
if (type === 'proton_auth_encrypted') {
if (!password) throw new migrationError('导入 Proton Authenticator 备份需要密码', 'MISSING_PASSWORD')
rawVault = await protonAuthStrategy.parse(content, password)
type = 'raw'
content = JSON.stringify(rawVault)
password = undefined
}
if (type === 'proton_pass_pgp') {
if (!password) throw new migrationError('导入 Proton Pass 备份需要密码', 'MISSING_PASSWORD')
rawVault = await protonPassStrategy.parse(content, password)
type = 'raw'
content = JSON.stringify(rawVault)
password = undefined
@@ -1272,7 +1288,7 @@ export const dataMigrationService = {
}))
}
}
else if (type === 'bitwarden_vault_csv' || type === 'bitwarden_auth_csv' || type === '1password_csv' || type === 'generic_csv') rawVault = csvStrategy.parseCsv(content)
else if (type === 'bitwarden_vault_csv' || type === 'bitwarden_auth_csv' || type === '1password_csv' || type === 'proton_pass_csv' || type === 'dashlane_csv' || type === 'generic_csv') rawVault = csvStrategy.parseCsv(content)
return rawVault.map(acc => {
if (typeof acc.account === 'string' && acc.account.includes(':')) {
@@ -61,7 +61,9 @@
</h4>
<div class="ecosystem-grid">
<el-button plain class="migration-button-with-icon" @click="triggerUpload"><el-icon><iconBitwarden /></el-icon> Bitwarden Vault (.json/.csv)</el-button>
<el-button plain class="migration-button-with-icon" @click="triggerUpload"><el-icon><icon1Password /></el-icon> {{ $t('migration.onepassword_format') }}</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><iconDashlanePass /></el-icon> Dashlane (.csv)</el-button>
</div>
</div>
@@ -99,7 +101,7 @@
<!-- 详细引导 -->
<el-divider border-style="dashed" content-position="center" class="mt-30">
<span class="text-secondary font-12">{{ $t('migration.ga_ms_import_guide') }}</span>
<span class="text-secondary font-12">{{ $t('migration.import_guide') }}</span>
</el-divider>
<div class="guide-section">
@@ -149,7 +151,8 @@
<!-- 加密文件密码输入弹窗 -->
<el-dialog v-model="showDecryptDialog" :title="$t('migration.decrypt_backup_title')" :width="layoutStore.isMobile ? '90%' : '400px'" @close="handleDecryptDialogClose" destroy-on-close>
<el-alert v-if="currentImportType === 'aegis_encrypted'" :title="$t('migration.detect_aegis')" type="warning" :closable="false" class="mb-15" />
<el-alert v-else-if="currentImportType === 'proton_encrypted'" :title="$t('migration.detect_proton')" type="warning" :closable="false" class="mb-15" />
<el-alert v-else-if="currentImportType === 'proton_auth_encrypted'" :title="$t('migration.detect_proton_auth')" type="warning" :closable="false" class="mb-15" />
<el-alert v-else-if="currentImportType === 'proton_pass_pgp'" :title="$t('migration.detect_proton_pass')" type="warning" :closable="false" class="mb-15" />
<el-alert v-else-if="currentImportType === '2fas_encrypted'" :title="$t('migration.detect_2fas')" type="warning" :closable="false" class="mb-15" />
<el-alert v-else-if="currentImportType === 'ente_encrypted'" :title="$t('migration.detect_ente')" type="warning" :closable="false" class="mb-15" />
<el-alert v-else-if="currentImportType === 'bitwarden_vault_encrypted'" :title="$t('migration.detect_bitwarden_vault')" type="warning" :closable="false" class="mb-15" />
@@ -187,7 +190,9 @@ import iconAegis from '@/shared/components/icons/iconAegis.vue'
import iconGoogleAuth from '@/shared/components/icons/iconGoogleAuth.vue'
import iconBitwarden from '@/shared/components/icons/iconBitwarden.vue'
import iconMicrosoftAuth from '@/shared/components/icons/iconMicrosoftAuth.vue'
import iconDashlanePass from '@/shared/components/icons/iconDashlanePass.vue'
import iconProtonAuth from '@/shared/components/icons/iconProtonAuth.vue'
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'
+5 -4
View File
@@ -15,6 +15,7 @@
"update_available": "Update Available",
"pwa_update_ready": "🎉 New Cloudflare Worker deployment is ready!",
"refresh_now": "Refresh Now",
"loading_failed": "Component loading failed, please check your network connection",
"copy_success": "Copy success",
"copy_fail": "Copy failed",
"offline_mode": "Network disconnected, the application is currently in offline cache mode",
@@ -371,16 +372,17 @@
"center_desc": "Support restoring data from various 2FA apps or system backups.",
"auto_identify_tip": "💡 Automatically recognize file types",
"drag_drop_tip": "Drag backup files or migration QR screenshots here, or <em>click to upload</em>",
"support_desc": "Supported Formats",
"support_desc": "Supported List",
"ga_auth_desc": "Export 'Transfer QR code' in the App and upload the screenshot (.png / .jpg)",
"ms_auth_desc": "Microsoft Authenticator (Android Only)",
"ms_auth_detail": "Export the following 3 files from Android and upload them together",
"ga_ms_import_guide": "Google / Microsoft Import Guide",
"import_guide": "Import Guide",
"batch_import_processing": "📦 Batch Import Processing",
"batch_progress": "{processed} / {total} tasks completed",
"decrypt_backup_title": "🔓 Decrypt Backup File",
"detect_aegis": "Aegis encrypted backup detected",
"detect_proton": "Proton Auth encrypted backup detected",
"detect_proton_auth": "Proton Authenticator encrypted backup detected",
"detect_proton_pass": "Proton Pass encrypted backup detected (.pgp)",
"detect_2fas": "2FAS Auth encrypted backup detected",
"detect_ente": "Ente Auth encrypted backup detected",
"detect_bitwarden_vault": "Bitwarden Vault encrypted backup detected",
@@ -390,7 +392,6 @@
"confirm_decrypt_import": "Confirm Decrypt & Import",
"system_backup_format": "System Backup",
"password_manager_format": "Password Manager (Vault)",
"onepassword_format": "1Password (.1pux/.csv)",
"encrypted_backup_json": "Encrypted Backup (.json)",
"plaintext_backup_json": "Plaintext Backup (.json)",
"mobile_app_format": "Mobile 2FA Apps",
+5 -4
View File
@@ -15,6 +15,7 @@
"update_available": "更新可用",
"pwa_update_ready": "🎉 Cloudflare Worker 新部署已就绪!",
"refresh_now": "立即刷新",
"loading_failed": "组件加载失败,请检查网络连接",
"copy_success": "复制成功",
"copy_fail": "复制失败",
"offline_mode": "网络已断开,当前应用正处于离线缓存模式",
@@ -371,16 +372,17 @@
"center_desc": "支持从各类 2FA 软件或本系统的备份文件中恢复数据。",
"auto_identify_tip": "💡 自动识别文件类型导入",
"drag_drop_tip": "将备份文件或迁移二维码截图拖到此处,或 <em>点击上传</em>",
"support_desc": "支持情况说明",
"support_desc": "支持列表",
"ga_auth_desc": "请在 App 内导出\"迁移二维码\",将截图直接上传 (.png / .jpg)",
"ms_auth_desc": "Microsoft Authenticator (仅支持 Android)",
"ms_auth_detail": "Android 手机导出以下3个文件后,同时选中上传即可导入",
"ga_ms_import_guide": "Google / Microsoft Auth 导入指南",
"import_guide": "导入指南",
"batch_import_processing": "📦 批量导入处理中",
"batch_progress": "{processed} / {total} 任务已完成",
"decrypt_backup_title": "🔓 解密备份文件",
"detect_aegis": "检测到 Aegis 加密备份",
"detect_proton": "检测到 Proton Auth 加密备份",
"detect_proton_auth": "检测到 Proton Authenticator 加密备份",
"detect_proton_pass": "检测到 Proton Pass 加密备份 (.pgp)",
"detect_2fas": "检测到 2FAS Auth 加密备份",
"detect_ente": "检测到 Ente Auth 加密备份",
"detect_bitwarden_vault": "检测到 Bitwarden Vault 加密备份",
@@ -390,7 +392,6 @@
"confirm_decrypt_import": "确认解密并导入",
"system_backup_format": "本系统备份",
"password_manager_format": "密码管理器 (Vault)",
"onepassword_format": "1Password (.1pux/.csv)",
"encrypted_backup_json": "加密备份 (.json)",
"plaintext_backup_json": "明文备份 (.json)",
"mobile_app_format": "移动端 2FA App",
@@ -0,0 +1,8 @@
<template>
<svg fill="currentColor" width="1em" height="1em" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M20.89 7.719c0-.149-.149-.298-.357-.357L18.03 6.44c-.416-.179-.893.03-.893.327V17.25c0 .149.15.327.298.387l2.561.922c.388.149.894-.06.894-.387zm-4.676-3.663c0-.15-.149-.298-.358-.358l-2.501-.922c-.417-.178-.894.03-.894.328v6.372c0 .15.149.328.298.388l2.561.922c.388.149.894-.06.894-.387zm0 11.466c0-.149-.149-.297-.358-.357l-2.501-.922c-.417-.179-.894.03-.894.328v6.372c0 .149.149.327.298.387l2.561.922c.388.15.894-.06.894-.387zm-4.676 1.281c0-.149-.149-.298-.358-.357l-2.501-.923c-.42-.178-.894.03-.894.328v6.79c0 .15.149.328.298.388l2.561.922c.387.15.894-.06.894-.387zm0-15.458c0-.149-.149-.297-.358-.357L8.68.066c-.42-.179-.894.03-.894.328v6.79c0 .15.149.328.298.387l2.561.922c.388.15.894-.06.894-.387zm-4.675.269c0-.15-.15-.298-.358-.358L4.004.334c-.417-.179-.894.03-.894.328v21.74c0 .15.149.328.298.387l2.561.923c.387.148.894-.06.894-.388z"/>
</svg>
</template>
<script>
export default { name: 'iconDashlanePass' }
</script>
@@ -0,0 +1,9 @@
<template>
<svg width="1em" height="1em" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<rect fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="3px" x="8.379" y="8.379" width="31.242" height="31.242" rx="4" ry="4" transform="translate(24 -9.9411) rotate(45)"/>
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="3px" d="m10.1332,11.3867h18.2265c2.2077,0,4,1.7923,4,4v18.2265c0,1.6557-1.3443,3-3,3H10.1332c-1.6557,0-3-1.3443-3-3V14.3867c0-1.6557,1.3443-3,3-3Z" transform="translate(16.7387 54.9334) rotate(-135)"/>
</svg>
</template>
<script>
export default { name: 'iconProtonPass' }
</script>
+32 -7
View File
@@ -1,5 +1,6 @@
import { defineAsyncComponent, h } from 'vue'
import { defineAsyncComponent, h, ref, onMounted, onUnmounted } from 'vue'
import { ElEmpty, ElButton } from 'element-plus'
import { i18n } from '@/locales'
/**
* 增强的异步组件工厂函数
@@ -10,14 +11,37 @@ export function createAsyncComponent(loader) {
loader,
// 加载中占位
loadingComponent: {
render() {
return h('div', {
setup() {
const dots = ref('...')
let timer = null
onMounted(() => {
timer = setInterval(() => {
if (dots.value.length >= 10) {
dots.value = '...'
} else {
dots.value += '.'
}
}, 400)
})
onUnmounted(() => {
if (timer) clearInterval(timer)
})
return () => h('div', {
style: 'display: flex; justify-content: center; align-items: center; min-height: 400px; width: 100%;'
}, [
h('div', {
class: 'is-loading',
style: 'font-size: 24px;'
}, 'Loading...')
style: `
font-size: 24px;
font-family: monospace;
color: var(--el-color-primary);
font-weight: 500;
letter-spacing: 1px;
`
}, `Loading${dots.value}`)
])
}
},
@@ -25,14 +49,15 @@ export function createAsyncComponent(loader) {
errorComponent: {
props: ['error'],
render() {
const { t } = i18n.global
return h('div', {
style: 'padding: 40px; text-align: center;'
}, [
h(ElEmpty, { description: '组件加载失败,请检查网络连接' }, {
h(ElEmpty, { description: t('common.loading_failed') }, {
default: () => h(ElButton, {
type: 'primary',
onClick: () => window.location.reload()
}, '刷新页面')
}, t('common.refresh_now'))
})
])
}
@@ -66,9 +66,11 @@ export const csvStrategy = {
const isBitwardenVault = headers.includes('login_totp')
const isBitwardenAuth = headers.includes('otpauth') && !headers.includes('title')
const is1Password = headers.includes('otpauth') && headers.includes('title')
const isProtonPass = headers.includes('totp') && headers.includes('vault') && headers.includes('createtime')
const isDashlane = headers.includes('otpurl') && headers.includes('title') && headers.includes('username')
const isGeneric = headers.includes('issuer') || headers.includes('secret') || headers.includes('name')
if (!isBitwardenVault && !isBitwardenAuth && !is1Password && !isGeneric) return []
if (!isBitwardenVault && !isBitwardenAuth && !is1Password && !isProtonPass && !isDashlane && !isGeneric) return []
for (let i = 1; i < lines.length; i++) {
const row = this._splitCsvLine(lines[i])
@@ -114,6 +116,28 @@ export const csvStrategy = {
rawVault.push(accData)
}
}
} else if (isProtonPass) {
const totpStr = (rowData['totp'] || '').trim()
if (totpStr && totpStr.startsWith('otpauth://')) {
const accData = parseOtpUri(totpStr)
if (accData) {
accData.service = rowData['name'] || accData.service
accData.account = rowData['username'] || accData.account
accData.category = rowData['vault'] || ''
rawVault.push(accData)
}
}
} else if (isDashlane) {
const totpStr = (rowData['otpurl'] || '').trim()
if (totpStr && totpStr.startsWith('otpauth://')) {
const accData = parseOtpUri(totpStr)
if (accData) {
accData.service = rowData['title'] || accData.service
accData.account = rowData['username'] || accData.account
accData.category = rowData['category'] || ''
rawVault.push(accData)
}
}
} else {
const secret = rowData['secret'] || ''
if (secret) {
@@ -1,8 +1,12 @@
import argon2 from 'argon2-browser/dist/argon2-bundled.min.js';
import { parseOtpUri } from '@/shared/utils/totp';
export default {
name: 'Proton Pass (.json)',
/**
* Proton Authenticator (.json) Strategy
* Uses Argon2id + AES-GCM
*/
export const protonAuthStrategy = {
name: 'Proton Authenticator (.json)',
fileType: 'application/json, text/plain',
async parse(fileContent, password) {
@@ -52,8 +56,6 @@ export default {
distPath: '/'
});
// 2. Decrypt via Web Crypto API
// argon2-browser returns Uint8Array in argon2Result.hash
const keyMaterial = argon2Result.hash;
const cryptoKey = await window.crypto.subtle.importKey(
@@ -64,7 +66,7 @@ export default {
['decrypt']
);
// AAD defined in Rust
// AAD defined in Rust source of Proton Auth
const aad = new TextEncoder().encode('proton.authenticator.export.v1');
const decryptedBuffer = await window.crypto.subtle.decrypt(
@@ -81,7 +83,7 @@ export default {
const decryptedString = new TextDecoder().decode(decryptedBuffer);
const decryptedJson = JSON.parse(decryptedString);
// 3. Parse items
// Parse items
const entries = decryptedJson.entries || [];
const results = [];
@@ -97,8 +99,7 @@ export default {
return results;
} catch (err) {
console.error('Proton decryption failed:', err);
// Must throw this precise message so UI prompts for password again if wrong
console.error('Proton Authenticator decryption failed:', err);
throw new Error('INVALID_FORMAT_OR_PASSWORD');
}
}
@@ -0,0 +1,55 @@
import { parseOtpUri } from '@/shared/utils/totp';
import * as openpgp from 'openpgp';
/**
* Proton Pass (.pgp) Strategy
* Uses OpenPGP (Armored Message)
*/
export const protonPassStrategy = {
name: 'Proton Pass (.pgp)',
fileType: 'text/plain',
async parse(armoredContent, password) {
try {
const message = await openpgp.readMessage({
armoredMessage: armoredContent
});
const { data: decrypted } = await openpgp.decrypt({
message,
passwords: [password],
format: 'utf8'
});
const decryptedJson = JSON.parse(decrypted);
const results = [];
const vaults = decryptedJson.vaults || {};
for (const vaultId in vaults) {
const vault = vaults[vaultId];
const items = vault.items || [];
for (const item of items) {
const data = item.data || {};
const content = data.content || {};
const metadata = data.metadata || {};
if (content.totpUri) {
const parsed = parseOtpUri(content.totpUri);
if (parsed) {
// Use metadata name if available for service
if (metadata.name) parsed.service = metadata.name;
if (content.itemUsername) parsed.account = content.itemUsername;
results.push(parsed);
}
}
}
}
return results;
} catch (err) {
console.error('Proton Pass PGP decryption failed:', err);
throw new Error('INVALID_FORMAT_OR_PASSWORD');
}
}
};
+6 -2
View File
@@ -84,7 +84,8 @@ export default defineConfig({
'**/assets/pdf-utils*.js',
'**/assets/compression-utils*.js',
'**/assets/dataImport*.js',
'**/assets/dataMigrationService*.js'
'**/assets/dataMigrationService*.js',
'**/assets/openpgp*.js'
],
cleanupOutdatedCaches: true,
clientsClaim: true,
@@ -117,7 +118,7 @@ export default defineConfig({
},
{
// 为被 globIgnores 忽略的大体积工具 JS chunk 配置动态缓存
urlPattern: /assets\/(wa-sqlite|argon2|hash-wasm|libsodium-wrappers|sql|jsQR|pdf-utils|compression-utils|dataImport|dataMigrationService).*\.js$/i,
urlPattern: /assets\/(wa-sqlite|argon2|hash-wasm|libsodium-wrappers|sql|jsQR|pdf-utils|compression-utils|dataImport|dataMigrationService|openpgp).*\.js$/i,
handler: 'CacheFirst',
options: {
cacheName: 'lazy-tools-js-cache',
@@ -190,6 +191,9 @@ export default defineConfig({
// 二维码处理相关 (体积较大且仅特定页面使用)
if (id.includes('/node_modules/qrcode/') || id.includes('/node_modules/jsqr/')) return 'qr-utils'
// 加密相关:OpenPGP (体积大且仅特定导入场景使用)
if (id.includes('/node_modules/openpgp/')) return 'openpgp'
// 大型 UI 支撑库
if (id.includes('/node_modules/@tanstack/')) return 'tanstack-query'