feat: 增加"核心密钥强制备份"流程,保护用户数据不丢失

This commit is contained in:
nap0o committed 2026-03-16 12:11:57 +08:00
1 parent d51d558500
commit efcef823a0
27 files changed
+926 -68

No files matched your search

+2
View File
@@ -11,6 +11,7 @@ import backupRoutes from '@/features/backup/backupRoutes';
import telegramRoutes from '@/features/telegram/telegramRoutes';
import toolsRoutes from '@/features/tools/toolsRoutes';
import healthRoutes from '@/features/health/healthRoutes';
import emergencyRoutes from '@/features/emergency/emergencyRoutes';
import { runHealthCheck } from '@/shared/utils/health';
// 扩展 EnvBindings 以包含 ASSETS (Cloudflare Pages/Workers Assets)
@@ -86,6 +87,7 @@ app.use('/api/*', async (c, next) => {
// 4. 挂载子路由
app.route('/api/health', healthRoutes);
app.route('/api/emergency', emergencyRoutes);
app.route('/api/oauth', authRoutes);
app.route('/api/vault', vaultRoutes); // 'accounts' is now 'vault'
app.route('/api/backups', backupRoutes);
+20 -5
View File
@@ -7,6 +7,7 @@ import { SECURITY_CONFIG } from '@/app/config';
import { getAvailableProviders } from '@/features/auth/providers/index';
import { AuthService } from '@/features/auth/authService';
import { WebAuthnService } from '@/features/auth/webAuthnService';
import { EmergencyRepository } from '@/shared/db/repositories/emergencyRepository';
const auth = new Hono<{ Bindings: EnvBindings, Variables: { user: any } }>();
@@ -79,7 +80,7 @@ auth.post('/callback/:provider', rateLimit({
const service = getService(c);
// 调用 Service 层处理登录
const { token, userInfo, deviceKey } = await service.handleOAuthCallback(providerName, body);
const { token, userInfo, deviceKey, needsEmergency, encryptionKey } = await service.handleOAuthCallback(providerName, body);
// 1. 设置 httpOnly 的鉴权 Cookie
setCookie(c, 'auth_token', token, {
@@ -107,7 +108,9 @@ auth.post('/callback/:provider', rateLimit({
return c.json({
success: true,
userInfo,
deviceKey
deviceKey,
needsEmergency,
encryptionKey
});
});
@@ -126,14 +129,24 @@ auth.post('/logout', (c) => {
});
// 获取当前用户信息
auth.get('/me', authMiddleware, (c) => {
auth.get('/me', authMiddleware, async (c) => {
const user = c.get('user');
c.header('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
c.header('Pragma', 'no-cache');
c.header('Expires', '0');
// 检查是否需要系统初始化
const repository = new EmergencyRepository(c.env.DB);
const isEmergencyConfirmed = await repository.isEmergencyConfirmed();
// 如果未激活,则持续返回加密密钥供备份页面使用
const encryptionKey = !isEmergencyConfirmed ? c.env.ENCRYPTION_KEY : undefined;
return c.json({
success: true,
userInfo: user
userInfo: user,
needsEmergency: !isEmergencyConfirmed,
encryptionKey
});
});
@@ -216,7 +229,9 @@ auth.post('/webauthn/login/verify', rateLimit({
return c.json({
success: true,
deviceKey: result.deviceKey,
userInfo: result.userInfo
userInfo: result.userInfo,
needsEmergency: result.needsEmergency,
encryptionKey: result.encryptionKey
});
});
+22 -2
View File
@@ -3,11 +3,15 @@ import { generateSecureJWT, generateDeviceKey } from '@/shared/utils/crypto';
import { getOAuthProvider } from '@/features/auth/providers/index';
import type { OAuthUserInfo } from '@/features/auth/providers/baseOAuthProvider';
import { EmergencyRepository } from '@/shared/db/repositories/emergencyRepository';
export class AuthService {
private env: EnvBindings;
private emergencyRepository: EmergencyRepository;
constructor(env: EnvBindings) {
this.env = env;
this.emergencyRepository = new EmergencyRepository(env.DB);
}
/**
@@ -28,7 +32,13 @@ export class AuthService {
/**
* OAuth Callback 处理,生成会话并返回附加参数
*/
async handleOAuthCallback(providerName: string, body: any): Promise<{ token: string, userInfo: OAuthUserInfo, deviceKey: string }> {
async handleOAuthCallback(providerName: string, body: any): Promise<{
token: string,
userInfo: OAuthUserInfo,
deviceKey: string,
needsEmergency: boolean,
encryptionKey?: string
}> {
const provider = getOAuthProvider(providerName, this.env);
let params: string | URLSearchParams;
@@ -57,7 +67,17 @@ export class AuthService {
// 注意:此处统一使用 userInfo.email 作为密钥因子,以确保 OAuth 与 Passkey 登录产生的解密密钥一致。
const deviceKey = await generateDeviceKey(userInfo.email || userInfo.id, this.env.JWT_SECRET || '');
return { token, userInfo, deviceKey };
// 检查是否需要强制备份 (Emergency 流程)
const isEmergencyConfirmed = await this.emergencyRepository.isEmergencyConfirmed();
const needsEmergency = !isEmergencyConfirmed;
return {
token,
userInfo,
deviceKey,
needsEmergency,
...(needsEmergency && { encryptionKey: this.env.ENCRYPTION_KEY })
};
}
+11 -1
View File
@@ -9,15 +9,19 @@ import {
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import { EmergencyRepository } from '@/shared/db/repositories/emergencyRepository';
export class WebAuthnService {
private env: EnvBindings;
private rpName = '2FAuth Worker';
private rpID: string;
private origin: string;
private emergencyRepository: EmergencyRepository;
constructor(env: EnvBindings, url: string, headers?: Record<string, string | undefined>) {
this.env = env;
const parsedUrl = new URL(url);
this.emergencyRepository = new EmergencyRepository(env.DB);
// Handle Reverse Proxy (Docker/Nginx/Cloudflare)
// If X-Forwarded-Proto exists, use it as the real protocol
@@ -151,6 +155,10 @@ export class WebAuthnService {
// 核心: 必须使用 Email 与 OAuth 逻辑保持对齐,确保解密一致性。
const deviceKey = await generateDeviceKey(userEmail, this.env.JWT_SECRET || '');
// 检查是否需要初始化设置
const isEmergencyConfirmed = await this.emergencyRepository.isEmergencyConfirmed();
const needsEmergency = !isEmergencyConfirmed;
return {
success: true,
token,
@@ -160,7 +168,9 @@ export class WebAuthnService {
username: userEmail.split('@')[0],
email: userEmail,
provider: 'passkey'
}
},
needsEmergency,
...(needsEmergency && { encryptionKey: this.env.ENCRYPTION_KEY })
};
}
+3 -3
View File
@@ -218,7 +218,7 @@ export class BackupService {
let encryptedAutoBackupPwd = null;
if (autoBackup && autoBackupPassword) {
if (autoBackupPassword.length < 12) throw new AppError('backup_password_length', 400);
if (autoBackupPassword.length < 6) throw new AppError('backup_password_length', 400);
encryptedAutoBackupPwd = JSON.stringify(await encryptData(autoBackupPassword, key));
} else if (autoBackup) {
throw new AppError('backup_password_required', 400);
@@ -254,7 +254,7 @@ export class BackupService {
let finalAutoPwd = current?.autoBackupPassword;
if (autoBackupPassword) {
if (autoBackupPassword.length < 12) throw new AppError('backup_password_length', 400);
if (autoBackupPassword.length < 6) throw new AppError('backup_password_length', 400);
finalAutoPwd = JSON.stringify(await encryptData(autoBackupPassword, key));
} else if (autoBackup && !finalAutoPwd) {
throw new AppError('backup_password_required', 400);
@@ -316,7 +316,7 @@ export class BackupService {
backupPassword = await decryptData(JSON.parse(providerRow.autoBackupPassword), key);
}
if (!backupPassword || backupPassword.length < 12) {
if (!backupPassword || backupPassword.length < 6) {
throw new AppError('backup_password_length', 400);
}
@@ -0,0 +1,36 @@
import { Hono } from 'hono';
import { EnvBindings, AppError, SECURITY_CONFIG } from '@/app/config';
import { authMiddleware } from '@/shared/middleware/auth';
import { rateLimit, resetRateLimit } from '@/shared/middleware/rateLimitMiddleware';
import { EmergencyRepository } from '@/shared/db/repositories/emergencyRepository';
const emergency = new Hono<{ Bindings: EnvBindings, Variables: { user: any } }>();
/**
* 确认系统重置(核心密钥已保存)
*/
emergency.post('/confirm', authMiddleware, rateLimit({
windowMs: SECURITY_CONFIG.LOCKOUT_TIME,
max: SECURITY_CONFIG.MAX_LOGIN_ATTEMPTS,
}), async (c) => {
const { lastFour } = await c.req.json();
const encryptionKey = c.env.ENCRYPTION_KEY || '';
if (!lastFour || encryptionKey.slice(-4) !== lastFour) {
throw new AppError('invalid_emergency_verification', 400);
}
const repository = new EmergencyRepository(c.env.DB);
await repository.confirmEmergency();
// 验证成功,重置限流计数器
const clientIp = c.req.header('CF-Connecting-IP') || 'unknown';
await resetRateLimit(c, `rl:${clientIp}:/api/emergency/confirm`);
return c.json({
success: true,
message: 'emergency_confirmed'
});
});
export default emergency;
@@ -0,0 +1,57 @@
import { eq } from 'drizzle-orm';
import { schemaMetadata, type SchemaMetadata } from '@/shared/db/schema';
export class EmergencyRepository {
private db: any;
constructor(dbClient: any) {
this.db = dbClient;
}
/**
* 获取元数据
*/
async getMetadata(key: string): Promise<string | null> {
const result = await this.db
.select()
.from(schemaMetadata)
.where(eq(schemaMetadata.key, key))
.limit(1);
return result[0]?.value || null;
}
/**
* 设置元数据
*/
async setMetadata(key: string, value: string): Promise<void> {
const existing = await this.getMetadata(key);
if (existing !== null) {
await this.db
.update(schemaMetadata)
.set({ value })
.where(eq(schemaMetadata.key, key))
.run();
} else {
await this.db
.insert(schemaMetadata)
.values({ key, value })
.run();
}
}
/**
* 检查系统是否已确认初始化 (Emergency 流程)
*/
async isEmergencyConfirmed(): Promise<boolean> {
const value = await this.getMetadata('emergency_confirmed');
return value === '1';
}
/**
* 确认系统初始化 (Emergency 流程)
*/
async confirmEmergency(): Promise<void> {
await this.setMetadata('emergency_confirmed', '1');
}
}
+10 -1
View File
@@ -75,6 +75,12 @@ export const rateLimits = sqliteTable('rate_limits', {
expiresAt: integer('expires_at'),
});
// 6. 架构元数据表 (Metadata)
export const schemaMetadata = sqliteTable('_schema_metadata', {
key: text('key').primaryKey(),
value: text('value'),
});
// 导出类型定义
export type VaultItem = typeof vault.$inferSelect;
export type NewVaultItem = typeof vault.$inferInsert;
@@ -83,5 +89,8 @@ export type NewBackupProvider = typeof backupProviders.$inferInsert;
export type BackupTelegramHistory = typeof backupTelegramHistory.$inferSelect;
export type NewBackupTelegramHistory = typeof backupTelegramHistory.$inferInsert;
export type BackupEmailHistory = typeof backupEmailHistory.$inferSelect;
export type NewBackupEmailHistory = typeof backupEmailHistory.$inferInsert;export type RateLimit = typeof rateLimits.$inferSelect;
export type NewBackupEmailHistory = typeof backupEmailHistory.$inferInsert;
export type RateLimit = typeof rateLimits.$inferSelect;
export type NewRateLimit = typeof rateLimits.$inferInsert;
export type SchemaMetadata = typeof schemaMetadata.$inferSelect;
export type NewSchemaMetadata = typeof schemaMetadata.$inferInsert;
+2
View File
@@ -17,6 +17,8 @@
"element-plus": "^2.9.5",
"fflate": "^0.8.2",
"hash-wasm": "^4.12.0",
"html2canvas": "^1.4.1",
"jspdf": "^4.2.0",
"jsqr": "^1.4.0",
"libsodium-wrappers-sumo": "^0.8.2",
"pinia": "^3.0.4",
+17 -1
View File
@@ -29,6 +29,12 @@ const routes = [
name: 'HealthCheck',
component: () => import('@/features/health/views/healthCheck.vue'),
meta: { guestOnly: false, requiresAuth: false }
},
{
path: '/emergency',
name: 'Emergency',
component: () => import('@/features/auth/views/emergency.vue'),
meta: { requiresAuth: true }
}
]
@@ -61,7 +67,17 @@ router.beforeEach(async (to) => {
return '/login'
}
// 4. 处理仅限游客的页面
// 4. 处理强制初始化逻辑 (核心密钥备份)
if (isAuthenticated) {
if (authUserStore.needsEmergency && to.path !== '/emergency') {
return '/emergency'
}
if (!authUserStore.needsEmergency && to.path === '/emergency') {
return '/'
}
}
// 5. 处理仅限游客的页面
if (to.meta.guestOnly && isAuthenticated) {
return '/'
}
+2 -1
View File
@@ -28,4 +28,5 @@
@import './modules/components/migration.css';
@import './modules/components/tools.css';
@import './modules/components/health.css';
@import './modules/components/security.css';
@import './modules/components/security.css';
@import './modules/components/emergency.css';
@@ -0,0 +1,348 @@
.emergency-container {
display: flex;
justify-content: center;
align-items: flex-start;
height: 100vh;
overflow-y: auto;
background: var(--el-bg-color-page);
padding: 80px 20px 60px;
}
.protocol-wrapper {
max-width: 720px;
width: 100%;
background: var(--el-bg-color);
border-radius: 12px;
box-shadow: var(--el-box-shadow-light);
overflow: hidden;
border: 1px solid var(--el-border-color-lighter);
}
/* Header - More balanced and adaptive */
.protocol-header {
padding: 48px 40px 32px;
background: var(--el-bg-color);
border-bottom: 1px solid var(--el-border-color-lighter);
}
.header-text h1 {
margin: 0;
font-size: 26px;
font-weight: 800;
color: var(--el-text-color-primary);
letter-spacing: -0.02em;
}
.protocol-subtitle {
margin: 8px 0 0;
font-size: 14px;
color: var(--el-text-color-secondary);
line-height: 1.5;
}
/* Stepper */
.protocol-stepper {
padding: 32px 40px;
}
.step-box {
display: flex;
gap: 24px;
padding: 32px;
border-radius: 8px;
background: var(--el-fill-color-blank);
border: 1px solid var(--el-border-color-lighter);
transition: all 0.3s ease;
position: relative;
z-index: 1;
}
.step-box.is-active {
border-color: var(--el-color-primary-light-5);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.03);
}
.step-box.is-completed {
border-color: var(--el-color-success-light-5);
background: var(--el-color-success-light-9);
}
.step-box.is-locked {
opacity: 0.6;
background: var(--el-fill-color-lighter);
}
.step-number {
font-family: 'Inter', sans-serif;
font-size: 64px;
font-weight: 900;
color: var(--el-text-color-placeholder);
line-height: 1;
opacity: 0.15;
position: absolute;
right: 24px;
bottom: 16px;
z-index: -1; /* Ensure it stays behind buttons/content */
pointer-events: none;
}
.step-content {
flex: 1;
z-index: 2;
}
.step-content h3 {
margin: 0 0 12px;
font-size: 18px;
font-weight: 700;
color: var(--el-text-color-primary);
}
.step-content p {
margin: 0 0 20px;
font-size: 14px;
color: var(--el-text-color-regular);
line-height: 1.6;
}
.huge-download-btn {
height: 44px;
padding: 0 24px;
font-size: 14px;
font-weight: 600;
border-radius: 6px;
}
.step-divider {
height: 24px;
width: 1px;
background: var(--el-border-color-lighter);
margin-left: 56px;
}
.step-status {
position: absolute;
top: 24px;
right: 24px;
font-size: 20px;
}
/* Verify Area */
.verify-input-group {
display: flex;
gap: 12px;
max-width: 400px;
}
.huge-verify-input .el-input__inner {
height: 44px;
font-family: ui-monospace, monospace;
font-size: 18px;
letter-spacing: 6px;
text-align: center;
border-radius: 6px;
}
.huge-verify-btn {
height: 44px;
padding: 0 20px;
font-weight: 600;
border-radius: 6px;
}
/* Info Footer - Brighter and stronger prominence */
.protocol-info-footer {
padding: 40px;
background: var(--el-fill-color-blank);
border-top: 1px solid var(--el-border-color-lighter);
}
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 40px;
}
.info-cell {
background: var(--el-fill-color-extra-light);
padding: 24px;
border-radius: 12px;
border: 1px solid var(--el-border-color-extra-light);
}
.cell-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.cell-header .el-icon {
font-size: 20px;
color: var(--el-color-primary);
}
.info-cell:nth-child(2) .cell-header .el-icon {
color: var(--el-color-warning);
}
.info-cell label {
display: block;
font-size: 15px;
font-weight: 700;
color: var(--el-text-color-primary);
text-transform: none;
letter-spacing: normal;
}
.info-cell p {
margin: 0;
font-size: 14px;
color: var(--el-text-color-secondary);
line-height: 1.6;
}
/* Mobile Adaptation Enhanced */
@media (max-width: 640px) {
.emergency-container {
padding: 0;
}
.protocol-wrapper {
border-radius: 0;
min-height: 100vh;
border: none;
box-shadow: none;
}
.protocol-header {
padding: 40px 24px 24px;
}
.header-text h1 {
font-size: 22px;
}
.protocol-stepper {
padding: 20px;
}
.step-box {
padding: 24px 20px;
gap: 16px;
}
.step-number {
font-size: 48px;
right: 16px;
bottom: 12px;
}
.step-divider {
margin: 0 0 0 24px;
}
.verify-input-group {
flex-direction: column;
max-width: 100%;
}
.info-grid {
grid-template-columns: 1fr;
gap: 24px;
}
.protocol-info-footer {
padding: 24px 20px;
}
}
/* PDF Document Style (Unchanged as it's for print) */
.pdf-document {
width: 210mm;
padding: 25mm;
color: #0f172a;
background: white;
font-family: sans-serif;
}
.doc-header {
border-bottom: 4px solid #3b82f6;
padding-bottom: 10mm;
margin-bottom: 15mm;
}
.doc-brand {
font-size: 20pt;
font-weight: 300;
color: #64748b;
margin-bottom: 2mm;
}
.doc-protocol {
font-size: 36pt;
font-weight: 800;
color: #0f172a;
}
.doc-id {
font-family: monospace;
font-size: 12pt;
color: #94a3b8;
margin-top: 4mm;
}
.info-grid-pdf {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10mm;
margin-bottom: 15mm;
}
.doc-key-section {
background: #f8fafc;
border: 1px solid #e2e8f0;
padding: 12mm;
border-radius: 4mm;
margin-bottom: 15mm;
}
.doc-key-section h2 {
margin: 0 0 8mm;
font-size: 16pt;
}
.doc-key-box {
background: white;
padding: 12mm;
font-family: monospace;
font-size: 28pt;
font-weight: 700;
border: 1px solid #e2e8f0;
text-align: center;
word-break: break-all;
box-shadow: 0 4pt 6pt -1pt rgba(0, 0, 0, 0.1);
}
.doc-warning {
color: #ef4444;
font-weight: 700;
margin-top: 8mm;
font-size: 12pt;
}
.doc-usage h3, .doc-legal h3 {
border-bottom: 1px solid #e2e8f0;
padding-bottom: 3mm;
margin-bottom: 5mm;
font-size: 14pt;
}
.doc-footer {
margin-top: 25mm;
border-top: 1px solid #e2e8f0;
padding-top: 8mm;
text-align: center;
font-size: 10pt;
color: #94a3b8;
}
@@ -376,7 +376,7 @@
cursor: pointer;
background: var(--el-fill-color-light);
color: var(--el-text-color-regular);
padding: 5px 10px;
padding: 3px 10px 4px 10px;
border-radius: 4px;
font-size: 13px;
white-space: nowrap;
@@ -391,16 +391,27 @@
background: var(--el-fill-color);
}
/* 合并后的激活状态 */
/* Final Choice: Option 1 - Subtle Glassmorphism (毛玻璃高亮) */
.category-tag.is-active {
background-color: var(--el-color-primary) !important;
color: #fff !important;
border-color: var(--el-color-primary) !important;
font-weight: 500;
background-color: var(--el-color-primary-light-9) !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: rgba(255, 255, 255, 0.8) !important;
color: var(--el-color-primary) !important;
opacity: 0.7;
}
html.dark .category-tag.is-active .tag-count {
color: var(--el-color-primary-light-3) !important;
}
.tag-count {
@@ -102,11 +102,18 @@ export function useOAuthCallback() {
}
// 更新状态机进入应用主流程
const authUserStore = useAuthUserStore()
await authUserStore.fetchUserInfo()
// 将用户信息和设置状态存入 store
await authUserStore.setUserInfo(data.userInfo, !!data.needsEmergency, data.encryptionKey || '')
// 清理旧的 Tab 状态,确保干净进入首页
sessionStorage.clear()
router.push('/')
if (data.needsEmergency) {
router.push('/emergency')
} else {
router.push('/')
}
} else {
errorMsg.value = data.error || t('auth.login_rejected')
}
@@ -33,7 +33,6 @@ export function useOAuthProviders() {
}
} catch (e) {
console.error('Failed to sync oauth providers:', e)
ElMessage.error(i18n.global.t('auth.fetch_providers_failed'))
} finally {
isFetchingProviders.value = false
}
@@ -67,11 +66,7 @@ export function useOAuthProviders() {
}
} catch (error) {
// Error managed by axios request interceptor & authError
let errorMsg = error.message
if (errorMsg && i18n.global.te(`api_errors.${errorMsg}`)) {
errorMsg = i18n.global.t(`api_errors.${errorMsg}`)
}
ElMessage.error(errorMsg || i18n.global.t('auth.oauth_network_error'))
console.error('OAuth Login Error:', error)
} finally {
// The loading state mostly clears when the page navigates away,
// but we reset it here in case of errors.
@@ -95,5 +95,20 @@ export const authService = {
} catch (error) {
return { success: false }
}
},
/**
* 确认系统初始化已完成 (验证并激活)
* @param {string} lastFour - Key 的最后 4 位
*/
async confirmEmergency(lastFour) {
try {
return await request('/api/emergency/confirm', {
method: 'POST',
body: JSON.stringify({ lastFour })
})
} catch (error) {
throw new authError(error.message || '系统确认失败', error.status)
}
}
}
@@ -6,9 +6,13 @@ import { setIdbItem, getIdbItem, removeIdbItem } from '@/shared/utils/idb'
export const useAuthUserStore = defineStore('authUserInfo', () => {
const userInfo = ref({})
const needsEmergency = ref(false)
const tempEncryptionKey = ref('')
const setUserInfo = async (info) => {
const setUserInfo = async (info, emergency = false, key = '') => {
userInfo.value = info
needsEmergency.value = emergency
tempEncryptionKey.value = key
await setIdbItem('auth:user:profile', info)
}
@@ -21,6 +25,8 @@ export const useAuthUserStore = defineStore('authUserInfo', () => {
const clearUserInfo = async () => {
userInfo.value = {}
needsEmergency.value = false
tempEncryptionKey.value = ''
await removeIdbItem('auth:user:profile')
await removeIdbItem('vault:data:main')
await removeIdbItem('vault:conf:backups')
@@ -38,7 +44,10 @@ export const useAuthUserStore = defineStore('authUserInfo', () => {
const fetchUserInfo = async () => {
const data = await authService.fetchMe()
if (data && data.success) {
setUserInfo(data.userInfo)
// NOTE: fetchMe might not return needsEmergency as it's only returned during login callback
// but we might want the server to actually keep track of this in the session if possible.
// For now, it's primarily used during initial login pulse.
await setUserInfo(data.userInfo, !!data.needsEmergency, data.encryptionKey || '')
return true
} else {
await clearUserInfo()
@@ -48,6 +57,8 @@ export const useAuthUserStore = defineStore('authUserInfo', () => {
return {
userInfo,
needsEmergency,
tempEncryptionKey,
setUserInfo,
clearUserInfo,
logout,
@@ -0,0 +1,236 @@
<template>
<div class="emergency-container">
<div class="protocol-wrapper">
<!-- High Impact Header -->
<header class="protocol-header">
<div class="header-text">
<h1>{{ $t('emergency.emergency_title') }}</h1>
<p class="protocol-subtitle">{{ $t('emergency.emergency_subtitle') }}</p>
</div>
</header>
<div class="protocol-stepper">
<!-- Step 1: Download -->
<section class="step-box" :class="{ 'is-completed': pdfDownloaded }">
<div class="step-number">01</div>
<div class="step-content">
<h3>{{ $t('emergency.download_pdf') }}</h3>
<p>{{ $t('emergency.save_instruction') }}</p>
<el-button
type="primary"
size="large"
class="huge-download-btn"
:loading="generatingPdf"
@click="generateEmergency"
>
{{ $t('emergency.download_pdf_btn') }}
</el-button>
</div>
<div class="step-status" v-if="pdfDownloaded">
<el-icon color="#67C23A"><CircleCheckFilled /></el-icon>
</div>
</section>
<div class="step-divider"></div>
<!-- Step 2: Verify (Locked until Step 1) -->
<section class="step-box" :class="{ 'is-locked': !pdfDownloaded, 'is-active': pdfDownloaded }">
<div class="step-number">02</div>
<div class="step-content">
<h3>{{ $t('emergency.verify_title') }}</h3>
<p>{{ $t('emergency.verify_desc') }}</p>
<div class="verify-input-group">
<el-input
v-model="verificationCode"
placeholder="****"
maxlength="4"
:disabled="!pdfDownloaded"
class="huge-verify-input"
@keyup.enter="handleVerify"
>
<template #prefix>
<el-icon><Key /></el-icon>
</template>
</el-input>
<el-button
type="success"
size="large"
class="huge-verify-btn"
:loading="verifying"
:disabled="!pdfDownloaded || verificationCode.length !== 4"
@click="handleVerify"
>
{{ $t('emergency.verify_btn') }}
</el-button>
</div>
</div>
</section>
</div>
<!-- Info Footer -->
<footer class="protocol-info-footer">
<div class="info-grid">
<div class="info-cell">
<div class="cell-header">
<el-icon><InfoFilled /></el-icon>
<label>{{ $t('emergency.importance_title') }}</label>
</div>
<p>{{ $t('emergency.importance_desc') }}</p>
</div>
<div class="info-cell">
<div class="cell-header">
<el-icon><WarningFilled /></el-icon>
<label>{{ $t('emergency.pdf_self_custody') }}</label>
</div>
<p>{{ $t('emergency.pdf_self_custody_desc') }}</p>
</div>
</div>
</footer>
</div>
<!-- The PDF Template (Hidden) -->
<div style="position: absolute; left: -9999px;">
<div id="pdf-template" class="pdf-document">
<div class="doc-header">
<div class="doc-brand">2FAuth Worker</div>
<div class="doc-protocol">{{ $t('emergency.pdf_header').toUpperCase() }}</div>
<div class="doc-id">KIT ID: {{ protocolId }}</div>
</div>
<div class="doc-body">
<section class="doc-info">
<div class="info-grid-pdf">
<div><strong>{{ $t('emergency.pdf_server_url') }}</strong><br>{{ serverUrl }}</div>
<div><strong>{{ $t('emergency.pdf_admin_email') }}</strong><br>{{ adminEmail }}</div>
<div><strong>{{ $t('emergency.pdf_gen_date') }}</strong><br>{{ genDate }}</div>
</div>
</section>
<section class="doc-key-section">
<h2>{{ $t('emergency.pdf_key_title') }}</h2>
<div class="doc-key-box">{{ encryptionKey }}</div>
<p class="doc-warning">{{ $t('emergency.pdf_warning') }}</p>
</section>
<section class="doc-usage">
<h3>{{ $t('emergency.pdf_usage_title') }}</h3>
<p>{{ $t('emergency.pdf_usage_desc') }}</p>
</section>
<section class="doc-legal">
<h3>{{ $t('emergency.importance_title') }}</h3>
<p>{{ $t('emergency.importance_desc') }}</p>
</section>
<section class="doc-legal">
<h3>{{ $t('emergency.pdf_self_custody') }}</h3>
<p>{{ $t('emergency.pdf_self_custody_desc') }}</p>
</section>
</div>
<div class="doc-footer">
&copy;{{ new Date().getFullYear() }} {{ $t('emergency.pdf_footer') }}
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { CircleCheckFilled, Download, Key, Lock, InfoFilled, WarningFilled } from '@element-plus/icons-vue'
import { useAuthUserStore } from '@/features/auth/store/authUserStore'
import { authService } from '@/features/auth/service/authService'
import jsPDF from 'jspdf'
import html2canvas from 'html2canvas'
const { t } = useI18n()
const router = useRouter()
const authUserStore = useAuthUserStore()
const encryptionKey = computed(() => authUserStore.tempEncryptionKey)
const verificationCode = ref('')
const generatingPdf = ref(false)
const verifying = ref(false)
const pdfDownloaded = ref(false)
const protocolId = ref(Math.random().toString(36).substr(2, 9).toUpperCase())
const serverUrl = ref(window.location.origin)
const adminEmail = ref(authUserStore.userInfo?.email || '')
const genDate = ref(new Date().toLocaleString())
onMounted(() => {
if (!encryptionKey.value) {
// If key is missing, try to fetch it again (it's now returned by /me if needsEmergency)
authUserStore.fetchUserInfo().then(success => {
if (!success || !encryptionKey.value) {
ElMessage.error('Security token expired, please re-login')
router.push('/login')
}
})
}
})
onUnmounted(() => {
// 安全清理:组件销毁时立即清空内存中的根密钥
authUserStore.tempEncryptionKey = ''
})
const generateEmergency = async () => {
generatingPdf.value = true
try {
const element = document.getElementById('pdf-template')
const canvas = await html2canvas(element, {
scale: 3,
useCORS: true,
backgroundColor: '#ffffff'
})
const imgData = canvas.toDataURL('image/jpeg', 0.95)
const pdf = new jsPDF('p', 'mm', 'a4')
const pdfWidth = pdf.internal.pageSize.getWidth()
const imgProps = pdf.getImageProperties(imgData)
const pdfHeight = (imgProps.height * pdfWidth) / imgProps.width
pdf.addImage(imgData, 'JPEG', 0, 0, pdfWidth, pdfHeight)
pdf.save(`${t('emergency.pdf_filename')}.pdf`)
pdfDownloaded.value = true
ElMessage.success(t('common.success'))
} catch (error) {
console.error('PDF Generation Failed:', error)
ElMessage.error('Failed to generate PDF')
} finally {
generatingPdf.value = false
}
}
const handleVerify = async () => {
if (verificationCode.value.length !== 4) return
verifying.value = true
try {
// We don't use silent: true here in authService yet, but let's check authService.js
const res = await authService.confirmEmergency(verificationCode.value)
if (res.success) {
ElMessage.success(t('emergency.confirm_success'))
authUserStore.needsEmergency = false
authUserStore.tempEncryptionKey = ''
router.push('/')
}
} catch (error) {
// The error is already toasted by request.js interceptor if it's a 400/500
// If we want a specific message here, we should have used silent: true
// To fix double toast, we simply remove this local ElMessage.error
// and let the interceptor show the translated "invalid_emergency_verification"
verificationCode.value = ''
} finally {
verifying.value = false
}
}
</script>
+8 -2
View File
@@ -66,6 +66,7 @@
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { Lock, Platform, Loading } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
@@ -82,6 +83,7 @@ import { useAuthUserStore } from '@/features/auth/store/authUserStore'
import { setIdbItem } from '@/shared/utils/idb'
const { t } = useI18n()
const router = useRouter()
const authUserStore = useAuthUserStore()
const iconComponents = {
@@ -113,14 +115,18 @@ const handlePasskeyLogin = async () => {
const res = await webAuthnService.login()
if (res.success) {
// Passkey 登录逻辑与 OAuth 回调成功后一致
await authUserStore.setUserInfo(res.userInfo)
await authUserStore.setUserInfo(res.userInfo, !!res.needsEmergency, res.encryptionKey || '')
// 设备指纹 key (用于离线加密验证)
if (res.deviceKey) {
await setIdbItem('sys:sec:device_salt', res.deviceKey)
}
ElMessage.success(t('common.success'))
window.location.href = '/'
if (res.needsEmergency) {
router.push('/emergency')
} else {
window.location.href = '/'
}
}
} catch (error) {
console.error('Passkey login failed:', error)
@@ -33,7 +33,7 @@ export function useBackupOperations(uiState, emit, fetchProviders) {
}
const handleBackup = async () => {
if (!uiState.useAutoPassword.value && uiState.backupPassword.value.length < 12) {
if (!uiState.useAutoPassword.value && uiState.backupPassword.value.length < 6) {
return ElMessage.warning(t('backup.password_min_length'))
}
@@ -167,7 +167,7 @@ export function useBackupProviders() {
if (isEditing.value && isAutoBackupPasswordSaved.value && shouldUseExistingAutoBackupPassword.value) {
return null
}
if (!form.value.autoBackupPassword || form.value.autoBackupPassword.length < 12) {
if (!form.value.autoBackupPassword || form.value.autoBackupPassword.length < 6) {
return t('backup.password_min_length')
}
}
@@ -77,7 +77,7 @@ export function useDataExport() {
if (exportForm.value.password !== exportForm.value.confirm) {
return ElMessage.error(t('migration.password_mismatch'))
}
if (exportForm.value.password.length < 12) {
if (exportForm.value.password.length < 6) {
return ElMessage.error(t('migration.password_weak'))
}
password = exportForm.value.password
@@ -160,7 +160,11 @@ export function useDataExport() {
ElMessage.success(t('migration.export_success'))
} catch (error) {
console.error('Export failed:', error)
ElMessage.error(error.message || t('migration.export_failed'))
// fetchAllVault uses request.js (auto-toast)
// For local errors like "no_accounts_to_export" or crypto fails, we manually toast if not handled
if (error.message && !error.message.includes('fetch') && !error.message.includes('api_errors')) {
ElMessage.error(error.message)
}
} finally {
isExporting.value = false
}
@@ -314,16 +314,11 @@ export function useDataImport(emitFn) {
duration: batchErrors.value.length > 0 ? 0 : 8000
})
if (data.count > 0) {
vaultStore.markDirty()
emitFn('success')
}
} else {
ElMessage.error(t('api_errors.request_failed'))
// The error case (data.success === false) is already handled by request.js interceptor
}
} catch (err) {
showBatchProgress.value = false
ElMessage.error(`${t('api_errors.request_failed')}: ${err.message}`)
// Error already toasted by request.js
} finally {
resetBatchState()
}
@@ -140,7 +140,7 @@ export function useVaultActions(fetchVault, vault, categoryStats) {
vaultStore.markDirty()
} catch (e) {
successMsg?.close()
ElMessage.error(t('common.error'))
// Error already toasted by request.js interceptor
// 失败回退:重推旧顺序到 UI
vault.value = oldItems
fetchVault()
+39 -9
View File
@@ -119,7 +119,7 @@
"name": "Config Name",
"config": "Settings",
"auto_backup": "Auto Backup",
"password_required": "Auto backup requires a password (min 12 chars)",
"password_required": "Auto backup requires a password (min 6 chars)",
"test_success": "Connection successful",
"test_failed": "Connection failed",
"save_success": "Cloud backup config saved",
@@ -246,7 +246,7 @@
"backup_security_tip": "Please enter an encryption password to secure the backup file.",
"use_auto_pwd": "Use Auto Backup Password",
"use_new_pwd": "Use New Password",
"input_custom_pwd": "Enter custom encryption password (at least 12 chars)",
"input_custom_pwd": "Enter custom encryption password (at least 6 chars)",
"baidu_auth_failed": "Baidu Netdisk authorization failed",
"dropbox_auth_failed": "Dropbox authorization failed",
"start_backup": "Start Backup",
@@ -262,7 +262,7 @@
"center_title": "Cloud Backup Center",
"center_desc": "Configure multiple backup sources (WebDAV, S3, etc.) to ensure data safety.",
"s3_region_placeholder": "auto or us-east-1",
"password_length_req": "Required, length must be ≥ 12 characters.",
"password_length_req": "Required, length must be ≥ 6 characters.",
"require_name": "Please enter a name",
"require_webdav_url": "Please enter WebDAV URL",
"require_username": "Please enter username",
@@ -282,7 +282,7 @@
"testing_connection": "Testing connection...",
"fetching_configs": "Fetching config list...",
"saving_config": "Saving config...",
"password_min_length": "Password must be at least 12 characters",
"password_min_length": "Password must be at least 6 characters",
"password_no_space": "Password cannot contain spaces",
"password_not_all_number": "Password cannot be all numbers",
"backup_finish_title": "Cloud Backup Finished",
@@ -303,10 +303,10 @@
"export": "Export Vault",
"import": "Import Data",
"export_format": "Export Format",
"export_password": "Encryption Password (min 12 chars)",
"export_password": "Encryption Password (min 6 chars)",
"export_password_confirm": "Confirm Password",
"password_mismatch": "Passwords do not match!",
"password_weak": "Password too weak. It must be at least 12 characters.",
"password_weak": "Password too weak. It must be at least 6 characters.",
"select_account": "Please select at least one account.",
"export_success": "Export completed.",
"export_failed": "Export failed. Please try again later.",
@@ -600,14 +600,14 @@
"missing_service_account": "Service and Account names are required",
"no_account_ids": "No account IDs provided",
"export_type_invalid": "Invalid export type",
"export_password_length": "Export password needs at least 12 characters",
"export_password_length": "Export password needs at least 6 characters",
"missing_content_type": "Content and type cannot be empty",
"import_password_required": "Encrypted import requires a password",
"parse_failed": "Parsing failed, please check file format or password",
"provider_not_found": "Provider not found",
"missing_fields": "Missing required fields",
"backup_password_length": "Auto backup password must be at least 12 characters",
"backup_password_required": "Auto backup password is required (at least 12 characters)",
"backup_password_length": "Auto backup password must be at least 6 characters",
"backup_password_required": "Auto backup password is required (at least 6 characters)",
"manual_backup_password_needed": "Manual backup with auto-password requires configuring an auto-backup password first.",
"filename_required": "Filename is required",
"no_session": "Unauthorized: Missing session cookie",
@@ -638,6 +638,7 @@
"telegram_download_failed": "Telegram download failed",
"file_not_found_in_history": "File not found in history",
"telegram_file_path_missing": "File path not returned from Telegram",
"invalid_emergency_verification": "Verification failed! Please double-check the last 4 digits of the key at the bottom of your Emergency Kit PDF.",
"s3_config_incomplete": "S3 configuration incomplete",
"s3_api_error": "S3 API Error",
"s3_list_failed": "Failed to list S3 objects",
@@ -761,5 +762,34 @@
"default_name": "My New Device",
"registering": "Communicating with hardware...",
"register_success": "Passkey registered successfully!"
},
"emergency": {
"emergency_title": "ENCRYPTION_KEY Backup",
"emergency_subtitle": "Initialization successful. For your data security, you must back up the core encryption key.",
"encryption_key_label": "Core Encryption Key",
"importance_title": "Why is this important?",
"importance_desc": "This system operates on zero-knowledge encryption. The core encryption key is the only credential for your data. In case of server environment loss, it is the only way to recover your data.",
"save_instruction": "Please download the PDF Core Key on this page and store it in a safe place (e.g., email, cloud storage, or print for safekeeping).",
"download_pdf": "Step 1: Download PDF Core Key",
"download_pdf_botton": "Download Core Key PDF Document",
"verify_title": "Step 2: Verify and Activate",
"verify_desc": "Please open the PDF file you just downloaded and fill in the last 4 characters of the core key.",
"verify_placeholder": "Last 4 chars",
"verify_btn": "Verify Now",
"invalid_verification": "Verification failed. Please check the last 4 characters of the key in the PDF.",
"confirm_success": "Core key backup completed, welcome to 2FAuth Worker!",
"pdf_filename": "2FAuth_Worker_Core_Key_Backup",
"pdf_header": "2FAuth Worker Core Key Backup",
"pdf_self_custody": "Important Notice",
"pdf_self_custody_desc": "2FAuth Worker is a fully self-hosted system. We do not store your master password or encryption keys. This means if you lose this Emergency code, all your data will be permanently locked and irrecoverable.",
"pdf_info_title": "System Information",
"pdf_server_url": "Server URL: ",
"pdf_admin_email": "Admin Email: ",
"pdf_gen_date": "Generation Date: ",
"pdf_key_title": "Core Encryption Key (ENCRYPTION_KEY)",
"pdf_usage_title": "How to use?",
"pdf_usage_desc": "If your server environment variables are lost, fill this key into the ENCRYPTION_KEY variable during redeployment to recover all data.",
"pdf_warning": "Warning: Never lose this key; it is the only way to recover your data.",
"pdf_footer": "2FAuth Worker A Secure 2FA Management Tool"
}
}
+39 -9
View File
@@ -119,7 +119,7 @@
"name": "配置名称",
"config": "参数配置",
"auto_backup": "自动备份",
"password_required": "静默同步需设置至少12位密码",
"password_required": "静默同步需设置至少 6 位密码",
"test_success": "连接成功",
"test_failed": "连接失败",
"save_success": "云端备份设置成功",
@@ -246,7 +246,7 @@
"backup_security_tip": "请输入加密密码用于保护备份文件。",
"use_auto_pwd": "使用自动备份密码",
"use_new_pwd": "使用新密码",
"input_custom_pwd": "输入自定义加密密码 (至少12位)",
"input_custom_pwd": "输入自定义加密密码 (至少 6 位)",
"baidu_auth_failed": "百度网盘授权失败",
"dropbox_auth_failed": "Dropbox 授权失败",
"start_backup": "开始备份",
@@ -262,7 +262,7 @@
"center_title": "云端备份中心",
"center_desc": "配置多个备份源(WebDAV, S3等),确保数据万无一失。",
"s3_region_placeholder": "auto 或 us-east-1",
"password_length_req": "必填项,长度必须 ≥ 12 位。",
"password_length_req": "必填项,长度必须 ≥ 6 位。",
"require_name": "请输入名称",
"require_webdav_url": "请输入 WebDAV 地址",
"require_username": "请输入用户名",
@@ -282,7 +282,7 @@
"testing_connection": "正在测试连接...",
"fetching_configs": "正在获取配置列表...",
"saving_config": "正在保存配置...",
"password_min_length": "密码至少12位",
"password_min_length": "密码至少 6 位",
"password_no_space": "密码不能包含空格",
"password_not_all_number": "密码不能全部为数字",
"backup_finish_title": "云端备份完成",
@@ -303,10 +303,10 @@
"export": "数据导出",
"import": "数据导入",
"export_format": "导出格式",
"export_password": "强加密密码(至少12位)",
"export_password": "强加密密码(至少 6 位)",
"export_password_confirm": "确认密码",
"password_mismatch": "两次输入的密码不一致!",
"password_weak": "密码太弱!至少需要 12 个字符。",
"password_weak": "密码太弱!至少需要 6 个字符。",
"select_account": "请至少选择一个账号",
"export_success": "导出完成",
"export_failed": "导出失败,请稍后重试",
@@ -600,14 +600,14 @@
"missing_service_account": "服务商和账号名称为必填项",
"no_account_ids": "未提供账号 ID",
"export_type_invalid": "无效的导出类型",
"export_password_length": "导出密码至少需要 12 个字符",
"export_password_length": "导出密码至少需要 6 个字符",
"missing_content_type": "内容和类型不能为空",
"import_password_required": "加密导入需要密码",
"parse_failed": "解析失败,请检查文件格式或密码是否正确",
"provider_not_found": "未找到提供商",
"missing_fields": "缺少必填字段",
"backup_password_length": "自动备份密码至少需要12个字符",
"backup_password_required": "自动备份密码为必填项(至少12个字符)",
"backup_password_length": "自动备份密码至少需要 6 个字符",
"backup_password_required": "自动备份密码为必填项(至少 6 个字符)",
"manual_backup_password_needed": "使用自动密码进行手动备份要求先配置好自动备份密码。",
"filename_required": "文件名为必填项",
"no_session": "未授权:缺少会话 cookie",
@@ -638,6 +638,7 @@
"telegram_download_failed": "Telegram 下载失败",
"file_not_found_in_history": "在历史记录中找不到此文件",
"telegram_file_path_missing": "Telegram 未返回文件路径",
"invalid_emergency_verification": "验证失败!请核对 PDF 文件中密钥的最后 4 位",
"s3_config_incomplete": "S3 配置不完整",
"s3_api_error": "S3 API 错误",
"s3_list_failed": "获取 S3 对象列表失败",
@@ -761,5 +762,34 @@
"default_name": "我的新设备",
"registering": "正在与硬件通信...",
"register_success": "通行密钥绑定成功!"
},
"emergency": {
"emergency_title": "加密密钥 ENCRYPTION_KEY 备份",
"emergency_subtitle": "系统初始化成功。为了您的数据安全,必须对加密密钥进行物理备份。",
"encryption_key_label": "核心加密密钥",
"importance_title": "为什么很重要?",
"importance_desc": "本系统采用零知识加密。核心加密密钥是解密所有令牌的唯一凭证。若服务器环境变量丢失,此密钥是找回数据的唯一途径。",
"save_instruction": "请在该页面下载 PDF 核心密钥,并将其存放在安全的地方(如邮箱、网盘或打印后保存)。",
"download_pdf": "第一步:下载 PDF 核心密钥",
"download_pdf_btn": "下载 核心密钥 PDF 文档",
"verify_title": "第二步:验证并激活",
"verify_desc": "请打开您刚刚下载的 PDF 文件,填写核心密钥的最后 4 位。",
"verify_placeholder": "最后 4 位",
"verify_btn": "马上验证",
"invalid_verification": "验证失败。请核对 PDF 中的密钥最后 4 位。",
"confirm_success": "核心密钥备份已完成,欢迎使用 2FAuth Worker!",
"pdf_filename": "2FAuth_Worker_核心密钥备份",
"pdf_header": "2FAuth Worker 核心密钥备份",
"pdf_self_custody": "为什么不能丢失?",
"pdf_self_custody_desc": "2FAuth Worker 是一个完全自托管的系统。我们不存储您的主密码或加密密钥。这意味着如果您丢失了此密钥,您的所有数据将永久锁定,无法恢复。",
"pdf_info_title": "系统信息",
"pdf_server_url": "服务器地址: ",
"pdf_admin_email": "管理员邮箱: ",
"pdf_gen_date": "生成日期: ",
"pdf_key_title": "核心加密密钥 (ENCRYPTION_KEY)",
"pdf_usage_title": "如何使用?",
"pdf_usage_desc": "如果您的服务器环境变量丢失,请在重新部署时将此密钥填入环境变量 ENCRYPTION_KEY 即可恢复所有数据。",
"pdf_warning": "警告:切勿遗失此密钥,这是恢复您数据的唯一途径。",
"pdf_footer": "2FAuth Worker A Secure 2FA Management Tool"
}
}
+7 -5
View File
@@ -26,10 +26,6 @@ export default defineConfig({
imports: ['vue', 'vue-router'],
resolvers: [ElementPlusResolver({ importStyle: false })],
}),
AutoImport({
imports: ['vue', 'vue-router'],
resolvers: [ElementPlusResolver({ importStyle: false })],
}),
Components({
resolvers: [
ElementPlusResolver({
@@ -85,6 +81,8 @@ export default defineConfig({
'**/assets/libsodium-wrappers*.js',
'**/assets/sql*.js',
'**/assets/jsQR*.js',
'**/assets/pdf-utils*.js',
'**/assets/compression-utils*.js',
'**/assets/dataImport*.js',
'**/assets/dataMigrationService*.js'
],
@@ -119,7 +117,7 @@ export default defineConfig({
},
{
// 为被 globIgnores 忽略的大体积工具 JS chunk 配置动态缓存
urlPattern: /assets\/(wa-sqlite|argon2|hash-wasm|libsodium-wrappers|sql|jsQR|dataImport|dataMigrationService).*\.js$/i,
urlPattern: /assets\/(wa-sqlite|argon2|hash-wasm|libsodium-wrappers|sql|jsQR|pdf-utils|compression-utils|dataImport|dataMigrationService).*\.js$/i,
handler: 'CacheFirst',
options: {
cacheName: 'lazy-tools-js-cache',
@@ -185,6 +183,10 @@ export default defineConfig({
// 新增:通行密钥 (Passkey) 相关
if (id.includes('/node_modules/@simplewebauthn/')) return 'simplewebauthn'
// 新增:PDF 生成与压缩相关 (体积较大且仅特定场景使用)
if (id.includes('/node_modules/jspdf/') || id.includes('/node_modules/html2canvas/')) return 'pdf-utils'
if (id.includes('/node_modules/fflate/')) return 'compression-utils'
// 二维码处理相关 (体积较大且仅特定页面使用)
if (id.includes('/node_modules/qrcode/') || id.includes('/node_modules/jsqr/')) return 'qr-utils'