feat: 增加中英文本地化适配
This commit is contained in:
1 parent
0dc02271e3
commit
790984ad0c
48 files changed
+1584
-469
No files matched your search
@@ -20,4 +20,4 @@
|
||||
"overrides": {
|
||||
"esbuild": ">=0.25.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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. 返回用户信息
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// 验证通过,将用户信息挂载到上下文,供后续路由使用
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,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> 必填项,长度必须 ≥ 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'))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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": "不支持的提供商"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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' 返回)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user