feat: 增加中英文本地化适配

This commit is contained in:
nap0o committed 2026-03-05 13:20:18 +08:00
1 parent 0dc02271e3
commit 790984ad0c
48 files changed
+1584 -469

No files matched your search

+1 -1
View File
@@ -20,4 +20,4 @@
"overrides": {
"esbuild": ">=0.25.0"
}
}
}
+1 -1
View File
@@ -62,7 +62,7 @@ auth.post('/callback/:provider', async (c) => {
const clientState = body.state; // 前端传回的回调 state 参数
if (!serverState || !clientState || serverState !== clientState) {
throw new AppError('Invalid or missing OAuth state. Security validation failed.', 403);
throw new AppError('oauth_state_invalid', 403);
}
// 校验通过,清理一次性 State Cookie
+4 -4
View File
@@ -41,7 +41,7 @@ export class AuthService {
});
params = searchParams;
} else {
if (!body.code) throw new AppError('Missing OAuth code', 400);
if (!body.code) throw new AppError('oauth_code_missing', 400);
params = body.code;
}
@@ -104,11 +104,11 @@ export class AuthService {
}
if (!isAllowed) {
throw new AppError(`Unauthorized user. Email: ${userEmail}, Username: ${userName}`, 403);
throw new AppError('unauthorized_user', 403);
}
} else {
// 默认安全策略:如果未配置白名单,未开启 ALLOW_ALL,则拒绝所有人(或者也可以记录警告,但为了安全通常应拒绝)
throw new AppError(`No authorization whitelist configured. Login denied.`, 403);
throw new AppError('not_whitelisted', 403);
}
}
@@ -127,7 +127,7 @@ export class AuthService {
};
if (!this.env.JWT_SECRET) {
throw new AppError('Server configuration error: JWT_SECRET is missing', 500);
throw new AppError('missing_jwt_secret', 500);
}
return await generateSecureJWT(payload, this.env.JWT_SECRET);
@@ -43,7 +43,7 @@ export class CloudflareAccessProvider extends BaseOAuthProvider {
private getBaseUrl(): string {
const orgDomain = this.env.OAUTH_CLOUDFLARE_ORG_DOMAIN;
if (!orgDomain) {
throw new AppError('Cloudflare Access configuration incomplete: Missing OAUTH_CLOUDFLARE_ORG_DOMAIN', 500);
throw new AppError('Cloudflare Access 配置不完整:缺少 OAUTH_CLOUDFLARE_ORG_DOMAIN', 500);
}
let baseUrl = orgDomain.replace(/\/$/, '');
@@ -58,7 +58,7 @@ export class CloudflareAccessProvider extends BaseOAuthProvider {
const redirectUri = this.env.OAUTH_CLOUDFLARE_REDIRECT_URI;
if (!clientId || !redirectUri) {
throw new AppError('Cloudflare Access configuration incomplete', 500);
throw new AppError('Cloudflare Access 配置不完整', 500);
}
const baseUrl = this.getBaseUrl();
@@ -83,7 +83,7 @@ export class CloudflareAccessProvider extends BaseOAuthProvider {
const code = typeof params === 'string' ? params : params.get('code');
if (!code) {
throw new AppError('Cloudflare Access OAuth callback missing code', 400);
throw new AppError('oauth_code_missing', 400);
}
const clientId = this.env.OAUTH_CLOUDFLARE_CLIENT_ID;
@@ -91,7 +91,7 @@ export class CloudflareAccessProvider extends BaseOAuthProvider {
const redirectUri = this.env.OAUTH_CLOUDFLARE_REDIRECT_URI;
if (!clientId || !clientSecret || !redirectUri) {
throw new AppError('Cloudflare Access configuration incomplete', 500);
throw new AppError('Cloudflare Access 配置不完整', 500);
}
const baseUrl = this.getBaseUrl();
@@ -116,7 +116,7 @@ export class CloudflareAccessProvider extends BaseOAuthProvider {
// 增加更详细的错误日志,方便调试
const errorText = await tokenResponse.text();
console.error(`Cloudflare Access Token Error: ${tokenResponse.status} - ${errorText}`);
throw new AppError(`Cloudflare Access Token Exchange failed: ${tokenResponse.status}`, 502);
throw new AppError(`oauth_token_exchange_failed: Cloudflare Access | ${tokenResponse.status}`, 502);
}
const tokenData: any = await tokenResponse.json();
@@ -128,7 +128,7 @@ export class CloudflareAccessProvider extends BaseOAuthProvider {
headers: { 'Authorization': `Bearer ${accessToken}` }
});
if (!userResponse.ok) throw new AppError(`Cloudflare Access API error: ${userResponse.status}`, 502);
if (!userResponse.ok) throw new AppError(`oauth_api_error: Cloudflare Access | ${userResponse.status}`, 502);
const userData: any = await userResponse.json();
return {
@@ -17,7 +17,7 @@ export class GiteeProvider extends BaseOAuthProvider {
const redirectUri = this.env.OAUTH_GITEE_REDIRECT_URI;
if (!clientId || !redirectUri) {
throw new AppError('Gitee OAuth configuration incomplete', 500);
throw new AppError('oauth_config_incomplete', 500);
}
const params = new URLSearchParams({
@@ -36,7 +36,7 @@ export class GiteeProvider extends BaseOAuthProvider {
const code = typeof params === 'string' ? params : params.get('code');
if (!code) {
throw new AppError('Gitee OAuth callback missing code', 400);
throw new AppError('oauth_code_missing', 400);
}
const clientId = this.env.OAUTH_GITEE_CLIENT_ID;
@@ -44,7 +44,7 @@ export class GiteeProvider extends BaseOAuthProvider {
const redirectUri = this.env.OAUTH_GITEE_REDIRECT_URI;
if (!clientId || !clientSecret || !redirectUri) {
throw new AppError('Gitee OAuth configuration incomplete', 500);
throw new AppError('oauth_config_incomplete', 500);
}
// 1. Exchange Code for Token
@@ -64,7 +64,7 @@ export class GiteeProvider extends BaseOAuthProvider {
});
if (!tokenResponse.ok) {
throw new AppError(`Gitee Token Exchange failed: ${tokenResponse.status}`, 502);
throw new AppError(`oauth_token_exchange_failed: Gitee | ${tokenResponse.status}`, 502);
}
const tokenData: any = await tokenResponse.json();
@@ -72,7 +72,7 @@ export class GiteeProvider extends BaseOAuthProvider {
// 2. Get User Info
const userResponse = await fetch(`https://gitee.com/api/v5/user?access_token=${accessToken}`);
if (!userResponse.ok) throw new AppError(`Gitee API error: ${userResponse.status}`, 502);
if (!userResponse.ok) throw new AppError(`oauth_api_error: Gitee | ${userResponse.status}`, 502);
const userData: any = await userResponse.json();
// 3. Get Email (if not in basic profile)
@@ -31,7 +31,7 @@ export class GitHubProvider extends BaseOAuthProvider {
const code = typeof params === 'string' ? params : params.get('code');
if (!code) {
throw new AppError('GitHub OAuth callback missing code', 400);
throw new AppError('oauth_code_missing', 400);
}
// 1. 使用 Code 换取 Access Token
@@ -51,13 +51,13 @@ export class GitHubProvider extends BaseOAuthProvider {
});
if (!tokenResponse.ok) {
throw new AppError(`GitHub Token Exchange failed: ${tokenResponse.status}`, 502);
throw new AppError(`oauth_token_exchange_failed: GitHub | ${tokenResponse.status}`, 502);
}
const tokenData: any = await tokenResponse.json();
if (tokenData.error) {
throw new AppError(`GitHub OAuth Error: ${tokenData.error_description || tokenData.error}`, 400);
throw new AppError(`oauth_token_exchange_failed: GitHub | ${tokenData.error_description || tokenData.error}`, 400);
}
const accessToken = tokenData.access_token;
@@ -71,7 +71,7 @@ export class GitHubProvider extends BaseOAuthProvider {
}
});
if (!userResponse.ok) throw new AppError(`GitHub API error: ${userResponse.status}`, 502);
if (!userResponse.ok) throw new AppError(`oauth_api_error: GitHub | ${userResponse.status}`, 502);
const userData: any = await userResponse.json();
// 3. 补充获取 Email (如果公开资料中没有)
@@ -17,7 +17,7 @@ export class GoogleProvider extends BaseOAuthProvider {
const redirectUri = this.env.OAUTH_GOOGLE_REDIRECT_URI;
if (!clientId || !redirectUri) {
throw new AppError('Google OAuth configuration incomplete', 500);
throw new AppError('oauth_config_incomplete', 500);
}
const params = new URLSearchParams({
@@ -37,7 +37,7 @@ export class GoogleProvider extends BaseOAuthProvider {
const code = typeof params === 'string' ? params : params.get('code');
if (!code) {
throw new AppError('Google OAuth callback missing code', 400);
throw new AppError('oauth_code_missing', 400);
}
const clientId = this.env.OAUTH_GOOGLE_CLIENT_ID;
@@ -45,7 +45,7 @@ export class GoogleProvider extends BaseOAuthProvider {
const redirectUri = this.env.OAUTH_GOOGLE_REDIRECT_URI;
if (!clientId || !clientSecret || !redirectUri) {
throw new AppError('Google OAuth configuration incomplete', 500);
throw new AppError('oauth_config_incomplete', 500);
}
// 1. Exchange Code for Token
@@ -65,7 +65,7 @@ export class GoogleProvider extends BaseOAuthProvider {
if (!tokenResponse.ok) {
const errText = await tokenResponse.text();
throw new AppError(`Google Token Exchange failed: ${tokenResponse.status} - ${errText}`, 502);
throw new AppError(`oauth_token_exchange_failed: Google | ${tokenResponse.status} - ${errText}`, 502);
}
const tokenData: any = await tokenResponse.json();
@@ -78,7 +78,7 @@ export class GoogleProvider extends BaseOAuthProvider {
}
});
if (!userResponse.ok) throw new AppError(`Google API error: ${userResponse.status}`, 502);
if (!userResponse.ok) throw new AppError(`oauth_api_error: Google | ${userResponse.status}`, 502);
const userData: any = await userResponse.json();
return {
+2 -2
View File
@@ -22,13 +22,13 @@ export function getOAuthProvider(providerId: string, env: EnvBindings): BaseOAut
case 'google':
return new GoogleProvider(env);
default:
throw new AppError(`Provider '${providerId}' is not supported`, 400);
throw new AppError(`unsupported_provider: ${providerId}`, 400);
}
}
export function getAvailableProviders(env: EnvBindings) {
const providers = [];
const githubProvider = new GitHubProvider(env);
const cloudflareProvider = new CloudflareAccessProvider(env);
const nodelocProvider = new NodeLocProvider(env);
@@ -17,7 +17,7 @@ export class NodeLocProvider extends BaseOAuthProvider {
const redirectUri = this.env.OAUTH_NODELOC_REDIRECT_URI;
if (!clientId || !redirectUri) {
throw new AppError('NodeLoc OAuth configuration incomplete', 500);
throw new AppError('oauth_config_incomplete', 500);
}
const params = new URLSearchParams({
@@ -36,7 +36,7 @@ export class NodeLocProvider extends BaseOAuthProvider {
const code = typeof params === 'string' ? params : params.get('code');
if (!code) {
throw new AppError('NodeLoc OAuth callback missing code', 400);
throw new AppError('oauth_code_missing', 400);
}
const clientId = this.env.OAUTH_NODELOC_CLIENT_ID;
@@ -44,7 +44,7 @@ export class NodeLocProvider extends BaseOAuthProvider {
const redirectUri = this.env.OAUTH_NODELOC_REDIRECT_URI;
if (!clientId || !clientSecret || !redirectUri) {
throw new AppError('NodeLoc OAuth configuration incomplete', 500);
throw new AppError('oauth_config_incomplete', 500);
}
// 1. Exchange Code for Token
@@ -64,7 +64,7 @@ export class NodeLocProvider extends BaseOAuthProvider {
});
if (!tokenResponse.ok) {
throw new AppError(`NodeLoc Token Exchange failed: ${tokenResponse.status}`, 502);
throw new AppError(`oauth_token_exchange_failed: NodeLoc | ${tokenResponse.status}`, 502);
}
const tokenData: any = await tokenResponse.json();
@@ -78,7 +78,7 @@ export class NodeLocProvider extends BaseOAuthProvider {
}
});
if (!userResponse.ok) throw new AppError(`NodeLoc API error: ${userResponse.status}`, 502);
if (!userResponse.ok) throw new AppError(`oauth_api_error: NodeLoc | ${userResponse.status}`, 502);
const userData: any = await userResponse.json();
return {
@@ -16,7 +16,7 @@ export class TelegramProvider extends BaseOAuthProvider {
// 例如:https://t.me/MyAuthBot?start=state_value
getAuthorizeUrl(state: string) {
const botName = this.env.OAUTH_TELEGRAM_BOT_NAME;
if (!botName) throw new AppError('Telegram Bot Name not configured', 500);
if (!botName) throw new AppError('telegram_missing_bot_name', 500);
// 注意:Telegram start 参数只允许 [A-Za-z0-9_-],UUID 通常符合,但最好移除连字符以防万一
// 这里我们直接使用 state,但在前端生成 state 时最好确保它是 URL 安全的
@@ -25,15 +25,15 @@ export class TelegramProvider extends BaseOAuthProvider {
async handleCallback(params: string | URLSearchParams, _codeVerifier?: string): Promise<OAuthUserInfo> {
if (typeof params === 'string') {
throw new AppError('Telegram provider requires full query parameters, not just code', 400);
throw new AppError('telegram_missing_query', 400);
}
const botToken = this.env.OAUTH_TELEGRAM_BOT_TOKEN;
if (!botToken) throw new AppError('Telegram Bot Token not configured', 500);
if (!botToken) throw new AppError('telegram_missing_bot_token', 500);
// 1. 提取并验证必要字段
const hash = params.get('hash');
if (!hash) throw new AppError('Missing hash signature', 400);
if (!hash) throw new AppError('telegram_missing_hash', 400);
const dataCheckArr: string[] = [];
const allowedKeys = ['auth_date', 'first_name', 'id', 'last_name', 'photo_url', 'username'];
@@ -69,14 +69,14 @@ export class TelegramProvider extends BaseOAuthProvider {
);
if (!isValid) {
throw new AppError('Telegram signature verification failed', 403);
throw new AppError('telegram_signature_failed', 403);
}
// 4. 检查时效性 (Telegram 建议检查 auth_date)
const authDate = parseInt(params.get('auth_date') || '0');
const now = Math.floor(Date.now() / 1000);
if (now - authDate > 86400) { // 24小时过期
throw new AppError('Telegram login data expired', 401);
throw new AppError('telegram_login_expired', 401);
}
// 5. 返回用户信息
+16 -16
View File
@@ -24,7 +24,7 @@ export class BackupService {
case 's3':
return new S3Provider(config);
default:
throw new AppError(`Unknown provider type: ${type}`, 400);
throw new AppError('provider_not_found', 400);
}
}
@@ -77,17 +77,17 @@ export class BackupService {
async addProvider(data: any): Promise<number> {
const { type, name, config, autoBackup, autoBackupPassword, autoBackupRetain } = data;
if (!type || !name || !config) throw new AppError('Missing required fields', 400);
if (!type || !name || !config) throw new AppError('missing_fields', 400);
const key = this.env.ENCRYPTION_KEY || this.env.JWT_SECRET;
const encryptedConfig = await this.processConfigForStorage(type, config, key);
let encryptedAutoBackupPwd = null;
if (autoBackup && autoBackupPassword) {
if (autoBackupPassword.length < 12) throw new AppError('Auto-backup password must be at least 12 characters', 400);
if (autoBackupPassword.length < 12) throw new AppError('backup_password_length', 400);
encryptedAutoBackupPwd = JSON.stringify(await encryptData(autoBackupPassword, key));
} else if (autoBackup) {
throw new AppError('Auto-backup password is required (min 12 chars)', 400);
throw new AppError('backup_password_required', 400);
}
const res = await this.db.insert(backupProviders).values({
@@ -126,10 +126,10 @@ export class BackupService {
let finalAutoPwd = current?.autoBackupPassword;
if (autoBackupPassword) {
if (autoBackupPassword.length < 12) throw new AppError('Auto-backup password must be at least 12 characters', 400);
if (autoBackupPassword.length < 12) throw new AppError('backup_password_length', 400);
finalAutoPwd = JSON.stringify(await encryptData(autoBackupPassword, key));
} else if (autoBackup && !finalAutoPwd) {
throw new AppError('Auto-backup password is required (min 12 chars)', 400);
throw new AppError('backup_password_required', 400);
}
await this.db.update(backupProviders).set({
@@ -167,7 +167,7 @@ export class BackupService {
const provider = await this.getProvider(type, config);
await provider.testConnection();
} catch (e: any) {
throw new AppError(`Connection failed: ${e.message}`, 400);
throw new AppError(`connection_failed: ${e.message}`, 400);
}
}
@@ -186,13 +186,13 @@ export class BackupService {
if (password === '') {
if (!providerRow || !providerRow.autoBackupPassword) {
throw new AppError('Manual backup with auto-password requires auto-backup to be configured with a password.', 400);
throw new AppError('manual_backup_password_needed', 400);
}
backupPassword = await decryptData(JSON.parse(providerRow.autoBackupPassword), key);
}
if (!backupPassword || backupPassword.length < 12) {
throw new AppError('Backup password must be at least 12 characters.', 400);
throw new AppError('backup_password_length', 400);
}
const vaultResults = await this.db.select().from(vaultTable).all();
@@ -223,11 +223,11 @@ export class BackupService {
finalFilename = `2fa-backup-manual-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
finalContent = fileContent;
} else {
throw new AppError('Request must include {filename, content} or {password}', 400);
throw new AppError('missing_fields', 400);
}
const providerRow = await this.db.select().from(backupProviders).where(eq(backupProviders.id, id)).get();
if (!providerRow) throw new AppError('Provider not found', 404);
if (!providerRow) throw new AppError('provider_not_found', 404);
const configObj = await this.processConfigForUsage(providerRow.type, providerRow.config, key);
const provider = await this.getProvider(providerRow.type, configObj);
@@ -240,13 +240,13 @@ export class BackupService {
}).where(eq(backupProviders.id, id)).run();
} catch (e: any) {
await this.db.update(backupProviders).set({ lastBackupStatus: 'failed' }).where(eq(backupProviders.id, id)).run();
throw new AppError(`Backup failed: ${e.message}`, 500);
throw new AppError(`backup_failed: ${e.message}`, 500);
}
}
async getFiles(id: number) {
const providerRow = await this.db.select().from(backupProviders).where(eq(backupProviders.id, id)).get();
if (!providerRow) throw new AppError('Provider not found', 404);
if (!providerRow) throw new AppError('provider_not_found', 404);
const key = this.env.ENCRYPTION_KEY || this.env.JWT_SECRET;
const config = await this.processConfigForUsage(providerRow.type, providerRow.config, key);
@@ -256,10 +256,10 @@ export class BackupService {
}
async downloadFile(id: number, filename: string) {
if (!filename) throw new AppError('Filename is required', 400);
if (!filename) throw new AppError('filename_required', 400);
const providerRow = await this.db.select().from(backupProviders).where(eq(backupProviders.id, id)).get();
if (!providerRow) throw new AppError('Provider not found', 404);
if (!providerRow) throw new AppError('provider_not_found', 404);
const key = this.env.ENCRYPTION_KEY || this.env.JWT_SECRET;
const config = await this.processConfigForUsage(providerRow.type, providerRow.config, key);
@@ -268,7 +268,7 @@ export class BackupService {
try {
return await provider.downloadBackup(filename);
} catch (e: any) {
throw new AppError(`Download failed: ${e.message}`, 500);
throw new AppError(`download_failed: ${e.message}`, 500);
}
}
+16 -16
View File
@@ -14,7 +14,7 @@ export class VaultService {
this.repository = repository;
if (!env.ENCRYPTION_KEY) {
throw new AppError('FATAL ERROR: ENCRYPTION_KEY is not configured.', 500);
throw new AppError('missing_encryption_key', 500);
}
this.encryptionKey = env.ENCRYPTION_KEY;
@@ -55,14 +55,14 @@ export class VaultService {
*/
// normalize a service+account pair for comparison
private normalizeSignature(service: string, account: string) {
return `${(service || '').toString().trim().toLowerCase()}:${(account||'').toString().trim().toLowerCase()}`;
return `${(service || '').toString().trim().toLowerCase()}:${(account || '').toString().trim().toLowerCase()}`;
}
async createAccount(userId: string, data: any) {
let { service, account, category, secret, digits, period, algorithm } = data;
if (!service || !account || !secret || !validateBase32Secret(secret)) {
throw new AppError('Invalid account data or secret format', 400);
throw new AppError('invalid_secret_format', 400);
}
// 入库清洗:去掉 account 中可能带有的 Issuer: 前缀
@@ -73,7 +73,7 @@ export class VaultService {
// duplicate check (case‑insensitive & trimmed) using repository helper
const existing = await this.repository.findByServiceAccount(service, account);
if (existing) {
throw new AppError('Account already exists', 409);
throw new AppError('account_exists', 409);
}
const normalizedSecret = secret.replace(/\s/g, '').toUpperCase();
@@ -100,7 +100,7 @@ export class VaultService {
let { service, account, category, secret, digits, period, algorithm } = data;
if (!service || !account) {
throw new AppError('Service and account are required', 400);
throw new AppError('missing_service_account', 400);
}
// 入库清洗:去掉 account 中可能带有的 Issuer: 前缀
@@ -112,14 +112,14 @@ export class VaultService {
let encryptedSecret: string;
if (secret) {
if (!validateBase32Secret(secret)) {
throw new AppError('Invalid secret format (must be Base32, min 16 chars)', 400);
throw new AppError('invalid_secret_format', 400);
}
const normalizedSecret = secret.replace(/\s/g, '').toUpperCase();
encryptedSecret = await encryptField(normalizedSecret, this.encryptionKey);
} else {
// 取出现有记录,复用已有加密值
const existing = await this.repository.findById(id);
if (!existing) throw new AppError('Account not found', 404);
if (!existing) throw new AppError('account_not_found', 404);
encryptedSecret = existing.secret;
}
@@ -133,7 +133,7 @@ export class VaultService {
...(period && { period }),
});
if (!updated) throw new AppError('Account not found', 404);
if (!updated) throw new AppError('account_not_found', 404);
return updated;
}
@@ -142,11 +142,11 @@ export class VaultService {
*/
async deleteAccount(id: string) {
const success = await this.repository.delete(id);
if (!success) throw new AppError('Account not found', 404);
if (!success) throw new AppError('account_not_found', 404);
}
async batchDeleteAccounts(ids: string[]) {
if (!ids || ids.length === 0) throw new AppError('No IDs provided', 400);
if (!ids || ids.length === 0) throw new AppError('no_account_ids', 400);
const count = await this.repository.batchDelete(ids);
return { count };
}
@@ -157,12 +157,12 @@ export class VaultService {
async exportAccounts(type: string, password?: string) {
const SECURITY_CONFIG = { MIN_EXPORT_PASSWORD_LENGTH: 5 };
if (!['encrypted', 'json', '2fas', 'text'].includes(type)) {
throw new AppError('Invalid export type', 400);
throw new AppError('export_type_invalid', 400);
}
if (type === 'encrypted') {
if (!password || password.length < SECURITY_CONFIG.MIN_EXPORT_PASSWORD_LENGTH) {
throw new AppError(`导出密码至少需要 ${SECURITY_CONFIG.MIN_EXPORT_PASSWORD_LENGTH} 个字符`, 400);
throw new AppError('export_password_length', 400);
}
}
@@ -208,20 +208,20 @@ export class VaultService {
return { data: lines.join('\n'), isText: true };
}
throw new AppError('Unhandled export type', 500);
throw new AppError('export_type_invalid', 500);
}
/**
* 处理导入
*/
async importAccounts(userId: string, type: string, content: string, password?: string) {
if (!content || !type) throw new AppError('内容和类型不能为空', 400);
if (!content || !type) throw new AppError('missing_content_type', 400);
let validAccounts: any[] = [];
try {
if (type === 'encrypted') {
if (!password) throw new AppError('加密导入需要密码', 400);
if (!password) throw new AppError('import_password_required', 400);
const encryptedFile = JSON.parse(content);
const decryptedData = await decryptData(encryptedFile.data, password);
validAccounts = decryptedData.accounts || [];
@@ -305,7 +305,7 @@ export class VaultService {
}
}
} catch (e) {
throw new AppError('解析失败,请检查文件格式或密码是否正确', 400);
throw new AppError('parse_failed', 400);
}
if (type === 'raw') validAccounts = JSON.parse(content);
+3 -3
View File
@@ -7,21 +7,21 @@ export async function authMiddleware(c: Context<{ Bindings: EnvBindings, Variabl
// 1. 从 Cookie 获取 JWT
const token = getCookie(c, 'auth_token');
if (!token) {
throw new AppError('Unauthorized: Missing session cookie', 401);
throw new AppError('no_session', 401);
}
// 2. CSRF 校验 (Double Submit Cookie)
const csrfCookie = getCookie(c, 'csrf_token');
const csrfHeader = c.req.header('X-CSRF-Token');
if (!csrfCookie || !csrfHeader || csrfCookie !== csrfHeader) {
throw new AppError('Forbidden: CSRF token mismatch', 403);
throw new AppError('csrf_mismatch', 403);
}
// 3. 验证 JWT
const payload = await verifySecureJWT(token, c.env.JWT_SECRET);
if (!payload || !payload.userInfo) {
throw new AppError('Unauthorized: Token expired or invalid', 401);
throw new AppError('token_expired', 401);
}
// 验证通过,将用户信息挂载到上下文,供后续路由使用
+1
View File
@@ -20,6 +20,7 @@
"scrypt-js": "^3.0.1",
"sql.js": "^1.10.3",
"vue": "^3.5.25",
"vue-i18n": "^9.14.5",
"vue-router": "^5.0.3",
"wa-sqlite": "^1.0.0"
},
+16 -5
View File
@@ -5,6 +5,15 @@ import { Warning } from '@element-plus/icons-vue'
import TheHeader from '@/shared/components/theHeader.vue'
import TheFooter from '@/shared/components/theFooter.vue'
import { useLayoutStore } from '@/shared/stores/layoutStore'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import en from 'element-plus/es/locale/lang/en'
const { locale } = useI18n()
const elementLocale = computed(() => {
return locale.value === 'zh-CN' ? zhCn : en
})
const route = useRoute()
const layoutStore = useLayoutStore()
@@ -33,10 +42,11 @@ onBeforeUnmount(() => {
</script>
<template>
<div class="app-container">
<!-- 方案A: 全局离线横幅 -->
<el-config-provider :locale="elementLocale">
<div class="app-container">
<!-- 方案A: 全局离线横幅 -->
<div v-if="isOffline" class="global-offline-banner" style="background-color: #fdf6ec; color: #e6a23c; padding: 10px 15px; text-align: center; font-size: 14px; display: flex; align-items: center; justify-content: center; gap: 6px; border-bottom: 1px solid #faecd8; position: sticky; top: 0; z-index: 9999; box-shadow: 0 2px 4px rgba(0,0,0,0.05);">
<el-icon><Warning /></el-icon> <span>网络已断开,当前应用正处于离线缓存模式</span>
<el-icon><Warning /></el-icon> <span>{{ $t('common.offline_mode') }}</span>
</div>
<!-- 登录页通常不显示头部,可以通过路由 meta 控制,这里简单示例默认显示 -->
@@ -45,6 +55,7 @@ onBeforeUnmount(() => {
<main>
<RouterView />
</main>
<TheFooter />
</div>
<TheFooter />
</div>
</el-config-provider>
</template>
@@ -1,4 +1,4 @@
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useAuthUserStore } from '@/features/auth/store/authUserStore'
import { setIdbItem } from '@/shared/utils/idb'
@@ -10,8 +10,9 @@ import { authService } from '@/features/auth/service/authService'
export function useOAuthCallback() {
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const errorMsg = ref('')
const providerName = ref('身份提供商')
const providerName = ref(t('auth.default_provider'))
onMounted(async () => {
const code = route.query.code
@@ -43,7 +44,7 @@ export function useOAuthCallback() {
}
if (error) {
errorMsg.value = route.query.error_description || `您拒绝了 ${providerName.value} 的授权请求`
errorMsg.value = route.query.error_description || t('auth.oauth_declined', { provider: providerName.value })
return
}
@@ -52,21 +53,21 @@ export function useOAuthCallback() {
if (providerId === 'telegram') {
if (!hash) {
errorMsg.value = 'Telegram 登录缺少签名参数'
errorMsg.value = t('auth.telegram_missing_hash')
return
}
const savedState = localStorage.getItem('oauth_state')
payload = { ...route.query, state: savedState }
} else {
if (!code || !state) {
errorMsg.value = 'URL 缺少必要的授权凭证参数'
errorMsg.value = t('auth.missing_auth_params')
return
}
// 防御 CSRF:必须检查 State 匹不匹配
const savedState = localStorage.getItem('oauth_state')
if (!savedState || savedState !== state) {
errorMsg.value = '安全警告:State 校验失败,请求可能被篡改'
errorMsg.value = t('auth.state_mismatch')
return
}
@@ -103,12 +104,12 @@ export function useOAuthCallback() {
await authUserStore.fetchUserInfo()
router.push('/')
} else {
errorMsg.value = data.error || '登录验证被后端拒绝'
errorMsg.value = data.error || t('auth.login_rejected')
}
} catch (err) {
console.error('OAuth Callback Error:', err)
// Error Managed by request interceptor and authError mapping
errorMsg.value = err.message || '网络请求异常,请稍后再试'
errorMsg.value = err.message || t('auth.network_abnormal')
}
})
+4 -4
View File
@@ -4,7 +4,7 @@
<div class="logo-container">
<el-icon :size="54" color="#409EFC"><Lock /></el-icon>
<h2>2FAuth Worker</h2>
<p class="subtitle">A Secure 2FA Management Tool</p>
<p class="subtitle">{{ $t('auth.subtitle') }}</p>
</div>
<div class="action-container">
@@ -23,16 +23,16 @@
<component :is="iconComponents[provider.icon] || Platform" />
</el-icon>
</template>
通过 {{ provider.name }} 授权登录
{{ $t('auth.login_with', { provider: provider.name }) }}
</el-button>
</template>
</div>
<div class="footer-tips">
<el-alert
title="安全隐私提示"
:title="$t('auth.privacy_title')"
type="info"
description="系统基于 OAuth 2.0 协议验证身份,绝不会获取、记录或传输您的密码信息。"
:description="$t('auth.privacy_desc')"
show-icon
:closable="false"
/>
@@ -4,8 +4,8 @@
<el-result
v-if="!errorMsg"
icon="info"
title="正在安全登录中"
:sub-title="`请稍候,系统正在与 ${providerName} 交换安全凭证...`"
:title="$t('auth.secure_login_in_progress')"
:sub-title="$t('auth.secure_login_sub', { provider: providerName })"
>
<template #extra>
<el-icon class="is-loading" :size="40" color="#409eff"><Loading /></el-icon>
@@ -15,11 +15,11 @@
<el-result
v-else
icon="error"
title="授权失败"
:title="$t('auth.auth_failed_title')"
:sub-title="errorMsg"
>
<template #extra>
<el-button type="primary" @click="goBackToLogin">返回登录页</el-button>
<el-button type="primary" @click="goBackToLogin">{{ $t('auth.back_to_login') }}</el-button>
</template>
</el-result>
</el-card>
@@ -2,16 +2,16 @@
<div class="backup-container">
<!-- 顶部操作 -->
<div class="header-actions">
<h3>云端备份管理</h3>
<h3>{{ $t('backup.management') }}</h3>
<el-button type="primary" @click="openAddDialog">
<el-icon><Plus /></el-icon> 添加备份源
<el-icon><Plus /></el-icon> {{ $t('backup.add_source') }}
</el-button>
</div>
<!-- 全局加载状态 -->
<div v-if="isLoading && providers.length === 0" class="loading-state" style="display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 200px; color: var(--el-text-color-secondary);">
<el-icon class="is-loading" :size="48" style="margin-bottom: 20px; color: var(--el-color-primary);"><Loading /></el-icon>
<p style="font-size: 16px; letter-spacing: 1px;">备份源获取中, 请稍候...</p>
<p style="font-size: 16px; letter-spacing: 1px;">{{ $t('backup.fetching_sources') }}</p>
</div>
<!-- 列表区域 (有数据情况) -->
@@ -24,7 +24,7 @@
<div class="provider-title">
<el-tag size="small" effect="dark" :type="getProviderTypeTag(provider.type)">{{ provider.type.toUpperCase() }}</el-tag>
<span class="provider-name">{{ provider.name }}</span>
<el-tooltip v-if="provider.auto_backup" content="自动备份已开启" placement="top">
<el-tooltip v-if="provider.auto_backup" :content="$t('backup.auto_backup_on')" placement="top">
<el-icon color="#67C23A" size="16" style="cursor: help"><Timer /></el-icon>
</el-tooltip>
</div>
@@ -38,17 +38,17 @@
<div class="card-content">
<p class="status-text">
上次备份:
{{ $t('backup.last_backup') }}
<span v-if="provider.lastBackupAt">{{ new Date(provider.lastBackupAt).toLocaleString() }}</span>
<span v-else>从未</span>
<span v-else>{{ $t('backup.never_backed_up') }}</span>
<el-tag v-if="provider.lastBackupStatus" size="small" :type="provider.lastBackupStatus === 'success' ? 'success' : 'danger'" style="margin-left: 5px;">
{{ provider.lastBackupStatus }}
</el-tag>
</p>
<div class="action-buttons">
<el-button type="success" plain size="small" @click="openBackupDialog(provider)">立即备份</el-button>
<el-button type="warning" plain size="small" @click="openRestoreDialog(provider)">恢复数据</el-button>
<el-button type="success" plain size="small" @click="openBackupDialog(provider)">{{ $t('backup.backup_now') }}</el-button>
<el-button type="warning" plain size="small" @click="openRestoreDialog(provider)">{{ $t('backup.restore_data') }}</el-button>
</div>
</div>
</el-card>
@@ -58,52 +58,52 @@
<!-- 空状态 -->
<div v-else class="empty-state" style="margin-top: 40px;">
<el-empty description="暂无备份源,请点击右上角添加" />
<el-empty :description="$t('backup.empty_source')" />
</div>
<!-- 编辑弹窗 -->
<el-dialog v-model="showConfigDialog" :title="isEditing ? '编辑备份源' : '添加备份源'" :width="layoutStore.isMobile ? '90%' : '500px'" destroy-on-close>
<el-dialog v-model="showConfigDialog" :title="isEditing ? $t('backup.edit_source') : $t('backup.add_source')" :width="layoutStore.isMobile ? '90%' : '500px'" destroy-on-close>
<el-form :model="form" label-position="top" ref="formRef">
<el-form-item label="类型">
<el-form-item :label="$t('backup.type_label')">
<el-select v-model="form.type" :disabled="isEditing">
<el-option label="WebDAV" value="webdav" />
<el-option label="S3 对象存储" value="s3" />
<el-option :label="$t('backup.type_s3')" value="s3" />
</el-select>
</el-form-item>
<el-form-item label="名称 (别名)">
<el-input v-model="form.name" placeholder="例如: OpenList" />
<el-form-item :label="$t('backup.name_label')">
<el-input v-model="form.name" :placeholder="$t('backup.name_placeholder')" />
</el-form-item>
<!-- WebDAV 配置 -->
<template v-if="form.type === 'webdav'">
<el-form-item label="WebDAV 地址">
<el-form-item :label="$t('backup.webdav_url')">
<el-input v-model="form.config.url" placeholder="https://pan.example.com/dav/" />
</el-form-item>
<el-form-item label="用户名">
<el-form-item :label="$t('backup.username')">
<el-input v-model="form.config.username" />
</el-form-item>
<el-form-item label="密码">
<el-form-item :label="$t('backup.password')">
<div v-if="isEditing && !isEditingWebdavPwd" style="display: flex; align-items: center; justify-content: space-between; background-color: var(--el-fill-color-light); padding: 0 15px; border-radius: 4px; border: 1px solid var(--el-border-color); width: 100%; height: 32px;">
<span style="font-family: monospace; letter-spacing: 2px;">******</span>
<el-button link type="primary" @click="isEditingWebdavPwd = true; form.config.password = ''">修改</el-button>
<el-button link type="primary" @click="isEditingWebdavPwd = true; form.config.password = ''">{{ $t('backup.modify') }}</el-button>
</div>
<el-input v-else v-model="form.config.password" type="password" show-password />
</el-form-item>
<el-form-item label="保存目录">
<el-form-item :label="$t('backup.save_dir')">
<el-input v-model="form.config.saveDir" placeholder="/2fauth-backups" />
</el-form-item>
</template>
<!-- S3 配置 -->
<template v-if="form.type === 's3'">
<el-form-item label="Endpoint (API 地址)">
<el-form-item :label="$t('backup.s3_endpoint')">
<el-input v-model="form.config.endpoint" placeholder="https://<account>.r2.cloudflarestorage.com" />
</el-form-item>
<el-form-item label="Bucket (存储桶名称)">
<el-form-item :label="$t('backup.s3_bucket')">
<el-input v-model="form.config.bucket" placeholder="my-backup-bucket" />
</el-form-item>
<el-form-item label="Region (区域)">
<el-input v-model="form.config.region" placeholder="auto 或 us-east-1" />
<el-form-item :label="$t('backup.s3_region')">
<el-input v-model="form.config.region" :placeholder="$t('backup.s3_region_placeholder')" />
</el-form-item>
<el-form-item label="Access Key ID">
<el-input v-model="form.config.accessKeyId" />
@@ -111,90 +111,90 @@
<el-form-item label="Secret Access Key">
<div v-if="isEditing && !isEditingS3Secret" style="display: flex; align-items: center; justify-content: space-between; background-color: var(--el-fill-color-light); padding: 0 15px; border-radius: 4px; border: 1px solid var(--el-border-color); width: 100%; height: 32px;">
<span style="font-family: monospace; letter-spacing: 2px;">******</span>
<el-button link type="primary" @click="isEditingS3Secret = true; form.config.secretAccessKey = ''">修改</el-button>
<el-button link type="primary" @click="isEditingS3Secret = true; form.config.secretAccessKey = ''">{{ $t('backup.modify') }}</el-button>
</div>
<el-input v-else v-model="form.config.secretAccessKey" type="password" show-password />
</el-form-item>
<el-form-item label="存储路径前缀 (可选)">
<el-form-item :label="$t('backup.s3_path_prefix')">
<el-input v-model="form.config.saveDir" placeholder="backups/" />
</el-form-item>
</template>
<el-divider content-position="left">自动备份配置</el-divider>
<el-form-item label="自动备份">
<el-switch v-model="form.autoBackup" active-text="开启" inactive-text="关闭" />
<el-divider content-position="left">{{ $t('backup.auto_backup_config') }}</el-divider>
<el-form-item :label="$t('backup.auto_backup')">
<el-switch v-model="form.autoBackup" :active-text="$t('backup.switch_on')" :inactive-text="$t('backup.switch_off')" />
</el-form-item>
<el-form-item label="加密密码" v-if="form.autoBackup">
<el-form-item :label="$t('backup.encrypt_password')" v-if="form.autoBackup">
<div v-if="isEditing && hasExistingAutoPwd" style="margin-bottom: 15px; width: 100%;">
<el-radio-group v-model="configUseExistingAutoPwd">
<el-radio :label="true">保持原密码不变</el-radio>
<el-radio :label="false">设置新密码</el-radio>
<el-radio :label="true">{{ $t('backup.keep_old_pwd') }}</el-radio>
<el-radio :label="false">{{ $t('backup.set_new_pwd') }}</el-radio>
</el-radio-group>
</div>
<div v-if="!(isEditing && hasExistingAutoPwd && configUseExistingAutoPwd)" style="width: 100%;">
<el-input v-model="form.autoBackupPassword" type="password" show-password placeholder="输入加密密码" />
<div class="form-tip"><span style="color: #F56C6C;">*</span> 必填项,长度必须 &ge; 12 位。</div>
<el-input v-model="form.autoBackupPassword" type="password" show-password :placeholder="$t('backup.input_encrypt_pwd')" />
<div class="form-tip"><span style="color: #F56C6C;">*</span> {{ $t('backup.password_length_req') }}</div>
</div>
<div v-else class="success-tip">
<el-icon><CircleCheck /></el-icon><span>系统将继续使用原有的自动备份密码。</span>
<el-icon><CircleCheck /></el-icon><span>{{ $t('backup.continue_use_old_pwd') }}</span>
</div>
</el-form-item>
<el-form-item label="保留最近份数" v-if="form.autoBackup">
<el-input-number v-model="form.autoBackupRetain" :min="0" :max="999" label="保留分数"></el-input-number>
<div class="form-tip" style="width: 100%">填 0 表示不对历史备份做任何限制与清理</div>
<el-form-item :label="$t('backup.retain_count_label')" v-if="form.autoBackup">
<el-input-number v-model="form.autoBackupRetain" :min="0" :max="999" :label="$t('backup.retain_count_label')"></el-input-number>
<div class="form-tip" style="width: 100%">{{ $t('backup.retain_zero_tip') }}</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="testConnection" :loading="isTesting">测试连接</el-button>
<el-button type="primary" @click="saveProvider" :loading="isSaving">保存</el-button>
<el-button @click="testConnection" :loading="isTesting">{{ $t('backup.test_connection') }}</el-button>
<el-button type="primary" @click="saveProvider" :loading="isSaving">{{ $t('backup.save') }}</el-button>
</template>
</el-dialog>
<!-- 备份弹窗 -->
<el-dialog v-model="showBackupDialog" title="加密备份" :width="layoutStore.isMobile ? '90%' : '400px'">
<el-alert title="数据安全" type="info" description="请输入加密密码用于保护备份文件。" show-icon :closable="false" style="margin-bottom: 20px;" />
<el-dialog v-model="showBackupDialog" :title="$t('backup.encrypted_backup')" :width="layoutStore.isMobile ? '90%' : '400px'">
<el-alert :title="$t('common.data_security')" type="info" :description="$t('backup.backup_security_tip')" show-icon :closable="false" style="margin-bottom: 20px;" />
<div v-if="currentActionProvider?.auto_backup" style="margin-bottom: 15px;">
<el-radio-group v-model="useAutoPassword">
<el-radio :label="true">使用自动备份密码</el-radio>
<el-radio :label="false">使用新密码</el-radio>
<el-radio :label="true">{{ $t('backup.use_auto_pwd') }}</el-radio>
<el-radio :label="false">{{ $t('backup.use_new_pwd') }}</el-radio>
</el-radio-group>
</div>
<el-input v-if="!currentActionProvider?.auto_backup || !useAutoPassword" v-model="backupPassword" type="password" show-password placeholder="输入自定义加密密码 (至少12位)" />
<el-input v-if="!currentActionProvider?.auto_backup || !useAutoPassword" v-model="backupPassword" type="password" show-password :placeholder="$t('backup.input_custom_pwd')" />
<template #footer>
<el-button @click="showBackupDialog = false">取消</el-button>
<el-button type="primary" @click="handleBackup" :loading="isBackingUp">开始备份</el-button>
<el-button @click="showBackupDialog = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" @click="handleBackup" :loading="isBackingUp">{{ $t('backup.start_backup') }}</el-button>
</template>
</el-dialog>
<!-- 恢复列表弹窗 -->
<el-dialog v-model="showRestoreListDialog" title="选择备份文件恢复" :width="layoutStore.isMobile ? '95%' : '600px'">
<el-dialog v-model="showRestoreListDialog" :title="$t('backup.select_restore_file')" :width="layoutStore.isMobile ? '95%' : '600px'">
<el-table :data="backupFiles" v-loading="isLoadingFiles" height="300px" style="width: 100%">
<el-table-column prop="filename" label="文件名" show-overflow-tooltip />
<el-table-column label="大小" width="100">
<el-table-column prop="filename" :label="$t('backup.filename')" show-overflow-tooltip />
<el-table-column :label="$t('backup.size')" width="100">
<template #default="scope">
{{ formatSize(scope.row.size) }}
</template>
</el-table-column>
<el-table-column prop="lastModified" label="时间" width="180" />
<el-table-column label="操作" width="100">
<el-table-column prop="lastModified" :label="$t('backup.time')" width="180" />
<el-table-column :label="$t('backup.action')" width="100">
<template #default="scope">
<el-button link type="primary" @click="selectRestoreFile(scope.row)">恢复</el-button>
<el-button link type="primary" @click="selectRestoreFile(scope.row)">{{ $t('backup.restore') }}</el-button>
</template>
</el-table-column>
</el-table>
</el-dialog>
<!-- 恢复确认弹窗 -->
<el-dialog v-model="showRestoreConfirmDialog" title="解密恢复" :width="layoutStore.isMobile ? '90%' : '400px'">
<el-alert title="警告" type="warning" description="恢复操作将覆盖当前所有数据!" show-icon :closable="false" style="margin-bottom: 15px;" />
<el-input v-model="restorePassword" type="password" show-password placeholder="输入备份时的加密密码" />
<el-dialog v-model="showRestoreConfirmDialog" :title="$t('backup.decrypt_restore')" :width="layoutStore.isMobile ? '90%' : '400px'">
<el-alert :title="$t('common.warning')" type="warning" :description="$t('backup.restore_warning')" show-icon :closable="false" style="margin-bottom: 15px;" />
<el-input v-model="restorePassword" type="password" show-password :placeholder="$t('backup.input_restore_pwd')" />
<template #footer>
<el-button @click="showRestoreConfirmDialog = false">取消</el-button>
<el-button type="danger" @click="handleRestore" :loading="isRestoring">确认覆盖恢复</el-button>
<el-button @click="showRestoreConfirmDialog = false">{{ $t('common.cancel') }}</el-button>
<el-button type="danger" @click="handleRestore" :loading="isRestoring">{{ $t('backup.confirm_restore') }}</el-button>
</template>
</el-dialog>
</div>
@@ -3,8 +3,10 @@ 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'
import { i18n } from '@/locales'
export function useBackupActions(emit, fetchProviders) {
const { t } = i18n.global
const vaultStore = useVaultStore()
// Backup Action State
@@ -33,7 +35,7 @@ export function useBackupActions(emit, fetchProviders) {
const handleBackup = async () => {
if (!useAutoPassword.value && backupPassword.value.length < 12) {
return ElMessage.warning('密码至少12位')
return ElMessage.warning(t('backup.password_min_length'))
}
const pwdToSend = useAutoPassword.value ? '' : backupPassword.value
@@ -43,8 +45,8 @@ export function useBackupActions(emit, fetchProviders) {
const res = await backupService.triggerBackup(currentActionProvider.value.id, pwdToSend)
if (res.success) {
ElNotification({
title: '云端备份完成',
message: `<div style="color:var(--el-color-success)">🎉 成功备份了数据</div>`,
title: t('backup.backup_finish_title'),
message: `<div style="color:var(--el-color-success)">🎉 ${t('backup.backup_success_msg')}</div>`,
dangerouslyUseHTMLString: true,
type: 'success',
duration: 5000
@@ -66,7 +68,7 @@ export function useBackupActions(emit, fetchProviders) {
const res = await backupService.getBackupFiles(provider.id)
if (res.success) backupFiles.value = res.files
} catch (e) {
ElMessage.error(e.message || '获取备份文件失败')
ElMessage.error(e.message || t('backup.fetch_backup_fail'))
} finally { isLoadingFiles.value = false }
}
@@ -93,16 +95,16 @@ export function useBackupActions(emit, fetchProviders) {
const saveRes = await dataMigrationService.saveImportedVault(vault)
if (saveRes.success) {
let msgHtml = `<div>共处理 <b>1</b> 个备份文件 (${vault.length} 个基准账号)。</div>`
let msgHtml = `<div>${t('backup.processed_total')} <b>1</b> ${t('backup.backup_files_count')} (${vault.length} ${t('backup.base_accounts_count')})。</div>`
if (saveRes.count > 0) {
msgHtml += `<div style="color:var(--el-color-success)">🎉 成功导入 <b>${saveRes.count}</b> 个新账户!</div>`
msgHtml += `<div style="color:var(--el-color-success)">🎉 ${t('backup.import_success_count')} <b>${saveRes.count}</b> ${t('backup.new_accounts')}</div>`
} else {
msgHtml += `<div style="color:var(--el-color-warning)">⚠️ 恢复的账户皆已存在,无新添项。</div>`
msgHtml += `<div style="color:var(--el-color-warning)">⚠️ ${t('backup.no_new_accounts')}</div>`
}
if (saveRes.duplicates > 0) msgHtml += `<div style="color:var(--el-text-color-secondary)">ℹ️ 自动跳过了 <b>${saveRes.duplicates}</b> 个已有账户。</div>`
if (saveRes.duplicates > 0) msgHtml += `<div style="color:var(--el-text-color-secondary)">ℹ️ ${t('backup.skipped_duplicates')} <b>${saveRes.duplicates}</b> ${t('backup.existing_accounts')}</div>`
ElNotification({
title: '恢复数据结束',
title: t('backup.restore_finish_title'),
message: msgHtml,
dangerouslyUseHTMLString: true,
type: 'success',
@@ -117,7 +119,7 @@ export function useBackupActions(emit, fetchProviders) {
}
}
} catch (e) {
ElMessage.error(e.message || '恢复失败')
ElMessage.error(e.message || t('backup.restore_fail'))
} finally { isRestoring.value = false }
}
@@ -2,8 +2,10 @@ 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'
import { i18n } from '@/locales'
export function useBackupProviders() {
const { t } = i18n.global
const vaultStore = useVaultStore()
const providers = ref([])
@@ -78,17 +80,17 @@ export function useBackupProviders() {
}
const validateForm = () => {
if (!form.value.name) return '请输入名称'
if (!form.value.name) return t('backup.require_name')
const c = form.value.config
if (form.value.type === 'webdav') {
if (!c.url) return '请输入 WebDAV 地址'
if (!c.username) return '请输入用户名'
if (!c.password) return '请输入密码'
if (!c.url) return t('backup.require_webdav_url')
if (!c.username) return t('backup.require_username')
if (!c.password) return t('backup.require_password')
} 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 (!c.endpoint) return t('backup.require_endpoint')
if (!c.bucket) return t('backup.require_bucket')
if (!c.accessKeyId) return t('backup.require_access_key')
if (!c.secretAccessKey) return t('backup.require_secret_key')
}
if (form.value.autoBackup) {
@@ -2,8 +2,8 @@
<div class="data-backup-wrapper">
<div class="tab-card-wrapper">
<div style="text-align: center; margin-bottom: 30px;">
<h2>云端备份中心</h2>
<p style="color: var(--el-text-color-secondary);">配置多个备份源(WebDAV, S3等),确保数据万无一失。</p>
<h2>{{ $t('backup.center_title') }}</h2>
<p style="color: var(--el-text-color-secondary);">{{ $t('backup.center_desc') }}</p>
</div>
<BackupSettings @restore-success="$emit('success')" />
</div>
@@ -1,9 +1,11 @@
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { dataMigrationService } from '@/features/migration/service/dataMigrationService'
import { downloadBlob } from '@/shared/utils/common'
import { i18n } from '@/locales'
export function useDataExport() {
const { t } = i18n.global
const showPasswordDialog = ref(false)
const showWarningDialog = ref(false)
const showGaDialog = ref(false)
@@ -73,10 +75,10 @@ export function useDataExport() {
if (type === 'encrypted') {
if (exportForm.value.password !== exportForm.value.confirm) {
return ElMessage.error('两次输入的密码不一致!')
return ElMessage.error(t('migration.password_mismatch'))
}
if (exportForm.value.password.length < 12) {
return ElMessage.error('密码太弱!至少需要 12 个字符。')
return ElMessage.error(t('migration.password_weak'))
}
password = exportForm.value.password
}
@@ -86,13 +88,13 @@ export function useDataExport() {
showWarningDialog.value = false
try {
loadingText.value = '正在获取账号数据...'
loadingText.value = t('migration.fetching_accounts')
const vault = await dataMigrationService.fetchAllVault()
if (!vault || vault.length === 0) {
throw new Error('没有可导出的账号')
throw new Error(t('migration.no_accounts_to_export'))
}
loadingText.value = type === 'encrypted' ? '正在加密...' : '正在生成导出文件...'
loadingText.value = type === 'encrypted' ? t('migration.encrypting') : t('migration.generating_file')
// 特殊处理 Google Auth (不再直接生成,而是进入选择界面)
if (type === 'gauth') {
@@ -105,11 +107,10 @@ export function useDataExport() {
return
}
// 特殊处理 HTML 报告导出
if (type === 'html') {
const htmlContent = await dataMigrationService.exportAsHtml(vault)
downloadBlob(htmlContent, `2fauth-worker-export-html-${new Date().toISOString().split('T')[0]}.html`, 'text/html')
ElMessage.success('导出完成!请在浏览器中打开该文件。')
ElMessage.success(t('migration.export_success'))
isExporting.value = false
return
}
@@ -148,10 +149,10 @@ export function useDataExport() {
}
downloadBlob(fileContent, filename, mimeType)
ElMessage.success('导出成功!')
ElMessage.success(t('migration.export_success'))
} catch (error) {
console.error('Export failed:', error)
ElMessage.error(error.message || '导出失败,请稍后重试')
ElMessage.error(error.message || t('migration.export_failed'))
} finally {
isExporting.value = false
}
@@ -160,12 +161,12 @@ export function useDataExport() {
// 执行生成所选的 Google Auth 二维码
const executeGaExport = async () => {
if (selectedAccountIds.value.length === 0) {
return ElMessage.warning('请至少选择一个账号')
return ElMessage.warning(t('migration.select_account'))
}
isExporting.value = true
showAccountSelectDialog.value = false
loadingText.value = '正在生成二维码...'
loadingText.value = t('migration.generating_qr')
try {
const selectedVault = fullVault.value.filter(acc => selectedAccountIds.value.includes(acc.id))
@@ -175,7 +176,7 @@ export function useDataExport() {
gaQrDataUrls.value = await dataMigrationService.exportAsGaMigration(selectedVault)
} catch (error) {
console.error('GA Export failed:', error)
ElMessage.error(error.message || '生成二维码失败')
ElMessage.error(error.message || t('common.error'))
} finally {
isExporting.value = false
}
@@ -2,8 +2,10 @@ 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'
import { i18n } from '@/locales'
export function useDataImport(emitFn) {
const { t } = i18n.global
const vaultStore = useVaultStore()
// --- State ---
@@ -78,7 +80,7 @@ export function useDataImport(emitFn) {
// We only proceed if we have at least *something*
if (groupedCount === 0) return
batchCurrentTaskName.value = '构建数据:PhoneFactor 数据集'
batchCurrentTaskName.value = t('migration.task_build_pf')
try {
// we pass the entire group object to the service
const vault = await dataMigrationService.parseImportData(group, 'phonefactor_group')
@@ -86,7 +88,7 @@ export function useDataImport(emitFn) {
} catch (err) {
console.error('[PhoneFactor Group] parseImportData failed:', err)
const detail = err && err.message ? err.message : String(err)
batchErrors.value.push(`[PhoneFactor] 解析失败: ${detail}`)
batchErrors.value.push(t('migration.err_pf_parse', { detail }))
} finally {
batchProcessedJobs.value += groupedCount
importingJobs.value -= groupedCount
@@ -97,13 +99,14 @@ export function useDataImport(emitFn) {
const file = uploadFile.raw
if (!file) return
// we pass the entire group object to the service
if (file.size > 10 * 1024 * 1024) {
ElMessage.error(`文件 ${file.name} 太大,不能超过 10MB`)
ElMessage.error(t('migration.file_too_large', { name: file.name }))
return
}
initBatchTask(1)
batchCurrentTaskName.value = `准备解析:${file.name}`
batchCurrentTaskName.value = t('migration.task_prepare', { name: file.name })
if (file.type.startsWith('image/')) {
await handleImageFile(file)
@@ -114,12 +117,12 @@ export function useDataImport(emitFn) {
const handleImageFile = async (file) => {
try {
batchCurrentTaskName.value = `截取图中二维码:${file.name}`
batchCurrentTaskName.value = t('migration.task_scan_qr', { name: file.name })
const vault = await dataMigrationService.parseGaQrImageFile(file)
batchAccumulatedVault.value.push(...vault)
} catch (err) {
console.error(err)
batchErrors.value.push(`[${file.name}] 解析图片失败: ${err.message}`)
batchErrors.value.push(t('migration.err_scan_qr', { name: file.name, msg: err.message }))
} finally {
batchProcessedJobs.value++
importingJobs.value--
@@ -145,7 +148,7 @@ export function useDataImport(emitFn) {
}, 500)
}
reader.onerror = () => {
batchErrors.value.push(`[${file.name}] 读取失败`)
batchErrors.value.push(t('migration.err_read_fail', { name: file.name }))
batchProcessedJobs.value++
importingJobs.value--
}
@@ -165,7 +168,7 @@ export function useDataImport(emitFn) {
currentImportType.value = detectedType
if (detectedType === 'unknown') {
batchErrors.value.push(`[${file.name}] 无法识别该文件格式。`)
batchErrors.value.push(t('migration.err_unrecognized', { name: file.name }))
batchProcessedJobs.value++
importingJobs.value--
return
@@ -189,14 +192,14 @@ export function useDataImport(emitFn) {
return
}
batchCurrentTaskName.value = `构建数据:${file.name}`
batchCurrentTaskName.value = t('migration.task_build_file', { name: 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}`)
batchErrors.value.push(t('migration.err_parse_fail', { name: file.name, detail }))
} finally {
batchProcessedJobs.value++
importingJobs.value--
@@ -204,7 +207,7 @@ export function useDataImport(emitFn) {
}
reader.onerror = () => {
batchErrors.value.push(`[${file.name}] 读取失败`)
batchErrors.value.push(t('migration.err_read_fail', { name: file.name }))
batchProcessedJobs.value++
importingJobs.value--
}
@@ -213,7 +216,7 @@ export function useDataImport(emitFn) {
} catch (error) {
console.error(error)
batchErrors.value.push(`[${file.name}] 读取异常`)
batchErrors.value.push(t('migration.err_read_abnormal', { name: file.name }))
batchProcessedJobs.value++
importingJobs.value--
}
@@ -221,12 +224,12 @@ export function useDataImport(emitFn) {
const submitEncryptedData = async () => {
if (!importPassword.value) {
return ElMessage.warning('请输入解密密码')
return ElMessage.warning(t('migration.import_password'))
}
isDecrypting.value = true
try {
batchCurrentTaskName.value = '正在使用密码解密数据...'
batchCurrentTaskName.value = t('migration.task_decrypting')
const vault = await dataMigrationService.parseImportData(
currentFileContent.value,
currentImportType.value,
@@ -239,7 +242,7 @@ export function useDataImport(emitFn) {
importingJobs.value--
} catch (error) {
console.error(error)
ElMessage.error(`解密失败:密码错误或不支持加密标准`)
ElMessage.error(t('migration.password_wrong'))
} finally {
isDecrypting.value = false
}
@@ -247,7 +250,7 @@ export function useDataImport(emitFn) {
const handleDecryptDialogClose = () => {
if (!isDialogHandled.value) {
batchErrors.value.push(`[${currentFile.value?.name || '加密文件'}] 用户取消密码输入`)
batchErrors.value.push(t('migration.err_user_cancel', { name: currentFile.value?.name || t('migration.encrypted_file') }))
batchProcessedJobs.value++
importingJobs.value--
isDialogHandled.value = true
@@ -259,14 +262,14 @@ export function useDataImport(emitFn) {
showBatchProgress.value = false
if (batchErrors.value.length > 0) {
ElNotification({
title: '导入异常',
title: t('migration.import_anomaly'),
message: batchErrors.value.join('<br>'),
type: 'error',
dangerouslyUseHTMLString: true,
duration: 0
})
} else {
ElMessage.warning('没有可导入的有效账户数据')
ElMessage.warning(t('migration.no_valid_data'))
}
resetBatchState()
return
@@ -284,28 +287,28 @@ export function useDataImport(emitFn) {
}
}
batchAccumulatedVault.value = filteredVault;
batchCurrentTaskName.value = `合并保存中... (${batchAccumulatedVault.value.length} 个账号)`
batchCurrentTaskName.value = t('migration.task_saving', { count: batchAccumulatedVault.value.length })
try {
const data = await dataMigrationService.saveImportedVault(batchAccumulatedVault.value)
showBatchProgress.value = false
if (data.success) {
let msgHtml = `<div>共处理 <b>${batchTotalJobs.value}</b> 个文件。</div>`
let msgHtml = t('migration.msg_total_files', { count: batchTotalJobs.value })
if (data.count > 0) {
msgHtml += `<div style="color:var(--el-color-success)">🎉 成功导入 <b>${data.count}</b> 个新账户!</div>`
msgHtml += t('migration.msg_success_accounts', { count: data.count })
} else {
msgHtml += `<div style="color:var(--el-color-warning)">⚠️ 导入账户皆已存在,无需添加。</div>`
msgHtml += t('migration.msg_no_new_accounts')
}
if (data.duplicates > 0) msgHtml += `<div style="color:var(--el-text-color-secondary)">ℹ️ 自动跳过了 <b>${data.duplicates}</b> 个已拥有的账户。</div>`
if (data.duplicates > 0) msgHtml += t('migration.msg_duplicates_skipped', { count: data.duplicates })
if (batchErrors.value.length > 0) {
msgHtml += `<div style="color:var(--el-color-danger); margin-top: 5px;">❌ <b>错误摘要:</b><br>${batchErrors.value.join('<br>')}</div>`
msgHtml += t('migration.msg_error_summary', { errors: batchErrors.value.join('<br>') })
}
ElNotification({
title: '批量导入结束',
title: t('migration.batch_import_finished'),
message: msgHtml,
dangerouslyUseHTMLString: true,
type: batchErrors.value.length > 0 ? 'warning' : 'success',
@@ -317,11 +320,11 @@ export function useDataImport(emitFn) {
emitFn('success')
}
} else {
ElMessage.error(`后端合并保存失败,请检查网络`)
ElMessage.error(t('api_errors.request_failed'))
}
} catch (err) {
showBatchProgress.value = false
ElMessage.error(`最终保存异常:${err.message}`)
ElMessage.error(`${t('api_errors.request_failed')}: ${err.message}`)
} finally {
resetBatchState()
}
@@ -2,8 +2,8 @@
<div class="data-export-wrapper">
<div class="tab-card-wrapper">
<div style="text-align: center; margin-bottom: 30px;">
<h2>数据导出</h2>
<p style="color: var(--el-text-color-secondary);">选择您需要的导出格式。请注意,明文导出存在安全风险。</p>
<h2>{{ $t('migration.export') }}</h2>
<p style="color: var(--el-text-color-secondary);">{{ $t('migration.export_desc') }}</p>
</div>
<div class="export-groups" v-loading="isExporting" :element-loading-text="loadingText">
@@ -12,14 +12,14 @@
<div class="export-group-card">
<div class="group-header">
<el-icon><Lock /></el-icon>
<span>本系统备份</span>
<span>{{ $t('migration.system_backup') }}</span>
</div>
<div class="button-row">
<el-button plain @click="openExportDialog('encrypted')" class="button-with-icon">
<el-icon><Lock /></el-icon> 加密备份 (.json)
<el-icon><Lock /></el-icon> {{ $t('migration.encrypted_json') }}
</el-button>
<el-button plain @click="openWarningDialog('json')" class="button-with-icon">
<el-icon><Unlock /></el-icon> 明文备份 (.json)
<el-icon><Unlock /></el-icon> {{ $t('migration.plaintext_json') }}
</el-button>
</div>
</div>
@@ -28,7 +28,7 @@
<div class="export-group-card">
<div class="group-header">
<el-icon><Iphone /></el-icon>
<span>移动端 2FA App</span>
<span>{{ $t('migration.mobile_app') }}</span>
</div>
<div class="button-row">
<el-button plain @click="openWarningDialog('2fas')" class="button-with-icon">
@@ -38,7 +38,7 @@
<el-icon><iconAegis /></el-icon> Aegis (.json)
</el-button>
<el-button plain @click="openGaDialogDirectly" class="button-with-icon">
<el-icon><iconGoogleAuth /></el-icon> 迁移到 Google Auth
<el-icon><iconGoogleAuth /></el-icon> {{ $t('migration.migrate_ga') }}
</el-button>
<el-button plain @click="openWarningDialog('bwauth')" class="button-with-icon">
<el-icon><iconBitwarden /></el-icon> Bitwarden Auth (.json)
@@ -50,20 +50,20 @@
<div class="export-group-card">
<div class="group-header">
<el-icon><Document /></el-icon>
<span>通用格式</span>
<span>{{ $t('migration.generic_format') }}</span>
</div>
<div class="button-row">
<el-button plain @click="openWarningDialog('generic_json')" class="button-with-icon">
<el-icon><Document /></el-icon> 通用格式 (.json)
<el-icon><Document /></el-icon> {{ $t('migration.generic_format') }} (.json)
</el-button>
<el-button plain @click="openWarningDialog('text')" class="button-with-icon">
<el-icon><Tickets /></el-icon> OTPAuth URI (.txt)
<el-icon><Tickets /></el-icon> {{ $t('migration.otpauth_txt') }}
</el-button>
<el-button plain @click="openWarningDialog('csv', 'generic')" class="button-with-icon">
<el-icon><Grid /></el-icon> 电子表格 (.csv)
<el-icon><Grid /></el-icon> {{ $t('migration.spreadsheet_csv') }}
</el-button>
<el-button plain @click="openWarningDialog('html')" class="button-with-icon">
<el-icon><Monitor /></el-icon> 普通网页 (.html)
<el-icon><Monitor /></el-icon> {{ $t('migration.html_page') }}
</el-button>
</div>
</div>
@@ -72,60 +72,60 @@
</div>
<!-- 加密导出密码弹窗 -->
<el-dialog v-model="showPasswordDialog" title="设置导出密码" width="400px" destroy-on-close>
<el-dialog v-model="showPasswordDialog" :title="$t('migration.export_pwd_title')" width="400px" destroy-on-close>
<el-form :model="exportForm" label-position="top">
<el-form-item label="加密密码 (至少 12 位)">
<el-input v-model="exportForm.password" type="password" show-password placeholder="请输入高强度密码" />
<el-form-item :label="$t('migration.export_password')">
<el-input v-model="exportForm.password" type="password" show-password :placeholder="$t('migration.pwd_placeholder_strong')" />
</el-form-item>
<el-form-item label="确认密码">
<el-input v-model="exportForm.confirm" type="password" show-password @keyup.enter="executeExport" placeholder="请再次输入" />
<el-form-item :label="$t('migration.export_password_confirm')">
<el-input v-model="exportForm.confirm" type="password" show-password @keyup.enter="executeExport" :placeholder="$t('migration.pwd_placeholder_again')" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showPasswordDialog = false">取消</el-button>
<el-button type="primary" :loading="isExporting" @click="executeExport">开始加密并下载</el-button>
<el-button @click="showPasswordDialog = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" :loading="isExporting" @click="executeExport">{{ $t('migration.start_encrypt_dl') }}</el-button>
</template>
</el-dialog>
<!-- 明文导出风险提示弹窗 -->
<el-dialog v-model="showWarningDialog" title="⚠️ 安全警告" width="400px" destroy-on-close>
<el-alert title="风险提示" type="error" :closable="false" description="您正在导出未加密的明文数据。任何获取该文件/图片的人都可以直接访问您的账号验证码!" show-icon />
<el-dialog v-model="showWarningDialog" :title="$t('migration.warning_title')" width="400px" destroy-on-close>
<el-alert :title="$t('migration.warning_alert_title')" type="error" :closable="false" :description="$t('migration.warning_desc')" show-icon />
<template #footer>
<el-button @click="showWarningDialog = false">取消</el-button>
<el-button type="danger" @click="executeExport" :loading="isExporting">确定导出</el-button>
<el-button @click="showWarningDialog = false">{{ $t('common.cancel') }}</el-button>
<el-button type="danger" @click="executeExport" :loading="isExporting">{{ $t('migration.confirm_export') }}</el-button>
</template>
</el-dialog>
<!-- Google Auth 二维码弹窗 -->
<el-dialog v-model="showGaDialog" title="迁移到 Google Authenticator" width="450px" style="text-align: center" destroy-on-close>
<el-dialog v-model="showGaDialog" :title="$t('migration.ga_title')" width="450px" style="text-align: center" destroy-on-close>
<div v-if="gaQrDataUrls.length > 0">
<p style="margin-bottom: 10px; color: var(--el-text-color-secondary);">
打开手机上的 Google Authenticator,选择"扫描二维码"进行导入<br/>
{{ $t('migration.ga_desc_1') }}<br/>
<span v-if="gaQrDataUrls.length > 1" style="color: var(--el-color-warning); font-weight: bold;">
(账号较多,已分批生成,请依次扫码)
{{ $t('migration.ga_desc_multiple') }}
</span>
</p>
<div v-loading="isExporting" style="min-height: 200px; display: flex; flex-direction: column; align-items: center; justify-content: center;">
<img :src="gaQrDataUrls[gaCurrentIndex]" alt="Google Auth Migration QR" style="max-width: 100%; padding: 10px; margin-bottom: 15px;" />
<div v-if="gaQrDataUrls.length > 1" style="display: flex; align-items: center; gap: 15px; justify-content: center;">
<el-button :disabled="gaCurrentIndex === 0" @click="gaCurrentIndex--" size="small">上一张</el-button>
<el-button :disabled="gaCurrentIndex === 0" @click="gaCurrentIndex--" size="small">{{ $t('migration.btn_prev') }}</el-button>
<span style="font-size: 14px; font-weight: bold;">{{ gaCurrentIndex + 1 }} / {{ gaQrDataUrls.length }}</span>
<el-button :disabled="gaCurrentIndex === gaQrDataUrls.length - 1" @click="gaCurrentIndex++" size="small" type="primary">下一张</el-button>
<el-button :disabled="gaCurrentIndex === gaQrDataUrls.length - 1" @click="gaCurrentIndex++" size="small" type="primary">{{ $t('migration.btn_next') }}</el-button>
</div>
</div>
</div>
<div v-else v-loading="isExporting" style="min-height: 200px;"></div>
<template #footer>
<el-button @click="showGaDialog = false">完成</el-button>
<el-button @click="showGaDialog = false">{{ $t('migration.finish') }}</el-button>
</template>
</el-dialog>
<!-- 账号选择弹窗 (用于 Google Auth 选择性导出) -->
<el-dialog v-model="showAccountSelectDialog" title="选择要导出的账号" width="450px" destroy-on-close>
<el-dialog v-model="showAccountSelectDialog" :title="$t('migration.select_account_title')" width="450px" destroy-on-close>
<div class="account-select-toolbar">
<el-input
v-model="searchKeyword"
placeholder="搜索服务或账号..."
:placeholder="$t('migration.search_account')"
clearable
:prefix-icon="Search"
style="margin-bottom: 15px;"
@@ -138,7 +138,7 @@
>
{{ selectAllText }}
</el-checkbox>
<span class="selected-count">已选: {{ selectedAccountIds.length }} / {{ fullVault.length }}</span>
<span class="selected-count">{{ $t('migration.selected_count', { selected: selectedAccountIds.length, total: fullVault.length }) }}</span>
</div>
</div>
@@ -155,14 +155,14 @@
</el-checkbox>
</div>
</template>
<el-empty v-else description="无匹配账号" :image-size="60" />
<el-empty v-else :description="$t('migration.no_matching')" :image-size="60" />
</el-scrollbar>
</el-checkbox-group>
</div>
<template #footer>
<el-button @click="showAccountSelectDialog = false">取消</el-button>
<el-button type="primary" :loading="isExporting" @click="executeGaExport">迁移 {{ selectedAccountIds.length }} 个账号</el-button>
<el-button @click="showAccountSelectDialog = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" :loading="isExporting" @click="executeGaExport">{{ $t('migration.migrate_btn', { count: selectedAccountIds.length }) }}</el-button>
</template>
</el-dialog>
</div>
@@ -179,6 +179,7 @@ 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 { useDataExport } from '@/features/migration/composables/useDataExport'
import { i18n } from '@/locales'
const {
showPasswordDialog,
@@ -206,10 +207,11 @@ const {
// 动态计算“全选”按钮文案
const selectAllText = computed(() => {
const { t } = i18n.global
if (!searchKeyword.value || searchKeyword.value.trim() === '') {
return '全选所有账号'
return t('migration.select_all_accounts')
} else {
return `全选搜索出的 ${filteredVault.value.length} 个账号`
return t('migration.select_all_search', { count: filteredVault.value.length })
}
})
@@ -2,8 +2,8 @@
<div class="data-import-wrapper">
<div class="tab-card-wrapper">
<div style="text-align: center; margin-bottom: 30px;">
<h2>数据导入</h2>
<p style="color: var(--el-text-color-secondary);">支持从各类 2FA 软件或本系统的备份文件中恢复数据。</p>
<h2>{{ $t('migration.center_title') }}</h2>
<p style="color: var(--el-text-color-secondary);">{{ $t('migration.center_desc') }}</p>
</div>
<div style="max-width: 100%; margin: 0 auto;">
@@ -20,23 +20,23 @@
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">
<p><el-tag type="success" effect="light">💡 自动识别文件类型导入</el-tag></p>
<p>将备份文件或迁移二维码截图拖到此处,或 <em>点击上传</em></p>
<p><el-tag type="success" effect="light">{{ $t('migration.auto_identify_tip') }}</el-tag></p>
<p><span v-html="$t('migration.drag_drop_tip')"></span></p>
</div>
<template #tip>
<div class="import-tips">
<h4>支持情况说明</h4>
<h4>{{ $t('migration.support_desc') }}</h4>
<div class="format-groups">
<div class="format-group">
<h4>📁 本系统备份</h4>
<h4>📁 {{ $t('migration.system_backup_format') }}</h4>
<div class="tags">
<el-tag type="info" effect="light"><el-icon><Lock /></el-icon> 加密备份 (.json)</el-tag>
<el-tag type="info" effect="light"><el-icon><Unlock /></el-icon> 明文备份 (.json)</el-tag>
<el-tag type="info" effect="light"><el-icon><Lock /></el-icon> {{ $t('migration.encrypted_backup_json') }}</el-tag>
<el-tag type="info" effect="light"><el-icon><Unlock /></el-icon> {{ $t('migration.plaintext_backup_json') }}</el-tag>
</div>
</div>
<div class="format-group">
<h4>📱 移动端 2FA App</h4>
<h4>📱 {{ $t('migration.mobile_app_format') }}</h4>
<div class="tags">
<el-tag type="info" effect="light"><icon2FAS /> 2FAS (.2fas)</el-tag>
<el-tag type="info" effect="light"><iconAegis /> Aegis (.json/.txt)</el-tag>
@@ -47,11 +47,11 @@
</div>
<div class="ga-tip">
<span>Google Authenticator</span>
<p>请在 App 内导出"迁移二维码",将截图直接上传 (.png / .jpg)</p>
<p>{{ $t('migration.ga_auth_desc') }}</p>
</div>
<div class="ms-tip">
<span>Microsoft Authenticator <small>(仅支持 Android)</small></span>
<p>Android 手机导出以下文件后,同时上传即可导入:
<span>{{ $t('migration.ms_auth_desc') }}</span>
<p>{{ $t('migration.ms_auth_detail') }}:<br />
<code>/data/data/com.azure.authenticator/databases/PhoneFactor</code><br />
<code>/data/data/com.azure.authenticator/databases/PhoneFactor-wal</code><br />
<code>/data/data/com.azure.authenticator/databases/PhoneFactor-shm</code>
@@ -60,11 +60,11 @@
</div>
<div class="format-group">
<h4>📄 通用格式</h4>
<h4>📄 {{ $t('migration.generic_format') }}</h4>
<div class="tags">
<el-tag type="info" effect="light"><el-icon><Document /></el-icon> 通用格式 (.json)</el-tag>
<el-tag type="info" effect="light"><el-icon><Document /></el-icon> {{ $t('migration.generic_json') }}</el-tag>
<el-tag type="info" effect="light"><el-icon><Tickets /></el-icon> OTPAuth URI (.txt)</el-tag>
<el-tag type="info" effect="light"><el-icon><Grid /></el-icon> 电子表格 (.csv)</el-tag>
<el-tag type="info" effect="light"><el-icon><Grid /></el-icon> {{ $t('migration.spreadsheet_csv') }}</el-tag>
</div>
</div>
</div>
@@ -78,7 +78,7 @@
<!-- 批量导入进度弹窗 -->
<el-dialog
v-model="showBatchProgress"
title="📦 批量导入处理中"
:title="$t('migration.batch_import_processing')"
width="450px"
:close-on-click-modal="false"
:close-on-press-escape="false"
@@ -91,7 +91,7 @@
:status="batchProgressPercent === 100 ? 'success' : ''"
/>
<h3 style="margin-top: 20px;">
{{ batchProcessedJobs }} / {{ batchTotalJobs }} 任务已完成
{{ $t('migration.batch_progress', { processed: batchProcessedJobs, total: batchTotalJobs }) }}
</h3>
<p style="color: var(--el-text-color-secondary); margin-top: 10px;">
{{ batchCurrentTaskName }}
@@ -100,19 +100,19 @@
</el-dialog>
<!-- 加密文件密码输入弹窗 -->
<el-dialog v-model="showDecryptDialog" title="🔓 解密备份文件" width="400px" @close="handleDecryptDialogClose" destroy-on-close>
<el-alert v-if="currentImportType === 'aegis_encrypted'" title="检测到 Aegis 加密备份" type="warning" :closable="false" style="margin-bottom: 15px;" />
<el-alert v-else-if="currentImportType === 'proton_encrypted'" title="检测到 Proton Auth 加密备份" type="warning" :closable="false" style="margin-bottom: 15px;" />
<el-alert v-else-if="currentImportType === '2fas_encrypted'" title="检测到 2FAS Auth 加密备份" type="warning" :closable="false" style="margin-bottom: 15px;" />
<el-alert v-else title="检测到本系统加密备份" type="success" :closable="false" style="margin-bottom: 15px;" />
<el-form label-position="top" v-loading="isDecrypting" :element-loading-text="loadingText">
<el-form-item label="请输入该备份的解密密码:">
<el-input v-model="importPassword" type="password" show-password @keyup.enter="submitEncryptedData" placeholder="输入当时设置的导出密码" />
<el-dialog v-model="showDecryptDialog" :title="$t('migration.decrypt_backup_title')" width="400px" @close="handleDecryptDialogClose" destroy-on-close>
<el-alert v-if="currentImportType === 'aegis_encrypted'" :title="$t('migration.detect_aegis')" type="warning" :closable="false" style="margin-bottom: 15px;" />
<el-alert v-else-if="currentImportType === 'proton_encrypted'" :title="$t('migration.detect_proton')" type="warning" :closable="false" style="margin-bottom: 15px;" />
<el-alert v-else-if="currentImportType === '2fas_encrypted'" :title="$t('migration.detect_2fas')" type="warning" :closable="false" style="margin-bottom: 15px;" />
<el-alert v-else :title="$t('migration.detect_system')" type="success" :closable="false" style="margin-bottom: 15px;" />
<el-form label-position="top">
<el-form-item :label="$t('migration.input_decrypt_pwd_label')">
<el-input v-model="importPassword" type="password" show-password @keyup.enter="submitEncryptedData" :placeholder="$t('migration.input_decrypt_pwd_placeholder')" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showDecryptDialog = false">取消</el-button>
<el-button type="primary" :loading="isDecrypting" @click="submitEncryptedData">确认解密并导入</el-button>
<el-button @click="showDecryptDialog = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" :loading="isDecrypting" @click="submitEncryptedData">{{ $t('migration.confirm_decrypt_import') }}</el-button>
</template>
</el-dialog>
</div>
@@ -1,10 +1,10 @@
<template>
<div class="migration-container">
<el-tabs v-model="activeTab" class="migration-tabs">
<el-tab-pane label="数据导出" name="export">
<el-tab-pane :label="$t('migration.export')" name="export">
<DataExport />
</el-tab-pane>
<el-tab-pane label="数据导入" name="import">
<el-tab-pane :label="$t('migration.import')" name="import">
<DataImport @success="$emit('success')" />
</el-tab-pane>
</el-tabs>
@@ -2,14 +2,14 @@
<div class="tool-pane">
<div class="result-box">
<div class="password-display">{{ password }}</div>
<el-button type="primary" link @click="copyPassword" title="复制">
<el-button type="primary" link @click="copyPassword" :title="$t('common.copy')">
<el-icon size="20"><CopyDocument /></el-icon>
</el-button>
</div>
<div class="controls">
<div class="control-row">
<span class="label">长度: {{ length }}</span>
<span class="label">{{ $t('tools.length') }}: {{ length }}</span>
<el-slider v-model="length" :min="6" :max="64" @input="generate" style="flex: 1; margin: 0 15px;" />
</div>
@@ -22,7 +22,7 @@
</div>
<el-button type="primary" size="large" @click="generate" style="width: 100%; margin-top: 20px;">
<el-icon><Refresh /></el-icon> 重新生成
<el-icon><Refresh /></el-icon> {{ $t('tools.regenerate') }}
</el-button>
</div>
</template>
@@ -31,6 +31,7 @@
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { CopyDocument, Refresh } from '@element-plus/icons-vue'
import { i18n } from '@/locales'
const password = ref('')
const length = ref(16)
@@ -38,6 +39,7 @@ const useUpper = ref(true)
const useLower = ref(true)
const useNumber = ref(true)
const useSymbol = ref(true)
const { t } = i18n.global
const generate = () => {
let chars = ''
@@ -48,7 +50,7 @@ const generate = () => {
if (!chars) {
password.value = ''
ElMessage.warning('请至少选择一种字符类型')
ElMessage.warning(t('tools.select_one_char_type'))
return
}
@@ -65,9 +67,9 @@ const copyPassword = async () => {
if (!password.value) return
try {
await navigator.clipboard.writeText(password.value)
ElMessage.success('密码已复制')
ElMessage.success(t('tools.password_copied'))
} catch (e) {
ElMessage.error('复制失败')
ElMessage.error(t('common.error'))
}
}
@@ -6,10 +6,10 @@
<!-- Result Display -->
<div v-if="scanResult" class="result-section" style="margin-top: 20px;">
<el-divider content-position="left">解析结果</el-divider>
<el-divider content-position="left">{{ $t('tools.qr_result') }}</el-divider>
<el-input v-model="scanResult" type="textarea" :rows="3" readonly resize="none" />
<el-button type="success" plain style="width: 100%; margin-top: 10px;" @click="copyResult">
<el-icon><CopyDocument /></el-icon> 复制内容
<el-icon><CopyDocument /></el-icon> {{ $t('common.copy') }}
</el-button>
</div>
@@ -21,19 +21,22 @@
import { ref, defineAsyncComponent } from 'vue'
import { ElMessage } from 'element-plus'
import { CopyDocument } from '@element-plus/icons-vue'
import { i18n } from '@/locales'
// Import QrScanner component
const QrScanner = defineAsyncComponent(() => import('@/shared/components/qrScanner.vue'))
const scanResult = ref('')
const { t } = i18n.global
const handleScanSuccess = (result) => {
scanResult.value = result
ElMessage.success('二维码解析成功')
ElMessage.success(t('tools.qr_parsed'))
}
const copyResult = async () => {
if (!scanResult.value) return
try { await navigator.clipboard.writeText(scanResult.value); ElMessage.success('内容已复制') } catch (e) {}
try { await navigator.clipboard.writeText(scanResult.value); ElMessage.success(t('tools.password_copied')) } catch (e) {}
}
</script>
@@ -15,23 +15,23 @@
<!-- 时钟仪表盘 -->
<div class="clocks-wrapper">
<div class="clock-card local">
<div class="clock-label">📱 本地设备时间</div>
<div class="clock-label">📱 {{ $t('tools.local_time') }}</div>
<div class="clock-time">{{ formatTime(localTime) }}</div>
</div>
<div class="clock-card server">
<div class="clock-label">☁️ 服务器时间 (估算)</div>
<div class="clock-label">☁️ {{ $t('tools.server_time') }}</div>
<div class="clock-time">{{ formatTime(serverTime) }}</div>
</div>
</div>
<!-- 详细数据 -->
<div class="sync-details">
<p>时间偏差: <strong>{{ offset !== null ? `${offset > 0 ? '+' : ''}${offset} ms` : '--' }}</strong></p>
<p>网络延迟: <span>{{ rtt !== null ? `${rtt} ms` : '--' }}</span></p>
<p>{{ $t('tools.time_offset') }}: <strong>{{ offset !== null ? `${offset > 0 ? '+' : ''}${offset} ms` : '--' }}</strong></p>
<p>{{ $t('tools.network_latency') }}: <span>{{ rtt !== null ? `${rtt} ms` : '--' }}</span></p>
</div>
<el-button type="primary" size="large" :loading="isSyncing" @click="syncTime" style="width: 100%; margin-top: 20px;">
<el-icon><Refresh /></el-icon> 立即检测
<el-icon><Refresh /></el-icon> {{ $t('tools.check_now') }}
</el-button>
</div>
</div>
@@ -42,6 +42,7 @@ import { onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import { useTimeSync } from '@/features/tools/composables/useTimeSync'
import { i18n } from '@/locales'
const {
localTime,
@@ -54,13 +55,14 @@ const {
} = useTimeSync()
const formatTime = (ts) => new Date(ts).toLocaleTimeString()
const { t } = i18n.global
const syncTime = async () => {
const result = await _syncTime()
if (result.success) {
ElMessage.success(`校准完成,偏差 ${result.offset}ms`)
ElMessage.success(t('tools.sync_completed', { offset: result.offset }))
} else {
ElMessage.error(result.error?.message || '无法连接服务器')
ElMessage.error(result.error?.message || t('api_errors.request_failed'))
}
}
@@ -6,9 +6,9 @@
<!-- 1. Secret Input (Tabs) -->
<div class="config-section">
<div class="section-header">
<h3 class="section-title">密钥配置</h3>
<h3 class="section-title">{{ $t('tools.secret_config') }}</h3>
<el-button link type="primary" @click="showScanner = true">
<el-icon><Camera /></el-icon> 识别二维码导入
<el-icon><Camera /></el-icon> {{ $t('vault.scan_qr') }}
</el-button>
</div>
@@ -23,12 +23,12 @@
:rows="3"
/>
<div class="tab-actions">
<el-button size="small" @click="refreshBase32"><el-icon><Refresh /></el-icon> 随机生成</el-button>
<el-button size="small" @click="copyToClipboard(secretBase32)"><el-icon><CopyDocument /></el-icon> 复制</el-button>
<el-button size="small" @click="refreshBase32"><el-icon><Refresh /></el-icon> {{ $t('tools.regenerate') }}</el-button>
<el-button size="small" @click="copyToClipboard(secretBase32)"><el-icon><CopyDocument /></el-icon> {{ $t('common.copy') }}</el-button>
</div>
</el-tab-pane>
<el-tab-pane label="Hex (十六进制)" name="hex">
<el-tab-pane :label="$t('tools.totp_hex')" name="hex">
<el-input
v-model="secretHex"
@input="handleHexInput"
@@ -38,8 +38,8 @@
:rows="3"
/>
<div class="tab-actions">
<el-button size="small" @click="refreshHex"><el-icon><Refresh /></el-icon> 随机生成</el-button>
<el-button size="small" @click="copyToClipboard(secretHex)"><el-icon><CopyDocument /></el-icon> 复制</el-button>
<el-button size="small" @click="refreshHex"><el-icon><Refresh /></el-icon> {{ $t('tools.regenerate') }}</el-button>
<el-button size="small" @click="copyToClipboard(secretHex)"><el-icon><CopyDocument /></el-icon> {{ $t('common.copy') }}</el-button>
</div>
</el-tab-pane>
@@ -53,8 +53,8 @@
:rows="3"
/>
<div class="tab-actions">
<el-button size="small" @click="refreshAscii"><el-icon><Refresh /></el-icon> 随机生成</el-button>
<el-button size="small" @click="copyToClipboard(secretAscii)"><el-icon><CopyDocument /></el-icon> 复制</el-button>
<el-button size="small" @click="refreshAscii"><el-icon><Refresh /></el-icon> {{ $t('tools.regenerate') }}</el-button>
<el-button size="small" @click="copyToClipboard(secretAscii)"><el-icon><CopyDocument /></el-icon> {{ $t('common.copy') }}</el-button>
</div>
</el-tab-pane>
</el-tabs>
@@ -62,13 +62,13 @@
<!-- 2. Metadata -->
<div class="config-section">
<h3 class="section-title">基础信息</h3>
<h3 class="section-title">{{ $t('tools.basic_info') }}</h3>
<div class="meta-row">
<el-input v-model="issuer" @input="updateUri">
<template #prefix><span class="input-label">服务商</span></template>
<template #prefix><span class="input-label">{{ $t('vault.service') }}</span></template>
</el-input>
<el-input v-model="account" @input="updateUri">
<template #prefix><span class="input-label">账号标识</span></template>
<template #prefix><span class="input-label">{{ $t('vault.account') }}</span></template>
</el-input>
</div>
</div>
@@ -76,23 +76,23 @@
<!-- 3. Result Preview -->
<div class="config-section">
<div class="section-header">
<h3 class="section-title">结果预览</h3>
<h3 class="section-title">{{ $t('tools.preview') }}</h3>
<el-button link type="primary" @click="downloadQrCode" :disabled="!qrCodeUrl">
<el-icon><Download /></el-icon> 保存二维码
<el-icon><Download /></el-icon> {{ $t('common.save') }}
</el-button>
</div>
<div class="preview-top-section">
<div class="qr-wrapper" v-loading="!qrCodeUrl">
<img v-if="qrCodeUrl" :src="qrCodeUrl" alt="QR Code" class="qr-img" />
<el-empty v-else description="配置参数以生成预览" :image-size="100" />
<el-empty v-else :description="$t('tools.totp_config_preview')" :image-size="100" />
</div>
<div class="result-card">
<div class="totp-code" :class="{ 'blur': !currentCode }">{{ currentCode || '------' }}</div>
<div class="totp-timer" :class="{ 'urgent': remaining < 5 }">
<el-icon><Timer /></el-icon> {{ remaining }}s 后刷新
<el-icon><Timer /></el-icon> {{ remaining }}s {{ $t('tools.refresh_after') }}
</div>
<el-button type="primary" plain size="small" @click="copyToClipboard(currentCode, '验证码已复制')" :disabled="!currentCode" style="margin-top: 10px;">
<el-icon><CopyDocument /></el-icon> 复制验证码
<el-button type="primary" plain size="small" @click="copyToClipboard(currentCode, $t('vault.copy_success'))" :disabled="!currentCode" style="margin-top: 10px;">
<el-icon><CopyDocument /></el-icon> {{ $t('common.copy') }}
</el-button>
</div>
</div>
@@ -105,20 +105,20 @@
<!-- 4. Advanced Settings -->
<div class="config-section">
<el-collapse>
<el-collapse-item title="高级设置 (算法/位数/步长)" name="1">
<el-collapse-item :title="$t('tools.advanced_settings')" name="1">
<div class="advanced-row">
<el-select v-model="algorithm" @change="updateAll('settings')" placeholder="算法" style="flex: 1">
<el-option label="SHA-1 (默认)" value="SHA-1" />
<el-select v-model="algorithm" @change="updateAll('settings')" :placeholder="$t('tools.totp_algorithm')" style="flex: 1">
<el-option :label="$t('tools.totp_algo_sha1_default')" value="SHA-1" />
<el-option label="SHA-256" value="SHA-256" />
<el-option label="SHA-512" value="SHA-512" />
</el-select>
<el-select v-model="digits" @change="updateAll('settings')" placeholder="位数" style="width: 100px">
<el-option label="6 位" :value="6" />
<el-option label="8 位" :value="8" />
<el-select v-model="digits" @change="updateAll('settings')" :placeholder="$t('tools.totp_digits')" style="width: 100px">
<el-option :label="$t('vault.digits_6')" :value="6" />
<el-option :label="$t('vault.digits_8')" :value="8" />
</el-select>
<el-select v-model="period" @change="updateAll('settings')" placeholder="周期" style="width: 100px">
<el-option label="30 秒" :value="30" />
<el-option label="60 秒" :value="60" />
<el-select v-model="period" @change="updateAll('settings')" :placeholder="$t('tools.totp_period')" style="width: 100px">
<el-option :label="$t('vault.period_30s')" :value="30" />
<el-option :label="$t('vault.period_60s')" :value="60" />
</el-select>
</div>
</el-collapse-item>
@@ -128,26 +128,26 @@
<!-- 5. Time Offset -->
<div class="config-section">
<div class="label-row">
<span class="label-text">时间偏移 (Time Travel): {{ timeOffset > 0 ? '+' : '' }}{{ timeOffset }}s</span>
<el-button link type="primary" @click="adjustTime(0, true)" size="small">重置</el-button>
<span class="label-text">{{ $t('tools.time_offset') }} (Time Travel): {{ timeOffset > 0 ? '+' : '' }}{{ timeOffset }}s</span>
<el-button link type="primary" @click="adjustTime(0, true)" size="small">{{ $t('tools.reset_time') }}</el-button>
</div>
<el-button-group style="width: 100%; display: flex;">
<el-button @click="adjustTime(-period)" style="flex:1" size="small">上个周期</el-button>
<el-button @click="adjustTime(period)" style="flex:1" size="small">下个周期</el-button>
<el-button @click="adjustTime(-period)" style="flex:1" size="small">{{ $t('tools.prev_period') }}</el-button>
<el-button @click="adjustTime(period)" style="flex:1" size="small">{{ $t('tools.next_period') }}</el-button>
</el-button-group>
</div>
<!-- Save Button -->
<div class="config-section" style="margin-top: 20px;">
<el-button type="success" size="large" @click="saveToVault" style="width: 100%;" :loading="isSaving">
<el-icon><CircleCheck /></el-icon> 保存到我的账户
<el-icon><CircleCheck /></el-icon> {{ $t('tools.save_to_vault') }}
</el-button>
</div>
</div>
</div>
<!-- 二维码扫描弹窗 -->
<el-dialog v-model="showScanner" title="扫描二维码" width="500px" destroy-on-close append-to-body>
<el-dialog v-model="showScanner" :title="$t('tools.totp_scan_qr_title')" width="500px" destroy-on-close append-to-body>
<QrScanner @scan-success="handleScanSuccess" />
</el-dialog>
</div>
@@ -1,5 +1,6 @@
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { toolService } from '@/features/tools/service/toolService'
import { i18n } from '@/locales'
/**
* 时间校准逻辑提取
@@ -18,9 +19,10 @@ export function useTimeSync() {
const syncStatus = computed(() => {
if (offset.value === null) return null
const abs = Math.abs(offset.value)
if (abs < 2000) return { title: '时间同步正常', type: 'success', desc: '本地时间与服务器误差极小,不影响 2FA 验证。' }
if (abs < 30000) return { title: '存在微小偏差', type: 'warning', desc: '建议校准本地时间,但通常仍可使用。' }
return { title: '时间偏差过大', type: 'error', desc: '严重偏差!2FA 验证码将失效,请务必校准设备时间。' }
const { t } = i18n.global
if (abs < 2000) return { title: t('tools.sync_normal'), type: 'success', desc: t('tools.sync_normal_desc') }
if (abs < 30000) return { title: t('tools.sync_warning'), type: 'warning', desc: t('tools.sync_warning_desc') }
return { title: t('tools.sync_error'), type: 'error', desc: t('tools.sync_error_desc') }
})
const syncTime = async () => {
@@ -2,6 +2,7 @@ import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import QRCode from 'qrcode'
import { vaultService } from '@/features/vault/service/vaultService'
import { i18n } from '@/locales'
/**
* TOTP 工具箱外部协同调度逻辑
@@ -11,6 +12,7 @@ import { vaultService } from '@/features/vault/service/vaultService'
* 这保证了 useTotpToolbox.js 作为纯状态机,不涉及第三方 UI 或非标准 API 边界效应。
*/
export function useTotpToolboxActions(toolboxState, queryClient) {
const { t } = i18n.global
const isSaving = ref(false)
const showScanner = ref(false)
const qrCodeUrl = ref('')
@@ -32,15 +34,15 @@ export function useTotpToolboxActions(toolboxState, queryClient) {
showScanner.value = false
const success = toolboxState.handleParsedUri(uri)
if (success) {
ElMessage.success('二维码解析成功')
ElMessage.success(t('tools.qr_parsed'))
} else {
ElMessage.warning('无效的 OTP URI')
ElMessage.warning(t('vault.generate_fail'))
}
}
const saveToVault = async () => {
if (!toolboxState.secretBase32.value) return ElMessage.warning('密钥不能为空')
if (!toolboxState.issuer.value || !toolboxState.account.value) return ElMessage.warning('请填写服务商和账号')
if (!toolboxState.secretBase32.value) return ElMessage.warning(t('tools.secret_empty'))
if (!toolboxState.issuer.value || !toolboxState.account.value) return ElMessage.warning(t('tools.fill_info'))
isSaving.value = true
try {
@@ -50,10 +52,10 @@ export function useTotpToolboxActions(toolboxState, queryClient) {
secret: toolboxState.secretBase32.value,
digits: toolboxState.digits.value,
period: toolboxState.period.value,
category: '工具箱添加'
category: t('tools.title')
})
if (res.success) {
ElMessage.success('已保存到我的账户')
ElMessage.success(t('vault.add_success'))
// 刷新账号列表缓存
queryClient.invalidateQueries(['vault'])
}
@@ -3,9 +3,9 @@
<div class="tab-card-wrapper">
<div class="tools-header">
<el-button v-if="currentTool" link @click="currentTool = null" class="back-btn">
<el-icon><ArrowLeft /></el-icon> 返回
<el-icon><ArrowLeft /></el-icon> {{ $t('common.back') }}
</el-button>
<h2>{{ currentTool ? getToolTitle(currentTool) : '🛠️ 实用工具箱' }}</h2>
<h2>{{ currentTool ? getToolTitle(currentTool) : `🛠️ ${$t('tools.title')}` }}</h2>
</div>
<!-- 工具列表 (卡片视图) -->
@@ -43,6 +43,7 @@
<script setup>
import { ref, computed, defineAsyncComponent } from 'vue'
import { Key, ArrowLeft, Timer, Lock, Camera } from '@element-plus/icons-vue'
import { i18n } from '@/locales'
const ToolPassword = defineAsyncComponent(() => import('@/features/tools/components/passwordGenerator.vue'))
const ToolTimeSync = defineAsyncComponent(() => import('@/features/tools/components/timeSync.vue'))
const ToolQrParser = defineAsyncComponent(() => import('@/features/tools/components/qrParser.vue'))
@@ -50,43 +51,45 @@ const ToolTotpSecret = defineAsyncComponent(() => import('@/features/tools/compo
const currentTool = ref(null)
const tools = [
const { t } = i18n.global
const tools = computed(() => [
{
id: 'totp-secret',
title: 'TOTP 密钥工具',
desc: '随机生成密钥,支持 Base32 格式转换与校验',
title: t('tools.totp_secret_title'),
desc: t('tools.totp_secret_desc'),
icon: Lock,
iconColor: '#67C23A',
bgColor: 'var(--el-color-success-light-9)'
},
{
id: 'password',
title: '密码生成器',
desc: '生成高强度随机密码,支持自定义长度和字符类型',
title: t('tools.password_gen_title'),
desc: t('tools.password_gen_desc'),
icon: Key,
iconColor: '#409EFF',
bgColor: 'var(--el-color-primary-light-9)'
},
{
id: 'time-sync',
title: '时间校准器',
desc: '精准检测本地时间与服务器时间的偏差',
title: t('tools.time_sync_title'),
desc: t('tools.time_sync_desc'),
icon: Timer,
iconColor: '#E6A23C',
bgColor: 'var(--el-color-warning-light-9)'
},
{
id: 'qr-parser',
title: '二维码解析器',
desc: '识别图片中的二维码内容,提取 OTP 链接',
title: t('tools.qr_parser_title'),
desc: t('tools.qr_parser_desc'),
icon: Camera,
iconColor: '#F56C6C',
bgColor: 'var(--el-color-danger-light-9)'
}
]
])
const getToolTitle = (id) => {
const t = tools.find(t => t.id === id)
const t = tools.value.find(tool => tool.id === id)
return t ? t.title : '工具'
}
@@ -1,54 +1,54 @@
<template>
<div class="add-vault-wrapper">
<div class="tab-card-wrapper">
<h2 style="text-align: center; margin-bottom: 20px;">⌨️ 手动输入</h2>
<h2 style="text-align: center; margin-bottom: 20px;">{{ $t('vault.manual_title') }}</h2>
<div style="max-width: 100%; margin: 0 auto;">
<el-form :model="newVault" label-position="top" :rules="rules" ref="addFormRef" style="padding: 10px 0;">
<el-form-item label="服务名称 (如 Google, GitHub)" prop="service">
<el-input v-model="newVault.service" placeholder="请输入服务名称" />
<el-form-item :label="$t('common.service_name')" prop="service">
<el-input v-model="newVault.service" :placeholder="$t('vault.input_service_placeholder')" />
</el-form-item>
<el-form-item label="账号标识 (如 邮箱地址)" prop="account">
<el-input v-model="newVault.account" placeholder="请输入账号或邮箱" />
<el-form-item :label="$t('common.account_identifier')" prop="account">
<el-input v-model="newVault.account" :placeholder="$t('vault.input_account_placeholder')" />
</el-form-item>
<el-form-item label="安全密钥 (Base32格式)" prop="secret">
<el-input v-model="newVault.secret" placeholder="请输入 16 位以上的安全密钥" clearable>
<el-form-item :label="$t('vault.input_secret_label')" prop="secret">
<el-input v-model="newVault.secret" :placeholder="$t('vault.input_secret_placeholder')" clearable>
<template #append>
<el-button @click="generateRandomSecret" title="随机生成密钥"><el-icon><Refresh /></el-icon></el-button>
<el-button @click="generateRandomSecret" :title="$t('vault.generate_random_secret')"><el-icon><Refresh /></el-icon></el-button>
</template>
</el-input>
</el-form-item>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="代码位数" prop="digits">
<el-form-item :label="$t('vault.digits_label')" prop="digits">
<el-select v-model="newVault.digits" style="width: 100%">
<el-option label="6 位" :value="6" />
<el-option label="8 位" :value="8" />
<el-option :label="$t('vault.digits_6')" :value="6" />
<el-option :label="$t('vault.digits_8')" :value="8" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="更新周期" prop="period">
<el-form-item :label="$t('vault.period_label')" prop="period">
<el-select v-model="newVault.period" style="width: 100%">
<el-option label="30 秒" :value="30" />
<el-option label="60 秒" :value="60" />
<el-option :label="$t('vault.period_30s')" :value="30" />
<el-option :label="$t('vault.period_60s')" :value="60" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="算法" prop="algorithm">
<el-form-item :label="$t('vault.algorithm_label')" prop="algorithm">
<el-select v-model="newVault.algorithm" style="width: 100%">
<el-option label="SHA1 (常用)" value="SHA1" />
<el-option :label="$t('vault.algo_sha1')" value="SHA1" />
<el-option label="SHA256" value="SHA256" />
<el-option label="SHA512" value="SHA512" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="分类 (可选)" prop="category">
<el-input v-model="newVault.category" placeholder="如 工作, 个人" />
<el-form-item :label="$t('common.category_optional')" prop="category">
<el-input v-model="newVault.category" :placeholder="$t('vault.input_category_placeholder')" />
</el-form-item>
<el-form-item style="margin-top: 30px;">
<el-button type="primary" :loading="submitting" @click="submitAddVault" style="width: 100%;" size="large">确认添加</el-button>
<el-button type="primary" :loading="submitting" @click="submitAddVault" style="width: 100%;" size="large">{{ $t('vault.confirm_add_btn') }}</el-button>
</el-form-item>
</el-form>
</div>
@@ -58,6 +58,7 @@
<script setup>
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import { bytesToBase32 } from '@/shared/utils/totp'
@@ -66,6 +67,8 @@ import { vaultService } from '@/features/vault/service/vaultService'
const emit = defineEmits(['success'])
const { t } = useI18n()
const vaultStore = useVaultStore()
const submitting = ref(false)
const addFormRef = ref(null)
@@ -75,22 +78,22 @@ const newVault = ref({
const validateSecret = (rule, value, callback) => {
if (!value) {
return callback(new Error('请输入安全密钥'))
return callback(new Error(t('vault.require_secret')))
}
// 移除空格后检查
const clean = value.replace(/\s/g, '')
if (clean.length < 16) {
return callback(new Error('密钥长度通常不少于 16 位'))
return callback(new Error(t('vault.secret_min_length')))
}
if (!/^[A-Z2-7]+$/i.test(clean)) {
return callback(new Error('包含非法字符 (仅限 A-Z, 2-7)'))
return callback(new Error(t('vault.secret_invalid_char')))
}
callback()
}
const rules = {
service: [{ required: true, message: '请输入服务名称', trigger: 'blur' }],
account: [{ required: true, message: '请输入账号标识', trigger: 'blur' }],
service: [{ required: true, message: t('vault.require_service'), trigger: 'blur' }],
account: [{ required: true, message: t('vault.require_account'), trigger: 'blur' }],
secret: [{ required: true, validator: validateSecret, trigger: 'blur' }]
}
@@ -108,7 +111,7 @@ const submitAddVault = async () => {
try {
const data = await vaultService.createAccount(newVault.value)
if (data.success) {
ElMessage.success('账号添加成功!')
ElMessage.success(t('vault.add_success'))
newVault.value = { service: '', account: '', secret: '', category: '', digits: 6, period: 30, algorithm: 'SHA1' }
vaultStore.markDirty() // 实际写入数据,标记缓存过期
emit('success')
@@ -1,10 +1,10 @@
<template>
<div class="add-vault-wrapper">
<div class="tab-card-wrapper">
<h2 style="text-align: center; margin-bottom: 20px;">📷 扫码添加</h2>
<h2 style="text-align: center; margin-bottom: 20px;">📷 {{ $t('vault.scan_qr') }}</h2>
<div style="max-width: 100%; margin: 0 auto;">
<div style="text-align: center; margin-bottom: 20px; margin-top: 10px;">
<p style="color: var(--el-text-color-secondary);">请允许浏览器使用摄像头,或直接上传微信/系统截图。</p>
<p style="color: var(--el-text-color-secondary);">{{ $t('vault.scan_camera_tip') }}</p>
</div>
<QrScanner @scan-success="handleScanSuccess" />
</div>
@@ -18,52 +18,54 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { parseOtpUri } from '@/shared/utils/totp'
import { useVaultStore } from '@/features/vault/store/vaultStore'
import { vaultService } from '@/features/vault/service/vaultService'
import { i18n } from '@/locales'
const QrScanner = defineAsyncComponent(() => import('@/shared/components/qrScanner.vue'))
const emit = defineEmits(['success'])
const vaultStore = useVaultStore()
const { t } = i18n.global
const handleScanSuccess = async (uri) => {
try {
const acc = parseOtpUri(uri)
if (!acc) {
ElMessage.error('二维码格式无效,请扫描有效的 OTP 二维码')
ElMessage.error(t('vault.invalid_qr_format'))
return
}
await ElMessageBox.confirm(
h('div', { style: 'text-align: left; background: var(--el-fill-color-light); padding: 16px; border-radius: 8px; border: 1px solid var(--el-border-color-lighter); margin-top: 10px;' }, [
h('div', { style: 'margin-bottom: 12px; display: flex; align-items: center;' }, [
h('span', { style: 'color: var(--el-text-color-secondary); width: 70px; flex-shrink: 0;' }, '服务方'),
h('span', { style: 'font-weight: 600; font-size: 15px; color: var(--el-text-color-primary); word-break: break-all;' }, acc.service || '未指定服务')
h('span', { style: 'color: var(--el-text-color-secondary); width: 70px; flex-shrink: 0;' }, t('vault.service_label')),
h('span', { style: 'font-weight: 600; font-size: 15px; color: var(--el-text-color-primary); word-break: break-all;' }, acc.service || t('vault.unknown_service'))
]),
h('div', { style: 'margin-bottom: 12px; display: flex; align-items: center;' }, [
h('span', { style: 'color: var(--el-text-color-secondary); width: 70px; flex-shrink: 0;' }, '账 号'),
h('span', { style: 'font-family: monospace; font-size: 14px; background: var(--el-fill-color-darker); padding: 4px 8px; border-radius: 6px; color: var(--el-color-primary); word-break: break-all;' }, acc.account || '未命名账号')
h('span', { style: 'color: var(--el-text-color-secondary); width: 70px; flex-shrink: 0;' }, t('vault.account_label')),
h('span', { style: 'font-family: monospace; font-size: 14px; background: var(--el-fill-color-darker); padding: 4px 8px; border-radius: 6px; color: var(--el-color-primary); word-break: break-all;' }, acc.account || t('vault.unnamed_account'))
]),
h('div', { style: 'display: flex; align-items: center;' }, [
h('span', { style: 'color: var(--el-text-color-secondary); width: 70px; flex-shrink: 0;' }, '参 数'),
h('span', { style: 'color: var(--el-text-color-secondary); width: 70px; flex-shrink: 0;' }, t('vault.param_label')),
h('div', { style: 'display: flex; gap: 8px; flex-wrap: wrap;' }, [
h('span', { style: 'background: var(--el-color-info-light-9); color: var(--el-color-info); border: 1px solid var(--el-color-info-light-7); padding: 2px 8px; border-radius: 4px; font-size: 12px;' }, acc.algorithm || 'SHA1'),
h('span', { style: 'background: var(--el-color-success-light-9); color: var(--el-color-success); border: 1px solid var(--el-color-success-light-7); padding: 2px 8px; border-radius: 4px; font-size: 12px;' }, `${acc.digits || 6}位`),
h('span', { style: 'background: var(--el-color-warning-light-9); color: var(--el-color-warning); border: 1px solid var(--el-color-warning-light-7); padding: 2px 8px; border-radius: 4px; font-size: 12px;' }, `${acc.period || 30}秒`)
h('span', { style: 'background: var(--el-color-success-light-9); color: var(--el-color-success); border: 1px solid var(--el-color-success-light-7); padding: 2px 8px; border-radius: 4px; font-size: 12px;' }, `${acc.digits || 6}${t('vault.digits_suffix')}`),
h('span', { style: 'background: var(--el-color-warning-light-9); color: var(--el-color-warning); border: 1px solid var(--el-color-warning-light-7); padding: 2px 8px; border-radius: 4px; font-size: 12px;' }, `${acc.period || 30}${t('vault.period_suffix')}`)
])
])
]),
'二维码解析成功,确认添加?',
t('vault.confirm_add_title'),
{
confirmButtonText: '确认添加',
cancelButtonText: '取消',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'success',
center: true
}
)
const addData = await vaultService.addFromUri(uri, '手机扫码')
const addData = await vaultService.addFromUri(uri, 'Scan')
if (addData.success) {
ElMessage.success('扫码账号添加成功!')
ElMessage.success(t('vault.add_success'))
vaultStore.markDirty() // 实际写入数据,标记缓存过期
emit('success')
}
@@ -9,35 +9,35 @@
>
<el-menu-item index="vault">
<el-icon><CopyDocument /></el-icon>
<template #title><span>我的账户</span></template>
<template #title><span>{{ $t('menu.vault') }}</span></template>
</el-menu-item>
<el-sub-menu index="add-vault">
<template #title>
<el-icon><Plus /></el-icon><span>添加账号</span>
<el-icon><Plus /></el-icon><span>{{ $t('menu.add') }}</span>
</template>
<el-menu-item index="add-vault-scan">
<el-icon><Camera /></el-icon><span>扫码添加</span>
<el-icon><Camera /></el-icon><span>{{ $t('vault.scan_qr') }}</span>
</el-menu-item>
<el-menu-item index="add-vault-manual">
<el-icon><Edit /></el-icon><span>手动输入</span>
<el-icon><Edit /></el-icon><span>{{ $t('vault.add_account') }}</span>
</el-menu-item>
</el-sub-menu>
<el-sub-menu index="migration">
<template #title>
<el-icon><Sort /></el-icon><span>数据迁移</span>
<el-icon><Sort /></el-icon><span>{{ $t('menu.migration') }}</span>
</template>
<el-menu-item index="migration-export"><el-icon><Upload /></el-icon><span>数据导出</span></el-menu-item>
<el-menu-item index="migration-import"><el-icon><Download /></el-icon><span>数据导入</span></el-menu-item>
<el-menu-item index="migration-export"><el-icon><Upload /></el-icon><span>{{ $t('migration.export') }}</span></el-menu-item>
<el-menu-item index="migration-import"><el-icon><Download /></el-icon><span>{{ $t('migration.import') }}</span></el-menu-item>
</el-sub-menu>
<el-menu-item index="backups">
<el-icon><Cloudy /></el-icon><template #title><span>云端备份</span></template>
<el-icon><Cloudy /></el-icon><template #title><span>{{ $t('menu.backup') }}</span></template>
</el-menu-item>
<el-menu-item index="tools">
<el-icon><Tools /></el-icon><template #title><span>实用工具</span></template>
<el-icon><Tools /></el-icon><template #title><span>{{ $t('menu.tools') }}</span></template>
</el-menu-item>
</el-menu>
@@ -45,7 +45,8 @@
<div class="sidebar-footer" :class="{ 'is-collapsed': isCollapse }">
<el-button circle :icon="isCollapse ? Expand : Fold" @click="toggleCollapse" :title="isCollapse ? '展开' : '折叠'" />
<el-button circle :icon="themeStore.isDark ? Sunny : Moon" @click="themeStore.toggleTheme" />
<el-button circle @click="handleLogout" title="退出登录">
<el-button circle size="medium" :icon="iconLocales" :title="$i18n.locale === 'zh-CN' ? 'English' : '切换语言'" @click="toggleLanguage" />
<el-button circle @click="handleLogout" :title="$t('menu.logout')">
<el-icon><SwitchButton /></el-icon>
</el-button>
</div>
@@ -68,37 +69,38 @@
>
<el-menu-item index="vault">
<el-icon><CopyDocument /></el-icon>
<span>我的账户</span>
<span>{{ $t('menu.vault') }}</span>
</el-menu-item>
<el-sub-menu index="add-vault">
<template #title>
<el-icon><Plus /></el-icon><span>添加账号</span>
<el-icon><Plus /></el-icon><span>{{ $t('menu.add') }}</span>
</template>
<el-menu-item index="add-vault-scan">
<el-icon><Camera /></el-icon><span>扫码添加</span>
<el-icon><Camera /></el-icon><span>{{ $t('vault.scan_qr') }}</span>
</el-menu-item>
<el-menu-item index="add-vault-manual">
<el-icon><Edit /></el-icon><span>手动输入</span>
<el-icon><Edit /></el-icon><span>{{ $t('vault.add_account') }}</span>
</el-menu-item>
</el-sub-menu>
<el-sub-menu index="migration">
<template #title>
<el-icon><Sort /></el-icon><span>数据迁移</span>
<el-icon><Sort /></el-icon><span>{{ $t('menu.migration') }}</span>
</template>
<el-menu-item index="migration-export"><el-icon><Upload /></el-icon><span>数据导出</span></el-menu-item>
<el-menu-item index="migration-import"><el-icon><Download /></el-icon><span>数据导入</span></el-menu-item>
<el-menu-item index="migration-export"><el-icon><Upload /></el-icon><span>{{ $t('migration.export') }}</span></el-menu-item>
<el-menu-item index="migration-import"><el-icon><Download /></el-icon><span>{{ $t('migration.import') }}</span></el-menu-item>
</el-sub-menu>
<el-menu-item index="backups">
<el-icon><Cloudy /></el-icon><span>云端备份</span>
<el-icon><Cloudy /></el-icon><span>{{ $t('menu.backup') }}</span>
</el-menu-item>
<el-menu-item index="tools">
<el-icon><Tools /></el-icon><span>实用工具</span>
<el-icon><Tools /></el-icon><span>{{ $t('menu.tools') }}</span>
</el-menu-item>
</el-menu>
<div class="sidebar-footer" style="display: flex; gap: 15px; justify-content: center; padding: 20px 0;">
<el-button circle size="medium" :icon="themeStore.isDark ? Sunny : Moon" @click="themeStore.toggleTheme" />
<el-button circle size="medium" @click="handleLogout" title="退出登录">
<el-button circle size="medium" :icon="iconLocales" :title="$i18n.locale === 'zh-CN' ? 'English' : '切换语言'" @click="toggleLanguage" />
<el-button circle size="medium" @click="handleLogout" :title="$t('menu.logout')">
<el-icon><SwitchButton /></el-icon>
</el-button>
</div>
@@ -114,9 +116,14 @@ import {
CopyDocument, Plus, Camera, Edit, Sort, Upload, Download,
Cloudy, Sunny, Moon, SwitchButton, Fold, Expand, Tools
} from '@element-plus/icons-vue'
import iconLocales from '@/shared/components/icons/iconLocales.vue'
import { useLayoutStore } from '@/shared/stores/layoutStore'
import { useThemeStore } from '@/shared/stores/themeStore'
import { useAuthUserStore } from '@/features/auth/store/authUserStore'
import { useI18n } from 'vue-i18n'
import { setLanguage } from '@/locales'
const { t, locale } = useI18n()
const props = defineProps({
activeTab: {
@@ -139,6 +146,11 @@ const toggleCollapse = () => {
localStorage.setItem('sidebar_collapse', isCollapse.value)
}
const toggleLanguage = () => {
const targetLang = locale.value === 'zh-CN' ? 'en-US' : 'zh-CN'
setLanguage(targetLang)
}
// 移动端:关闭抽屉后再通知父组件切换 Tab
const handleMobileSelect = (index) => {
layoutStore.showMobileMenu = false
@@ -151,6 +163,6 @@ const handleLogout = async () => {
}
await authUserStore.logout()
router.replace('/login')
ElMessage.success('已安全退出')
ElMessage.success(t('auth.logout_success'))
}
</script>
@@ -5,7 +5,7 @@
<div style="display: flex; align-items: center; gap: 10px; flex: 1;">
<el-input
v-model="searchQuery"
placeholder="搜索服务名称、账号或分类..."
:placeholder="$t('common.search_placeholder')"
clearable
style="max-width: 400px;"
>
@@ -18,13 +18,13 @@
<div class="batch-actions" style="display: flex; align-items: center; gap: 10px;">
<template v-if="selectedIds.length > 0">
<span class="batch-text">已选 {{ selectedIds.length }} 项</span>
<span class="batch-text">{{ $t('common.selected_items', { count: selectedIds.length }) }}</span>
<el-button type="danger" plain @click="handleBulkDelete" :loading="isBulkDeleting">
<el-icon><Delete /></el-icon> 删除
<el-icon><Delete /></el-icon> {{ $t('common.delete') }}
</el-button>
<el-button @click="selectedIds = []" plain>取消</el-button>
<el-button @click="selectedIds = []" plain>{{ $t('common.cancel') }}</el-button>
</template>
<el-button v-else @click="selectAllLoaded" plain>全选已加载</el-button>
<el-button v-else @click="selectAllLoaded" plain>{{ $t('common.select_all_loaded') }}</el-button>
</div>
</div>
@@ -32,13 +32,13 @@
<div v-if="(isInitializing || isLoading || isFetching) && vault.length === 0" class="loading-state" style="display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 400px; color: var(--el-text-color-secondary);">
<el-icon class="is-loading" :size="48" style="margin-bottom: 20px; color: var(--el-color-primary);"><Loading /></el-icon>
<p style="font-size: 16px; letter-spacing: 1px;">数据获取中, 请稍候...</p>
<p style="font-size: 16px; letter-spacing: 1px;">{{ $t('common.loading_data') }}</p>
</div>
<!-- 2. 空状态 (明确加载完毕 + 数据真正为空 + 无搜索) -->
<div v-else-if="!isLoading && !isFetching && vault.length === 0 && !searchQuery" class="empty-state">
<el-empty description="空空如也,快去添加你的第一个 2FA 账号吧!">
<el-button type="primary" @click="$emit('switch-tab', 'add-vault-scan')">去添加账号</el-button>
<el-empty :description="$t('common.empty_vault')">
<el-button type="primary" @click="$emit('switch-tab', 'add-vault-scan')">{{ $t('common.go_add_vault') }}</el-button>
</el-empty>
</div>
@@ -65,13 +65,13 @@
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="qr">
<el-icon><Picture /></el-icon> 账号导出
<el-icon><Picture /></el-icon> {{ $t('common.export_account') }}
</el-dropdown-item>
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon> 编辑账号
<el-icon><Edit /></el-icon> {{ $t('common.edit') }}
</el-dropdown-item>
<el-dropdown-item command="delete" style="color: #F56C6C;">
<el-icon><Delete /></el-icon> 删除账号
<el-icon><Delete /></el-icon> {{ $t('common.delete') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
@@ -106,39 +106,39 @@
</el-row>
<div v-if="isFetchingNextPage" style="text-align: center; padding: 20px; color: var(--el-text-color-secondary);">
<el-icon class="is-loading"><Loading /></el-icon> 正在加载更多...
<el-icon class="is-loading"><Loading /></el-icon> {{ $t('common.loading_more') }}
</div>
<div v-if="!hasNextPage && vault.length > 0" style="text-align: center; padding: 20px; color: var(--el-text-color-secondary); font-size: 12px;">
- 到底了,没有更多账号了 -
{{ $t('common.no_more_accounts') }}
</div>
<el-empty v-if="!isLoading && vault.length === 0 && searchQuery" description="没有找到匹配的账号" />
<el-empty v-if="!isLoading && vault.length === 0 && searchQuery" :description="$t('common.no_matching_accounts')" />
</div>
</div>
<!-- 编辑弹窗 -->
<el-dialog v-model="showEditDialog" title="✏️ 编辑账号" :width="layoutStore.isMobile ? '90%' : '400px'" destroy-on-close>
<el-dialog v-model="showEditDialog" :title="$t('common.edit_account')" :width="layoutStore.isMobile ? '90%' : '400px'" destroy-on-close>
<el-form :model="editVaultData" label-position="top">
<el-form-item label="服务名称 (如 Google, GitHub)">
<el-form-item :label="$t('common.service_name')">
<el-input v-model="editVaultData.service" />
</el-form-item>
<el-form-item label="账号标识 (如 邮箱地址)">
<el-form-item :label="$t('common.account_identifier')">
<el-input v-model="editVaultData.account" />
</el-form-item>
<el-form-item label="分类 (可选)">
<el-form-item :label="$t('common.category_optional')">
<el-input v-model="editVaultData.category" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="showEditDialog = false">取消</el-button>
<el-button type="primary" :loading="isEditing" @click="submitEditVault">保存修改</el-button>
<el-button @click="showEditDialog = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" :loading="isEditing" @click="submitEditVault">{{ $t('common.save') }}</el-button>
</span>
</template>
</el-dialog>
<!-- 账号导出弹窗 -->
<el-dialog v-model="showQrDialog" title="账号导出" :width="layoutStore.isMobile ? '90%' : '350px'" center align-center destroy-on-close @closed="showSecret = false">
<el-dialog v-model="showQrDialog" :title="$t('common.export_account')" :width="layoutStore.isMobile ? '90%' : '350px'" center align-center destroy-on-close @closed="showSecret = false">
<div class="qr-container" v-if="currentQrItem">
<div class="qr-info">
<h3 class="qr-service">{{ currentQrItem.service }}</h3>
@@ -149,20 +149,20 @@
<img :src="qrCodeUrl" class="qr-code-img" />
</div>
<p class="qr-tip">使用任意 2FA 应用扫描二维码即可添加此账户</p>
<p class="qr-tip">{{ $t('vault.export_qr_tip') }}</p>
<div class="secret-section">
<div class="secret-box">
<div class="secret-text">{{ showSecret ? formatSecret(currentQrItem.secret) : '•••• •••• •••• ••••' }}</div>
<div class="secret-actions">
<el-icon class="action-icon" @click="showSecret = !showSecret" :title="showSecret ? '隐藏' : '显示'"><View v-if="!showSecret" /><Hide v-else /></el-icon>
<el-icon class="action-icon" @click="copySecret" title="复制密钥"><CopyDocument /></el-icon>
<el-icon class="action-icon" @click="showSecret = !showSecret" :title="showSecret ? $t('vault.hide_secret') : $t('vault.show_secret')"><View v-if="!showSecret" /><Hide v-else /></el-icon>
<el-icon class="action-icon" @click="copySecret" :title="$t('vault.copy_secret')"><CopyDocument /></el-icon>
</div>
</div>
</div>
<div class="uri-link-wrapper">
<el-button link type="info" size="small" @click="copyOtpUrl">复制原始 otpauth 链接</el-button>
<el-button link type="info" size="small" @click="copyOtpUrl">{{ $t('vault.copy_otp_url') }}</el-button>
</div>
</div>
</el-dialog>
@@ -4,6 +4,7 @@ import QRCode from 'qrcode'
import { useVaultStore } from '@/features/vault/store/vaultStore'
import { vaultService } from '@/features/vault/service/vaultService'
import { copyToClipboard } from '@/shared/utils/common'
import { i18n } from '@/locales'
/**
* 管理账号的增删改查与二维码展示等弹窗行为
@@ -13,6 +14,7 @@ import { copyToClipboard } from '@/shared/utils/common'
*/
export function useVaultActions(fetchVault, vault) {
const vaultStore = useVaultStore()
const { t } = i18n.global
// --- 批量操作 ---
const selectedIds = ref([])
@@ -34,13 +36,13 @@ export function useVaultActions(fetchVault, vault) {
if (!selectedIds.value.length) return
try {
await ElMessageBox.confirm(
`确定要删除选中的 ${selectedIds.value.length} 个账号吗?此操作不可恢复。`,
'批量删除',
{ type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' }
t('vault.delete_batch_confirm', { count: selectedIds.value.length }),
t('common.delete'),
{ type: 'warning', confirmButtonText: t('common.delete'), cancelButtonText: t('common.cancel') }
)
isBulkDeleting.value = true
await vaultService.batchDelete(selectedIds.value)
ElMessage.success(`已成功删除 ${selectedIds.value.length} 个账号`)
ElMessage.success(t('vault.delete_batch_success', { count: selectedIds.value.length }))
selectedIds.value = []
vaultStore.markDirty()
fetchVault()
@@ -70,9 +72,9 @@ export function useVaultActions(fetchVault, vault) {
// --- 复制 TOTP 代码 ---
const copyCode = async (vaultItem) => {
if (!vaultItem.currentCode || vaultItem.currentCode === '------') {
return ElMessage.warning('验证码尚未生成,请稍候')
return ElMessage.warning(t('vault.not_generated_yet'))
}
await copyToClipboard(vaultItem.currentCode, '验证码已复制')
await copyToClipboard(vaultItem.currentCode, t('vault.copy_success'))
}
// --- 编辑账号 ---
@@ -92,7 +94,7 @@ export function useVaultActions(fetchVault, vault) {
const { id, ...updateData } = editVaultData.value
const res = await vaultService.updateAccount(id, updateData)
if (res.success) {
ElMessage.success('账号信息已更新')
ElMessage.success(t('vault.update_success'))
showEditDialog.value = false
vaultStore.markDirty()
fetchVault()
@@ -107,13 +109,13 @@ export function useVaultActions(fetchVault, vault) {
// --- 删除单个账号 ---
const deleteVault = async (vaultItem) => {
try {
await ElMessageBox.confirm(`确定要删除 ${vaultItem.service} 吗?`, '删除确认', {
await ElMessageBox.confirm(t('vault.delete_confirm', { service: vaultItem.service }), t('common.delete'), {
type: 'warning',
confirmButtonText: '确认删除',
cancelButtonText: '取消'
confirmButtonText: t('common.delete'),
cancelButtonText: t('common.cancel')
})
await vaultService.deleteAccount(vaultItem.id)
ElMessage.success('账号已删除')
ElMessage.success(t('vault.delete_success'))
vaultStore.markDirty()
fetchVault()
} catch (e) {
@@ -136,7 +138,7 @@ export function useVaultActions(fetchVault, vault) {
const copySecret = () => {
if (currentQrItem.value) {
copyToClipboard(currentQrItem.value.secret)
ElMessage.success('密钥已复制到剪贴板')
ElMessage.success(t('vault.copy_success'))
}
}
@@ -146,7 +148,7 @@ export function useVaultActions(fetchVault, vault) {
const algorithm = (item.algorithm || 'SHA1').replace(/-/g, '').toUpperCase()
const uri = `otpauth://totp/${encodeURIComponent(item.service)}:${encodeURIComponent(item.account)}?secret=${encodeURIComponent(item.secret)}&issuer=${encodeURIComponent(item.service)}&algorithm=${algorithm}&digits=${item.digits || 6}&period=${item.period || 30}`
copyToClipboard(uri)
ElMessage.success('OTP URI 已复制')
ElMessage.success(t('vault.copy_success'))
}
}
+447
View File
@@ -0,0 +1,447 @@
{
"common": {
"confirm": "Confirm",
"cancel": "Cancel",
"success": "Success",
"error": "Error",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
"add": "Add",
"test_connection": "Test Connection",
"close": "Close",
"back": "Back",
"copy": "Copy",
"update_available": "Update Available",
"pwa_update_ready": "🎉 New Cloudflare Worker deployment is ready!",
"refresh_now": "Refresh Now",
"offline_mode": "Network disconnected, the application is currently in offline cache mode",
"search_placeholder": "Search service, account, or category...",
"selected_items": "Selected {count} items",
"select_all_loaded": "Select all loaded",
"loading_data": "Fetching data, please wait...",
"empty_vault": "Empty here, go add your first 2FA account!",
"go_add_vault": "Go to Add Account",
"export_account": "Export",
"loading_more": "Loading more...",
"no_more_accounts": "- No more accounts -",
"no_matching_accounts": "No matching accounts found",
"edit_account": "✏️ Edit Account",
"service_name": "Service Name (e.g., Google, GitHub)",
"account_identifier": "Account Identifier (e.g., Email)",
"category_optional": "Category (Optional)",
"data_security": "Data Security",
"warning": "Warning"
},
"menu": {
"vault": "Vault List",
"add": "Add Vault",
"backup": "Cloud Backup",
"migration": "Migration",
"tools": "Toolbox",
"settings": "Settings",
"logout": "Sign Out"
},
"vault": {
"add_account": "Manual Add",
"scan_qr": "Scan QR Code",
"service": "Service",
"account": "Account",
"secret": "Secret",
"category": "Category",
"delete_confirm": "Are you sure you want to delete {service}?",
"delete_success": "Account deleted",
"delete_batch_confirm": "Are you sure you want to delete {count} selected account(s)?",
"delete_batch_success": "Successfully deleted {count} account(s)",
"copy_success": "Copied to clipboard",
"copy_failed": "Copy failed. Your browser might disable clipboard operations.",
"qr_invalid": "Invalid QR code format. Please scan a valid OTP QR code.",
"add_success": "Account added successfully",
"update_success": "Account details updated",
"not_generated_yet": "Token has not been generated yet, please wait.",
"duplicate_confirm": "An account might already exist. Continue adding?",
"generate_fail": "Invalid OTP URI",
"scan_camera_tip": "Please allow camera access, or upload a screenshot directly.",
"invalid_qr_format": "Invalid QR code format, please scan a valid OTP QR code.",
"service_label": "Service",
"unknown_service": "Unknown Service",
"account_label": "Account",
"unnamed_account": "Unnamed Account",
"param_label": "Params",
"digits_suffix": " digits",
"period_suffix": "s",
"confirm_add_title": "QR parsed successfully, confirm add?",
"manual_title": "⌨️ Manual Input",
"input_service_placeholder": "Enter service name",
"input_account_placeholder": "Enter account or email",
"input_secret_label": "Secret Key (Base32)",
"input_secret_placeholder": "Enter a secret key of 16+ characters",
"generate_random_secret": "Randomly generate secret",
"digits_label": "Digits",
"digits_6": "6 Digits",
"digits_8": "8 Digits",
"period_label": "Period",
"period_30s": "30 Seconds",
"period_60s": "60 Seconds",
"algorithm_label": "Algorithm",
"algo_sha1": "SHA1 (Default)",
"input_category_placeholder": "e.g., Work, Personal",
"confirm_add_btn": "Confirm Add",
"require_secret": "Please enter a secret key",
"secret_min_length": "Secret length is usually at least 16 chars",
"secret_invalid_char": "Contains invalid characters (A-Z, 2-7 only)",
"require_service": "Please enter service name",
"require_account": "Please enter account identifier",
"export_qr_tip": "Scan the QR code with any 2FA app to add this account",
"hide_secret": "Hide",
"show_secret": "Show",
"copy_secret": "Copy Secret",
"copy_otp_url": "Copy raw otpauth URL"
},
"backup": {
"providers": "Backup Providers",
"add_provider": "Add Provider",
"cloud": "Cloud Backup",
"restore": "Restore",
"type": "Provider Type",
"name": "Config Name",
"config": "Settings",
"auto_backup": "Auto Backup",
"password_required": "Auto backup requires a password (min 12 chars)",
"test_success": "Connection successful",
"test_failed": "Connection failed",
"save_success": "Cloud backup config saved",
"delete_provider_confirm": "Are you sure you want to delete this backup source?",
"backup_success": "Backup completed successfully to {provider}",
"restore_success": "Restored successfully. {count} new accounts added.",
"no_new_accounts": "All restored accounts already exist, no new items added.",
"management": "Source Management",
"add_source": "Add",
"fetching_sources": "Fetching backup sources, please wait...",
"auto_backup_on": "Auto Backup Enabled",
"last_backup": "Last Backup: ",
"never_backed_up": "Never",
"backup_now": "Backup Now",
"restore_data": "Restore Data",
"empty_source": "No backup sources, click top right to add",
"edit_source": "Edit Backup Source",
"type_label": "Type",
"type_s3": "S3 Object Storage",
"name_label": "Name (Alias)",
"name_placeholder": "e.g., OpenList",
"webdav_url": "WebDAV URL",
"username": "Username",
"password": "Password",
"modify": "Modify",
"save_dir": "Save Directory",
"s3_endpoint": "Endpoint (API URL)",
"s3_bucket": "Bucket Name",
"s3_region": "Region",
"s3_path_prefix": "Path Prefix (Optional)",
"auto_backup_config": "Auto Backup Configuration",
"switch_on": "ON",
"switch_off": "OFF",
"encrypt_password": "Encryption Password",
"keep_old_pwd": "Keep Original Password",
"set_new_pwd": "Set New Password",
"input_encrypt_pwd": "Enter encryption password",
"continue_use_old_pwd": "The system will continue to use the previous auto-backup password.",
"retain_count_label": "Retain Recent Backups",
"retain_zero_tip": "0 means no limits or cleanup for historical backups.",
"test_connection": "Test Connection",
"save": "Save",
"encrypted_backup": "Encrypted Backup",
"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)",
"start_backup": "Start Backup",
"select_restore_file": "Select Backup to Restore",
"filename": "Filename",
"size": "Size",
"time": "Time",
"action": "Action",
"decrypt_restore": "Decrypt & Restore",
"restore_warning": "Restoring will overwrite all current data!",
"input_restore_pwd": "Enter encryption password used for backup",
"confirm_restore": "Confirm Overwrite",
"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.",
"require_name": "Please enter a name",
"require_webdav_url": "Please enter WebDAV URL",
"require_username": "Please enter username",
"require_password": "Please enter password",
"require_endpoint": "Please enter Endpoint",
"require_bucket": "Please enter Bucket",
"require_access_key": "Please enter Access Key ID",
"require_secret_key": "Please enter Secret Access Key",
"require_auto_backup_pwd": "Please enter auto backup password",
"config_missing": "Missing configurations in backup source",
"create_success": "Created successfully",
"update_success": "Updated successfully",
"delete_confirm": "Delete Confirmation",
"delete_warning": "This will permanently delete the backup profile (it will NOT affect backed up data in the cloud). Continue?",
"cancel_delete": "Deletion cancelled",
"delete_success": "Deleted successfully",
"testing_connection": "Testing connection...",
"fetching_configs": "Fetching config list...",
"saving_config": "Saving config...",
"password_min_length": "Password must be at least 12 characters",
"password_no_space": "Password cannot contain spaces",
"password_not_all_number": "Password cannot be all numbers",
"backup_finish_title": "Cloud Backup Finished",
"backup_success_msg": "Successfully backed up data",
"fetch_backup_fail": "Failed to fetch backup file",
"fetch_list_fail": "Failed to fetch backup list",
"restore_finish_title": "Data Restoration Finished",
"processed_total": "Processed",
"backup_files_count": "backup files",
"base_accounts_count": "base accounts",
"import_success_count": "Successfully imported",
"new_accounts": "new accounts!",
"skipped_duplicates": "Automatically skipped",
"existing_accounts": "existing accounts.",
"restore_fail": "Restoration failed"
},
"migration": {
"export": "Export Vault",
"import": "Import Data",
"export_format": "Export Format",
"export_password": "Encryption Password (min 12 chars)",
"export_password_confirm": "Confirm Password",
"password_mismatch": "Passwords do not match!",
"password_weak": "Password too weak. It must be at least 12 characters.",
"select_account": "Please select at least one account.",
"export_success": "Export completed.",
"export_failed": "Export failed. Please try again later.",
"file_too_large": "The file {name} is too large. Limit is 10MB.",
"import_password": "Decryption Password",
"password_wrong": "Decryption failed: Incorrect password or unsupported standard.",
"no_valid_data": "No valid account data found to import.",
"import_success": "Import successful",
"task_build_pf": "Building Data: PhoneFactor Dataset",
"err_pf_parse": "[PhoneFactor] Parsing failed: {detail}",
"task_prepare": "Preparing to parse: {name}",
"task_scan_qr": "Extracting QR code from image: {name}",
"err_scan_qr": "[{name}] Image parsing failed: {msg}",
"err_read_fail": "[{name}] Read failed",
"err_unrecognized": "[{name}] Unrecognized file format.",
"task_build_file": "Building Data: {name}",
"err_parse_fail": "[{name}] Parsing failed: {detail}",
"err_read_abnormal": "[{name}] Read error",
"task_decrypting": "Decrypting data...",
"err_user_cancel": "[{name}] User cancelled password input",
"import_anomaly": "Import Error",
"task_saving": "Merging and saving... ({count} accounts)",
"msg_total_files": "<div>Processed <b>{count}</b> files in total.</div>",
"msg_success_accounts": "<div style=\"color:var(--el-color-success)\">🎉 Successfully imported <b>{count}</b> new accounts!</div>",
"msg_no_new_accounts": "<div style=\"color:var(--el-color-warning)\">⚠️ All accounts already exist, no new accounts added.</div>",
"msg_duplicates_skipped": "<div style=\"color:var(--el-text-color-secondary)\">ℹ️ Skipped <b>{count}</b> duplicated accounts.</div>",
"msg_error_summary": "<div style=\"color:var(--el-color-danger); margin-top: 5px;\">❌ <b>Error Summary:</b><br>{errors}</div>",
"batch_import_finished": "Batch Import Finished",
"encrypted_file": "Encrypted file",
"export_desc": "Choose your export format. Plaintext exports have security risks.",
"system_backup": "System Backup",
"encrypted_json": "Encrypted Backup (.json)",
"plaintext_json": "Plaintext Backup (.json)",
"mobile_app": "Mobile 2FA App",
"migrate_ga": "Migrate to Google Auth",
"generic_format": "Generic Formats",
"otpauth_txt": "OTPAuth URI (.txt)",
"spreadsheet_csv": "Spreadsheet (.csv)",
"html_page": "HTML Webpage (.html)",
"export_pwd_title": "Set Export Password",
"pwd_placeholder_strong": "Enter strong password",
"pwd_placeholder_again": "Re-enter password",
"start_encrypt_dl": "Start Encrypting & Download",
"warning_title": "⚠️ Security Warning",
"warning_alert_title": "Risk Warning",
"warning_desc": "You are exporting unencrypted plaintext data. Anyone with access to this file can access your account codes!",
"confirm_export": "Confirm Export",
"ga_title": "Migrate to Google Authenticator",
"ga_desc_1": "Open Google Authenticator on your phone and scan the QR code",
"ga_desc_multiple": "(Too many accounts, generated in batches. Please scan sequentially)",
"btn_prev": "Prev",
"btn_next": "Next",
"finish": "Done",
"select_account_title": "Select Accounts to Export",
"search_account": "Search service or account...",
"select_all_accounts": "Select all accounts",
"select_all_search": "Select {count} searched accounts",
"selected_count": "Selected: {selected} / {total}",
"no_matching": "No matching accounts",
"migrate_btn": "Migrate {count} accounts",
"center_title": "Data Import",
"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",
"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",
"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_2fas": "2FAS Auth encrypted backup detected",
"detect_system": "System encrypted backup detected",
"input_decrypt_pwd_label": "Please enter the decryption password:",
"input_decrypt_pwd_placeholder": "Enter the password set during export",
"confirm_decrypt_import": "Confirm Decrypt & Import",
"system_backup_format": "System Backup",
"encrypted_backup_json": "Encrypted Backup (.json)",
"plaintext_backup_json": "Plaintext Backup (.json)",
"mobile_app_format": "Mobile 2FA Apps",
"generic_json": "Generic Format (.json)",
"fetching_accounts": "Fetching account data...",
"no_accounts_to_export": "No accounts to export",
"encrypting": "Encrypting...",
"generating_file": "Generating export file...",
"generating_qr": "Generating QR code...",
"reading_backup": "Reading backup file...",
"uploading_processing": "Uploading and processing...",
"unsupported_format": "Unrecognized or unsupported file format.",
"unsupported_decrypt": "Decrypting from",
"encrypted_backup_tail": "is not supported currently."
},
"tools": {
"qr_parser": "QR Code Parser",
"password_generator": "Password Gen",
"time_sync": "Time Sync",
"totp_sandbox": "TOTP Sandbox",
"parse_success": "Parsed successfully",
"invalid_uri": "Invalid OTP URI",
"secret_required": "Secret is required.",
"service_required": "Please provide Service and Account name.",
"save_to_vault": "Save to Vault",
"sync_success": "Sync complete. Offset: {offset}ms",
"sync_failed": "Failed to connect to the server.",
"select_chars": "Please select at least one character type.",
"title": "Utility Toolbox",
"totp_secret_title": "TOTP Secret Tool",
"totp_secret_desc": "Generate and convert Base32 secrets",
"password_gen_title": "Password Generator",
"password_gen_desc": "Generate strong random passwords",
"time_sync_title": "Time Sync Tool",
"time_sync_desc": "Check time drift between local and server",
"qr_parser_title": "QR Code Parser",
"qr_parser_desc": "Extract OTP links from QR code images",
"length": "Length",
"regenerate": "Regenerate",
"select_one_char_type": "Select at least one character type",
"password_copied": "Password copied",
"qr_result": "Parsed Result",
"qr_parsed": "QR parsed successfully",
"local_time": "Local Device Time",
"server_time": "Server Time (Estimated)",
"time_offset": "Time Offset",
"network_latency": "Network Latency",
"check_now": "Check Now",
"sync_completed": "Sync complete, offset {offset}ms",
"secret_config": "Secret Configuration",
"basic_info": "Basic Info",
"preview": "Result Preview",
"refresh_after": "to refresh",
"advanced_settings": "Advanced Settings",
"reset_time": "Reset",
"prev_period": "Prev Period",
"next_period": "Next Period",
"scanner_camera": "Camera Scan",
"scanner_image": "Image Parse",
"camera_click_to_open": "Click to open camera",
"camera_open": "Open Camera",
"camera_stop": "Stop Camera",
"image_placeholder": "Click or drag QR image here",
"camera_unsupported": "Camera not supported",
"camera_access_denied": "Camera access denied",
"qr_not_recognized": "No valid QR code recognized",
"sync_normal": "Time Sync Normal",
"sync_normal_desc": "Local time is accurate.",
"sync_warning": "Slight Drift",
"sync_warning_desc": "Minor time drift. You can still use 2FA.",
"sync_error": "Significant Time Drift",
"sync_error_desc": "Drift is too large. 2FA codes will be invalid.",
"secret_empty": "Secret cannot be empty",
"fill_info": "Please fill service and account details",
"totp_hex": "Hexadecimal",
"totp_config_preview": "Configure parameters to generate a preview",
"totp_algorithm": "Algorithm",
"totp_algo_sha1_default": "SHA-1 (Default)",
"totp_digits": "Digits",
"totp_period": "Period",
"totp_scan_qr_title": "Scan QR Code"
},
"auth": {
"subtitle": "A Secure 2FA Management Tool",
"login": "Login",
"logout_success": "Signed out securely.",
"oauth_failed": "Failed to retrieve OAuth link. Provider not configured.",
"oauth_network_error": "Network error while getting OAuth status.",
"login_with": "Login with {provider}",
"privacy_title": "Privacy & Security Note",
"privacy_desc": "The system validates identity based on the OAuth 2.0 protocol and will never capture, log, or transmit your password.",
"secure_login_in_progress": "Secure login in progress",
"secure_login_sub": "Please wait, exchanging security credentials with {provider}...",
"auth_failed_title": "Authorization Failed",
"back_to_login": "Back to Login",
"default_provider": "Identity Provider",
"oauth_declined": "You declined the authorization request from {provider}",
"telegram_missing_hash": "Telegram login missing signature parameter",
"missing_auth_params": "URL missing required authorization credential parameters",
"state_mismatch": "Security Warning: State validation failed, request may be tampered with",
"login_rejected": "Login validation rejected by server",
"network_abnormal": "Network request abnormal, please try again later"
},
"camera": {
"not_supported": "Your browser does not support camera access.",
"no_access": "Cannot access camera. Please review your permission settings.",
"no_qr_found": "No valid QR code was detected in the image."
},
"api_errors": {
"offline": "Offline mode: action not allowed.",
"session_expired": "Session expired, please login again.",
"unauthorized": "Unauthorized access.",
"forbidden": "Forbidden.",
"server_error": "Server error.",
"request_failed": "Request failed",
"account_not_found": "Account not found",
"account_exists": "Account already exists",
"invalid_secret_format": "Invalid secret format (must be Base32, at least 16 chars)",
"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",
"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)",
"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",
"csrf_mismatch": "Request rejected: CSRF token mismatch",
"token_expired": "Unauthorized: Token expired or invalid",
"oauth_state_invalid": "Invalid or missing OAuth state parameter. Security check failed.",
"oauth_code_missing": "Missing OAuth code parameter",
"not_whitelisted": "Access whitelist not configured. Login denied.",
"unauthorized_user": "Unauthorized user, login denied!",
"connection_failed": "Connection failed",
"backup_failed": "Backup failed",
"download_failed": "Download failed",
"oauth_config_incomplete": "OAuth configuration incomplete",
"oauth_api_error": "OAuth API error",
"oauth_token_exchange_failed": "OAuth token exchange failed",
"telegram_missing_bot_name": "Telegram Bot Name is not configured",
"telegram_missing_query": "Telegram provider requires full query parameters, not just code",
"telegram_missing_bot_token": "Telegram Bot Token is not configured",
"telegram_missing_hash": "Missing hash signature",
"telegram_signature_failed": "Telegram signature verification failed",
"telegram_login_expired": "Telegram login data has expired",
"unsupported_provider": "Unsupported provider"
}
}
+32
View File
@@ -0,0 +1,32 @@
import { createI18n } from 'vue-i18n'
import zhCN from './zh-CN.json'
import enUS from './en-US.json'
// 1. 获取浏览器默认语言
const getBrowserLanguage = () => {
const navLang = navigator.language || navigator.userLanguage
if (navLang.startsWith('zh')) return 'zh-CN'
return 'en-US'
}
// 2. 从 localStorage 读取,优先缓存,否则取系统语言
const currentLocale = localStorage.getItem('app_locale') || getBrowserLanguage()
// 3. 配置 i18n
export const i18n = createI18n({
legacy: false, // 使用 Vue 3 Composition API 模式
locale: currentLocale,
fallbackLocale: 'en-US',
messages: {
'zh-CN': zhCN,
'en-US': enUS
}
})
// 暴露一个切换语言的快捷函数
export const setLanguage = (lang) => {
i18n.global.locale.value = lang
localStorage.setItem('app_locale', lang)
// 修改 HTML 语言属性
document.documentElement.lang = lang === 'zh-CN' ? 'zh-CN' : 'en'
}
+447
View File
@@ -0,0 +1,447 @@
{
"common": {
"confirm": "确定",
"cancel": "取消",
"success": "操作成功",
"error": "操作失败",
"save": "保存",
"delete": "删除",
"edit": "编辑",
"add": "添加",
"test_connection": "测试连接",
"close": "关闭",
"back": "返回",
"copy": "复制",
"update_available": "更新可用",
"pwa_update_ready": "🎉 Cloudflare Worker 新部署已就绪!",
"refresh_now": "立即刷新",
"offline_mode": "网络已断开,当前应用正处于离线缓存模式",
"search_placeholder": "搜索服务名称、账号或分类...",
"selected_items": "已选 {count} 项",
"select_all_loaded": "全选已加载",
"loading_data": "数据获取中, 请稍候...",
"empty_vault": "空空如也,快去添加你的第一个 2FA 账号吧!",
"go_add_vault": "去添加账号",
"export_account": "账号导出",
"loading_more": "正在加载更多...",
"no_more_accounts": "- 到底了,没有更多账号了 -",
"no_matching_accounts": "没有找到匹配的账号",
"edit_account": "✏️ 编辑账号",
"service_name": "服务名称 (如 Google, GitHub)",
"account_identifier": "账号标识 (如 邮箱地址)",
"category_optional": "分类 (可选)",
"data_security": "数据安全",
"warning": "警告"
},
"menu": {
"vault": "我的账号",
"add": "添加账号",
"backup": "云端备份",
"migration": "数据迁移",
"tools": "实用工具",
"settings": "系统设置",
"logout": "退出登录"
},
"vault": {
"add_account": "手动添加",
"scan_qr": "扫码添加",
"service": "服务商",
"account": "账号",
"secret": "密钥",
"category": "分类",
"delete_confirm": "确定要删除 {service} 吗?",
"delete_success": "账号已删除",
"delete_batch_confirm": "确定要删除选中的 {count} 个账号吗?",
"delete_batch_success": "已成功删除 {count} 个账号",
"copy_success": "已复制到剪贴板",
"copy_failed": "复制失败,你的浏览器可能禁用或不支持剪贴板操作",
"qr_invalid": "二维码格式无效,请扫描有效的 OTP 二维码",
"add_success": "账号添加成功",
"update_success": "账号信息已更新",
"not_generated_yet": "验证码尚未生成,请稍候",
"duplicate_confirm": "账号可能已存在,是否继续添加?",
"generate_fail": "无效的 OTP URI",
"scan_camera_tip": "请允许浏览器使用摄像头,或直接上传微信/系统截图。",
"invalid_qr_format": "二维码格式无效,请扫描有效的 OTP 二维码",
"service_label": "服务方",
"unknown_service": "未指定服务",
"account_label": "账 号",
"unnamed_account": "未命名账号",
"param_label": "参 数",
"digits_suffix": "位",
"period_suffix": "秒",
"confirm_add_title": "二维码解析成功,确认添加?",
"manual_title": "⌨️ 手动输入",
"input_service_placeholder": "请输入服务名称",
"input_account_placeholder": "请输入账号或邮箱",
"input_secret_label": "安全密钥 (Base32格式)",
"input_secret_placeholder": "请输入 16 位以上的安全密钥",
"generate_random_secret": "随机生成密钥",
"digits_label": "代码位数",
"digits_6": "6 位",
"digits_8": "8 位",
"period_label": "更新周期",
"period_30s": "30 秒",
"period_60s": "60 秒",
"algorithm_label": "算法",
"algo_sha1": "SHA1 (默认)",
"input_category_placeholder": "如 工作, 个人",
"confirm_add_btn": "确认添加",
"require_secret": "请输入安全密钥",
"secret_min_length": "密钥长度通常不少于 16 位",
"secret_invalid_char": "包含非法字符 (仅限 A-Z, 2-7)",
"require_service": "请输入服务名称",
"require_account": "请输入账号标识",
"export_qr_tip": "使用任意 2FA 应用扫描二维码即可添加此账户",
"hide_secret": "隐藏",
"show_secret": "显示",
"copy_secret": "复制密钥",
"copy_otp_url": "复制原始 otpauth 链接"
},
"backup": {
"providers": "备份配置",
"add_provider": "添加备份源",
"cloud": "云端备份",
"restore": "恢复",
"type": "提供商类型",
"name": "配置名称",
"config": "参数配置",
"auto_backup": "自动备份",
"password_required": "静默同步需设置至少12位密码",
"test_success": "连接成功",
"test_failed": "连接失败",
"save_success": "云端备份设置成功",
"delete_provider_confirm": "确定删除该备份源吗?",
"backup_success": "云端备份完成,已成功备份数据至 {provider}",
"restore_success": "恢复成功,导入了 {count} 个新账户",
"no_new_accounts": "恢复的账户皆已存在,无新添项。",
"management": "云端备份管理",
"add_source": "添加备份源",
"fetching_sources": "备份源获取中, 请稍候...",
"auto_backup_on": "自动备份已开启",
"last_backup": "上次备份: ",
"never_backed_up": "从未",
"backup_now": "立即备份",
"restore_data": "恢复数据",
"empty_source": "暂无备份源,请点击右上角添加",
"edit_source": "编辑备份源",
"type_label": "类型",
"type_s3": "S3 对象存储",
"name_label": "名称 (别名)",
"name_placeholder": "例如: OpenList",
"webdav_url": "WebDAV 地址",
"username": "用户名",
"password": "密码",
"modify": "修改",
"save_dir": "保存目录",
"s3_endpoint": "Endpoint (API 地址)",
"s3_bucket": "Bucket (存储桶名称)",
"s3_region": "Region (区域)",
"s3_path_prefix": "存储路径前缀 (可选)",
"auto_backup_config": "自动备份配置",
"switch_on": "开启",
"switch_off": "关闭",
"encrypt_password": "加密密码",
"keep_old_pwd": "保持原密码不变",
"set_new_pwd": "设置新密码",
"input_encrypt_pwd": "输入加密密码",
"continue_use_old_pwd": "系统将继续使用原有的自动备份密码。",
"retain_count_label": "保留最近份数",
"retain_zero_tip": "填 0 表示不对历史备份做任何限制与清理",
"test_connection": "测试连接",
"save": "保存",
"encrypted_backup": "加密备份",
"backup_security_tip": "请输入加密密码用于保护备份文件。",
"use_auto_pwd": "使用自动备份密码",
"use_new_pwd": "使用新密码",
"input_custom_pwd": "输入自定义加密密码 (至少12位)",
"start_backup": "开始备份",
"select_restore_file": "选择备份文件恢复",
"filename": "文件名",
"size": "大小",
"time": "时间",
"action": "操作",
"decrypt_restore": "解密恢复",
"restore_warning": "恢复操作将覆盖当前所有数据!",
"input_restore_pwd": "输入备份时的加密密码",
"confirm_restore": "确认覆盖恢复",
"center_title": "云端备份中心",
"center_desc": "配置多个备份源(WebDAV, S3等),确保数据万无一失。",
"s3_region_placeholder": "auto 或 us-east-1",
"password_length_req": "必填项,长度必须 ≥ 12 位。",
"require_name": "请输入名称",
"require_webdav_url": "请输入 WebDAV 地址",
"require_username": "请输入用户名",
"require_password": "请输入密码",
"require_endpoint": "请输入 Endpoint",
"require_bucket": "请输入 Bucket",
"require_access_key": "请输入 Access Key ID",
"require_secret_key": "请输入 Secret Access Key",
"require_auto_backup_pwd": "请输入自动备份加密密码",
"config_missing": "备份源配置存在缺失内容",
"create_success": "新建成功",
"update_success": "修改成功",
"delete_confirm": "删除确认",
"delete_warning": "此操作将永久删除该备份源配置(不影响云端已备份的数据),是否继续?",
"cancel_delete": "已取消删除",
"delete_success": "删除成功",
"testing_connection": "正在测试连接...",
"fetching_configs": "正在获取配置列表...",
"saving_config": "正在保存配置...",
"password_min_length": "密码至少12位",
"password_no_space": "密码不能包含空格",
"password_not_all_number": "密码不能全部为数字",
"backup_finish_title": "云端备份完成",
"backup_success_msg": "成功备份了数据",
"fetch_backup_fail": "获取备份文件失败",
"fetch_list_fail": "获取备份列表失败",
"restore_finish_title": "恢复数据结束",
"processed_total": "共处理",
"backup_files_count": "个备份文件",
"base_accounts_count": "个基准账号",
"import_success_count": "成功导入",
"new_accounts": "个新账户!",
"skipped_duplicates": "自动跳过了",
"existing_accounts": "个已有账户。",
"restore_fail": "恢复失败"
},
"migration": {
"export": "数据导出",
"import": "数据导入",
"export_format": "导出格式",
"export_password": "强加密密码(至少12位)",
"export_password_confirm": "确认密码",
"password_mismatch": "两次输入的密码不一致!",
"password_weak": "密码太弱!至少需要 12 个字符。",
"select_account": "请至少选择一个账号",
"export_success": "导出完成",
"export_failed": "导出失败,请稍后重试",
"file_too_large": "文件 {name} 太大,不能超过 10MB",
"import_password": "解密密码",
"password_wrong": "解密失败:密码错误或不支持加密标准",
"no_valid_data": "没有可导入的有效账户数据",
"import_success": "导入成功",
"task_build_pf": "构建数据:PhoneFactor 数据集",
"err_pf_parse": "[PhoneFactor] 解析失败: {detail}",
"task_prepare": "准备解析:{name}",
"task_scan_qr": "截取图中二维码:{name}",
"err_scan_qr": "[{name}] 解析图片失败: {msg}",
"err_read_fail": "[{name}] 读取失败",
"err_unrecognized": "[{name}] 无法识别该文件格式。",
"task_build_file": "构建数据:{name}",
"err_parse_fail": "[{name}] 解析失败: {detail}",
"err_read_abnormal": "[{name}] 读取异常",
"task_decrypting": "正在使用密码解密数据...",
"err_user_cancel": "[{name}] 用户取消密码输入",
"import_anomaly": "导入异常",
"task_saving": "合并保存中... ({count} 个账号)",
"msg_total_files": "<div>共处理 <b>{count}</b> 个文件。</div>",
"msg_success_accounts": "<div style=\"color:var(--el-color-success)\">🎉 成功导入 <b>{count}</b> 个新账户!</div>",
"msg_no_new_accounts": "<div style=\"color:var(--el-color-warning)\">⚠️ 导入账户皆已存在,无需添加。</div>",
"msg_duplicates_skipped": "<div style=\"color:var(--el-text-color-secondary)\">ℹ️ 自动跳过了 <b>{count}</b> 个已拥有的账户。</div>",
"msg_error_summary": "<div style=\"color:var(--el-color-danger); margin-top: 5px;\">❌ <b>错误摘要:</b><br>{errors}</div>",
"batch_import_finished": "批量导入结束",
"encrypted_file": "加密文件",
"export_desc": "选择您需要的导出格式。请注意,明文导出存在安全风险。",
"system_backup": "本系统备份",
"encrypted_json": "加密备份 (.json)",
"plaintext_json": "明文备份 (.json)",
"mobile_app": "移动端 2FA App",
"migrate_ga": "迁移到 Google Auth",
"generic_format": "通用格式",
"otpauth_txt": "OTPAuth URI (.txt)",
"spreadsheet_csv": "电子表格 (.csv)",
"html_page": "普通网页 (.html)",
"export_pwd_title": "设置导出密码",
"pwd_placeholder_strong": "请输入高强度密码",
"pwd_placeholder_again": "请再次输入",
"start_encrypt_dl": "开始加密并下载",
"warning_title": "⚠️ 安全警告",
"warning_alert_title": "风险提示",
"warning_desc": "您正在导出未加密的明文数据。任何获取该文件/图片的人都可以直接访问您的账号验证码!",
"confirm_export": "确定导出",
"ga_title": "迁移到 Google Authenticator",
"ga_desc_1": "打开手机上的 Google Authenticator,选择\"扫描二维码\"进行导入",
"ga_desc_multiple": "(账号较多,已分批生成,请依次扫码)",
"btn_prev": "上一张",
"btn_next": "下一张",
"finish": "完成",
"select_account_title": "选择要导出的账号",
"search_account": "搜索服务或账号...",
"select_all_accounts": "全选所有账号",
"select_all_search": "全选搜索出的 {count} 个账号",
"selected_count": "已选: {selected} / {total}",
"no_matching": "无匹配账号",
"migrate_btn": "迁移 {count} 个账号",
"center_title": "数据导入",
"center_desc": "支持从各类 2FA 软件或本系统的备份文件中恢复数据。",
"auto_identify_tip": "💡 自动识别文件类型导入",
"drag_drop_tip": "将备份文件或迁移二维码截图拖到此处,或 <em>点击上传</em>",
"support_desc": "支持情况说明",
"ga_auth_desc": "请在 App 内导出\"迁移二维码\",将截图直接上传 (.png / .jpg)",
"ms_auth_desc": "Microsoft Authenticator (仅支持 Android)",
"ms_auth_detail": "Android 手机导出以下3个文件后,同时上传即可导入:",
"batch_import_processing": "📦 批量导入处理中",
"batch_progress": "{processed} / {total} 任务已完成",
"decrypt_backup_title": "🔓 解密备份文件",
"detect_aegis": "检测到 Aegis 加密备份",
"detect_proton": "检测到 Proton Auth 加密备份",
"detect_2fas": "检测到 2FAS Auth 加密备份",
"detect_system": "检测到本系统加密备份",
"input_decrypt_pwd_label": "请输入该备份的解密密码:",
"input_decrypt_pwd_placeholder": "输入当时设置的导出密码",
"confirm_decrypt_import": "确认解密并导入",
"system_backup_format": "本系统备份",
"encrypted_backup_json": "加密备份 (.json)",
"plaintext_backup_json": "明文备份 (.json)",
"mobile_app_format": "移动端 2FA App",
"generic_json": "通用格式 (.json)",
"fetching_accounts": "正在获取账号数据...",
"no_accounts_to_export": "没有可导出的账号",
"encrypting": "正在加密...",
"generating_file": "正在生成导出文件...",
"generating_qr": "正在生成二维码...",
"reading_backup": "正在读取备份文件...",
"uploading_processing": "正在上传并处理...",
"unsupported_format": "未能识别或不支持的文件格式。",
"unsupported_decrypt": "暂不支持解密来自",
"encrypted_backup_tail": "的加密备份文件。"
},
"tools": {
"qr_parser": "二维码解析",
"password_generator": "强密码生成",
"time_sync": "时间校准",
"totp_sandbox": "TOTP 沙盒",
"parse_success": "解析成功",
"invalid_uri": "无效的 OTP URI",
"secret_required": "密钥不能为空",
"service_required": "请填写服务商和账号",
"save_to_vault": "保存到我的账户",
"sync_success": "校准完成,偏差 {offset}ms",
"sync_failed": "无法连接服务器",
"select_chars": "请至少选择一种字符类型",
"title": "实用工具箱",
"totp_secret_title": "TOTP 密钥工具",
"totp_secret_desc": "随机生成密钥,支持 Base32 格式转换与校验",
"password_gen_title": "密码生成器",
"password_gen_desc": "生成高强度随机密码,支持自定义长度和字符类型",
"time_sync_title": "时间校准器",
"time_sync_desc": "精准检测本地时间与服务器时间的偏差",
"qr_parser_title": "二维码解析器",
"qr_parser_desc": "识别图片中的二维码内容,提取 OTP 链接",
"length": "长度",
"regenerate": "重新生成",
"select_one_char_type": "请至少选择一种字符类型",
"password_copied": "密码已复制",
"qr_result": "解析结果",
"qr_parsed": "二维码解析成功",
"local_time": "本地设备时间",
"server_time": "服务器时间 (估算)",
"time_offset": "时间偏差",
"network_latency": "网络延迟",
"check_now": "立即检测",
"sync_completed": "校准完成,偏差 {offset}ms",
"secret_config": "密钥配置",
"basic_info": "基础信息",
"preview": "结果预览",
"refresh_after": "后刷新",
"advanced_settings": "高级设置 (算法/位数/步长)",
"reset_time": "重置",
"prev_period": "上个周期",
"next_period": "下个周期",
"scanner_camera": "摄像头扫描",
"scanner_image": "图片识别",
"camera_click_to_open": "点击开启摄像头",
"camera_open": "开启摄像头",
"camera_stop": "停止扫描",
"image_placeholder": "点击选择或拖拽二维码图片",
"camera_unsupported": "您的浏览器不支持摄像头功能",
"camera_access_denied": "无法访问摄像头,请检查权限设置",
"qr_not_recognized": "未能从图片中识别出有效的二维码",
"sync_normal": "时间同步正常",
"sync_normal_desc": "本地时间与服务器误差极小,不影响 2FA 验证。",
"sync_warning": "存在微小偏差",
"sync_warning_desc": "建议校准本地时间,但通常仍可使用。",
"sync_error": "时间偏差过大",
"sync_error_desc": "严重偏差!2FA 验证码将失效,请务必校准设备时间。",
"secret_empty": "密钥不能为空",
"fill_info": "请填写服务商和账号",
"totp_hex": "Hex (十六进制)",
"totp_config_preview": "配置参数以生成预览",
"totp_algorithm": "算法",
"totp_algo_sha1_default": "SHA1 (默认)",
"totp_digits": "位数",
"totp_period": "周期",
"totp_scan_qr_title": "扫描二维码"
},
"auth": {
"subtitle": "一站式安全双重认证管理",
"login": "登录",
"logout_success": "已安全退出",
"oauth_failed": "获取授权链接失败:提供商未配置或失效",
"oauth_network_error": "获取授权状态遭遇网络异常",
"login_with": "通过 {provider} 授权登录",
"privacy_title": "安全隐私提示",
"privacy_desc": "系统基于 OAuth 2.0 协议验证身份,绝不会获取、记录或传输您的密码信息。",
"secure_login_in_progress": "正在安全登录中",
"secure_login_sub": "请稍候,系统正在与 {provider} 交换安全凭证...",
"auth_failed_title": "授权失败",
"back_to_login": "返回登录页",
"default_provider": "身份提供商",
"oauth_declined": "您拒绝了 {provider} 的授权请求",
"telegram_missing_hash": "Telegram 登录缺少签名参数",
"missing_auth_params": "URL 缺少必要的授权凭证参数",
"state_mismatch": "安全警告:State 校验失败,请求可能被篡改",
"login_rejected": "登录验证被后端拒绝",
"network_abnormal": "网络请求异常,请稍后再试"
},
"camera": {
"not_supported": "您的浏览器不支持摄像头功能",
"no_access": "无法访问摄像头,请检查权限设置",
"no_qr_found": "未能从图片中识别出有效的二维码"
},
"api_errors": {
"offline": "当前处于离线模式,无法进行此操作",
"session_expired": "会话已过期,请重新登录",
"unauthorized": "未授权的访问",
"forbidden": "禁止访问",
"server_error": "服务器异常",
"request_failed": "请求失败",
"account_not_found": "未找到该账号",
"account_exists": "账号已存在",
"invalid_secret_format": "密钥格式无效(必须为 Base32,至少 16 个字符)",
"missing_service_account": "服务商和账号名称为必填项",
"no_account_ids": "未提供账号 ID",
"export_type_invalid": "无效的导出类型",
"export_password_length": "导出密码至少需要 12 个字符",
"missing_content_type": "内容和类型不能为空",
"import_password_required": "加密导入需要密码",
"parse_failed": "解析失败,请检查文件格式或密码是否正确",
"provider_not_found": "未找到提供商",
"missing_fields": "缺少必填字段",
"backup_password_length": "自动备份密码至少需要12个字符",
"backup_password_required": "自动备份密码为必填项(至少12个字符)",
"manual_backup_password_needed": "使用自动密码进行手动备份要求先配置好自动备份密码。",
"filename_required": "文件名为必填项",
"no_session": "未授权:缺少会话 cookie",
"csrf_mismatch": "请求被拒绝:CSRF token 不匹配",
"token_expired": "未授权:Token 已过期或无效",
"oauth_state_invalid": "无效或缺失的 OAuth state 参数。安全校验失败。",
"oauth_code_missing": "缺少 OAuth code 参数",
"not_whitelisted": "未配置访问白名单。拒绝登录。",
"unauthorized_user": "未授权的用户,拒绝登录!",
"connection_failed": "连接失败",
"backup_failed": "备份失败",
"download_failed": "下载失败",
"oauth_config_incomplete": "OAuth 配置不完整",
"oauth_api_error": "OAuth API 错误",
"oauth_token_exchange_failed": "OAuth token 交换失败",
"telegram_missing_bot_name": "尚未配置 Telegram Bot 昵称(Bot Name)",
"telegram_missing_query": "Telegram 提供商需要完整的查询参数,不仅是 code",
"telegram_missing_bot_token": "尚未配置 Telegram Bot Token",
"telegram_missing_hash": "缺少哈希签名",
"telegram_signature_failed": "Telegram 签名验证失败",
"telegram_login_expired": "Telegram 登录数据已过期",
"unsupported_provider": "不支持的提供商"
}
}
+5 -3
View File
@@ -11,11 +11,13 @@ import router from '@/app/router'
import { useThemeStore } from '@/shared/stores/themeStore'
import { VueQueryPlugin } from '@tanstack/vue-query'
import { useVaultStore } from '@/features/vault/store/vaultStore'
import { i18n } from '@/locales'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(i18n)
// 初始化主题状态
const themeStore = useThemeStore()
@@ -26,17 +28,17 @@ registerSW({
onOfflineReady() {
import('element-plus').then(({ ElNotification }) => {
ElNotification({
title: '更新可用',
title: i18n.global.t('common.update_available'),
message: `
<div style="line-height: 1.5; font-size: 14px; margin-bottom: 5px;">
🎉 Cloudflare Worker 新部署已就绪!
🎉 ${i18n.global.t('common.pwa_update_ready')}
</div>
<div>
<button
style="background: var(--el-color-primary); color: white; border: none; padding: 5px 12px; border-radius: 4px; cursor: pointer; font-size: 13px;"
onclick="window.location.reload()"
>
立即刷新
${i18n.global.t('common.refresh_now')}
</button>
</div>
`,
@@ -0,0 +1,21 @@
<template>
<!-- 中文状态下显示的图标:您可以把它换成代表“English”或纯大写“E”的 SVG 路径 -->
<svg v-if="locale === 'zh-CN'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path fill="currentColor" d="M5 15v2a2 2 0 0 0 2 2h3v2H7a4 4 0 0 1-4-4v-2zm13-5l4.4 11h-2.16l-1.2-3h-4.09l-1.2 3h-2.15L16 10zm-1 2.89L15.75 16h2.5zM8 2v2h4v7H8v3H6v-3H2V4h4V2zm9 1a4 4 0 0 1 4 4v2h-2V7a2 2 0 0 0-2-2h-3V3zM6 6H4v3h2zm4 0H8v3h2z"/>
</svg>
<!-- 英文状态下显示的图标:可以换成代表“中文”或“中”字的 SVG 路径 -->
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path fill="currentColor" transform="scale(-1 1) translate(-24 0)" d="M5 15v2a2 2 0 0 0 2 2h3v2H7a4 4 0 0 1-4-4v-2zm13-5l4.4 11h-2.16l-1.2-3h-4.09l-1.2 3h-2.15L16 10zm-1 2.89L15.75 16h2.5zM8 2v2h4v7H8v3H6v-3H2V4h4V2zm9 1a4 4 0 0 1 4 4v2h-2V7a2 2 0 0 0-2-2h-3V3zM6 6H4v3h2zm4 0H8v3h2z"/>
</svg>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
// 在这里直接引用当前的国际化状态
const { locale } = useI18n()
</script>
<script>
export default { name: 'iconLocales' }
</script>
+10 -8
View File
@@ -1,8 +1,8 @@
<template>
<div class="qr-scanner-container">
<el-radio-group v-model="scannerMode" style="margin-bottom: 20px; width: 100%; display: flex; justify-content: center;" @change="handleModeChange">
<el-radio-button label="camera">摄像头扫描</el-radio-button>
<el-radio-button label="image">图片识别</el-radio-button>
<el-radio-button label="camera">{{ $t('tools.scanner_camera') }}</el-radio-button>
<el-radio-button label="image">{{ $t('tools.scanner_image') }}</el-radio-button>
</el-radio-group>
<!-- Camera Mode -->
@@ -15,10 +15,10 @@
</div>
<div v-if="!isScanning" class="camera-placeholder" @click="toggleCamera">
<el-icon :size="48" color="#909399"><Camera /></el-icon>
<p>点击开启摄像头</p>
<p>{{ $t('tools.camera_click_to_open') }}</p>
</div>
<el-button :type="isScanning ? 'danger' : 'primary'" @click="toggleCamera" style="margin-top: 15px; width: 100%;" :loading="isStarting">
{{ isScanning ? '停止扫描' : '开启摄像头' }}
{{ isScanning ? $t('tools.camera_stop') : $t('tools.camera_open') }}
</el-button>
</div>
@@ -28,7 +28,7 @@
<img v-if="previewImg" :src="previewImg" class="preview-img" />
<div v-else class="upload-placeholder">
<el-icon :size="48" color="#909399"><Picture /></el-icon>
<p>点击选择或拖拽二维码图片</p>
<p>{{ $t('tools.image_placeholder') }}</p>
</div>
</div>
<input type="file" ref="fileInputRef" accept="image/*" style="display: none" @change="handleFileUpload" />
@@ -41,8 +41,10 @@ import { ref, onBeforeUnmount, nextTick } from 'vue'
import { Camera, Picture, Upload } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import jsQR from 'jsqr'
import { i18n } from '@/locales'
const emit = defineEmits(['scan-success'])
const { t } = i18n.global
const scannerMode = ref('camera')
const videoRef = ref(null)
@@ -70,7 +72,7 @@ const toggleCamera = () => {
const startCamera = async () => {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
ElMessage.error('您的浏览器不支持摄像头功能')
ElMessage.error(t('tools.camera_unsupported'))
return
}
@@ -93,7 +95,7 @@ const startCamera = async () => {
}
} catch (err) {
isStarting.value = false
ElMessage.error('无法访问摄像头,请检查权限设置')
ElMessage.error(t('tools.camera_access_denied'))
console.error('Camera error:', err)
}
}
@@ -161,7 +163,7 @@ const handleFileUpload = (e) => {
if (code && code.data) {
emit('scan-success', code.data)
} else {
ElMessage.error('未能从图片中识别出有效的二维码')
ElMessage.error(t('tools.qr_not_recognized'))
}
}
img.src = event.target.result
+1 -2
View File
@@ -25,7 +25,6 @@
</template>
<script setup>
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Lock } from '@element-plus/icons-vue'
import { useLayoutStore } from '@/shared/stores/layoutStore'
@@ -43,4 +42,4 @@ const goHome = () => {
router.push('/')
}
}
</script>
</script>\n
+39 -3
View File
@@ -1,6 +1,7 @@
import { ElMessage } from 'element-plus'
import router from '@/app/router'
import { useAuthUserStore } from '@/features/auth/store/authUserStore'
import { i18n } from '@/locales'
// 辅助函数:从 document.cookie 中安全地读取指定的 cookie 值
export function getCookie(name) {
@@ -27,7 +28,7 @@ export async function request(url, options = {}) {
const method = (options.method || 'GET').toUpperCase();
if (!navigator.onLine && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
if (!options.silent) {
ElMessage.warning('当前处于离线模式,无法进行此操作');
ElMessage.warning(i18n.global.t('api_errors.offline'));
}
throw new Error('Offline: Cannot perform mutative action');
}
@@ -50,7 +51,26 @@ export async function request(url, options = {}) {
if (!isNavigatingToLogin) {
isNavigatingToLogin = true;
ElMessage.error(data.message || data.error || '会话已过期,请重新登录')
// 这里我们暂且尝试直接翻译:假设后端抛出的 data.message 已经是 ERROR_CODE
// 或者我们可以尝试:如果字典里包含这个 message 的 key 就翻译,否则原样输出
let fallbackMessage = data.message || data.error || 'session_expired'
let errorDetails = ''
if (typeof fallbackMessage === 'string' && fallbackMessage.includes(':')) {
const parts = fallbackMessage.split(':')
fallbackMessage = parts[0].trim()
errorDetails = parts.slice(1).join(':').trim()
}
let translatedMessage = i18n.global.te(`api_errors.${fallbackMessage}`)
? i18n.global.t(`api_errors.${fallbackMessage}`)
: fallbackMessage
if (errorDetails) {
translatedMessage = `${translatedMessage}: ${errorDetails}`
}
ElMessage.error(translatedMessage)
// 调用 Pinia Action 清空用户信息、缓存、以及重置内存状态,防止路由守卫死循环打回
try {
@@ -82,7 +102,23 @@ export async function request(url, options = {}) {
// 注意:兼容旧的 data.error 和新的 data.message
if (!response.ok || data.success === false) {
if (!options.silent) {
ElMessage.error(data.message || data.error || '请求失败')
let fallbackError = data.message || data.error || 'request_failed'
let errorDetails = ''
if (typeof fallbackError === 'string' && fallbackError.includes(':')) {
const parts = fallbackError.split(':')
fallbackError = parts[0].trim()
errorDetails = parts.slice(1).join(':').trim()
}
let translatedError = i18n.global.te(`api_errors.${fallbackError}`)
? i18n.global.t(`api_errors.${fallbackError}`)
: fallbackError
if (errorDetails) {
translatedError = `${translatedError}: ${errorDetails}`
}
ElMessage.error(translatedError)
}
throw new Error(data.message || data.error || '请求失败')
}
+77 -4
View File
@@ -26,6 +26,10 @@ export default defineConfig({
imports: ['vue', 'vue-router'],
resolvers: [ElementPlusResolver({ importStyle: false })],
}),
AutoImport({
imports: ['vue', 'vue-router'],
resolvers: [ElementPlusResolver({ importStyle: false })],
}),
Components({
resolvers: [
ElementPlusResolver({
@@ -74,15 +78,49 @@ export default defineConfig({
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
globIgnores: [
'**/assets/wa-sqlite*.js',
'**/assets/argon2*.js',
'**/assets/sql*.js',
'**/assets/jsQR*.js',
'**/assets/dataImport*.js',
'**/assets/dataMigrationService*.js'
],
cleanupOutdatedCaches: true,
clientsClaim: true,
skipWaiting: true,
navigateFallback: '/index.html',
navigateFallbackDenylist: [/^\/api/],
runtimeCaching: [{
urlPattern: ({ url }) => url.pathname.startsWith('/api'),
handler: 'NetworkOnly'
}]
runtimeCaching: [
{
urlPattern: ({ url }) => url.pathname.startsWith('/api'),
handler: 'NetworkOnly'
},
{
// 为大体积、非高频访问的 WASM 文件配置动态缓存
urlPattern: /\.wasm$/i,
handler: 'CacheFirst',
options: {
cacheName: 'wasm-dynamic-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 30 * 24 * 60 * 60 // 30 天
}
}
},
{
// 为被 globIgnores 忽略的大体积工具 JS chunk 配置动态缓存
urlPattern: /assets\/(wa-sqlite|argon2|sql|jsQR|dataImport|dataMigrationService).*\.js$/i,
handler: 'CacheFirst',
options: {
cacheName: 'lazy-tools-js-cache',
expiration: {
maxEntries: 20,
maxAgeSeconds: 30 * 24 * 60 * 60
}
}
}
]
}
})
],
@@ -103,5 +141,40 @@ export default defineConfig({
changeOrigin: true
}
}
},
build: {
chunkSizeWarningLimit: 1500,
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
// 精确匹配 Vue 与其核心生态相关包
if (
id.includes('/node_modules/vue/') ||
id.includes('/node_modules/@vue/') ||
id.includes('/node_modules/vue-router/') ||
id.includes('/node_modules/pinia/')
) {
return 'vue-core'
}
// 精确匹配 Element Plus 及其底层依赖
if (
id.includes('/node_modules/element-plus/') ||
id.includes('/node_modules/@element-plus/')
) {
return 'element-plus'
}
// 加密与数据库相关的超大模块抽象为主 Vendor Chunk
if (id.includes('/node_modules/wa-sqlite/')) return 'wa-sqlite'
if (id.includes('/node_modules/argon2-browser/')) return 'argon2-browser'
if (id.includes('/node_modules/sql.js/')) return 'sql-js'
// 剩余其他的第三方包交给 Rollup 按需路由或组件自动拆分 (移除强硬的 'vendor' 返回)
}
}
}
}
}
})