feat: 数据迁移支持导入更多App备份文件
This commit is contained in:
1 parent
2afb8353ac
commit
9404e34bd3
79 files changed
+4414
-1474
No files matched your search
@@ -104,7 +104,6 @@ curl "https://api.telegram.org/bot<你的BotToken>/setWebhook?url=https://<你
|
||||
| :--- | :--- | :--- |
|
||||
| `CLOUDFLARE_ACCOUNT_ID` | 你的 CF 账户 ID | `a1b2c3d4e5f6g7h8i9j0` (CF 面板的概述页右下角) |
|
||||
| `CLOUDFLARE_API_TOKEN` | CF 部署令牌 [前往获取](https://dash.cloudflare.com/profile/api-tokens) <br> 点击“创建令牌” -> “创建自定义令牌” <br/> 权限 1: **账户** -> **D1** -> **编辑** <br/>权限 2: **账户** -> **Workers 脚本** -> **编辑** | `r4_xyz...` |
|
||||
| `CLOUDFLARE_D1_DATABASE_ID` | D1 数据库 ID | xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` |
|
||||
|
||||
#### 🔐 必填:系统安全参数
|
||||
|
||||
@@ -161,15 +160,14 @@ cd 2fauth-worker
|
||||
cd frontend && npm install
|
||||
cd ../backend && npm install
|
||||
|
||||
# 2. 在 backend 目录,修改 example.dev.vars 为 .dev.vars, 按说明写入开发用的测试密钥
|
||||
# 2. 在 backend 目录,复制 example.dev.vars 为 .dev.vars, 按说明写入开发用的测试密钥
|
||||
cp example.dev.vars .dev.vars
|
||||
|
||||
# 3. 初始化本地的 SQLite Sandbox 数据库
|
||||
cd backend
|
||||
npx wrangler d1 execute 2fauth-db-dev --local --env dev --file=schema.sql
|
||||
|
||||
# 4. 终端启动应用
|
||||
```bash
|
||||
cd backend && npm run dev
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
+10
-6
@@ -6,14 +6,18 @@
|
||||
"deploy": "wrangler deploy --minify"
|
||||
},
|
||||
"dependencies": {
|
||||
"aws4fetch": "^1.0.19",
|
||||
"drizzle-orm": "^0.30.9",
|
||||
"hono": "^4.11.10",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"hono": "^4.12.3",
|
||||
"webdav": "^5.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260226.1",
|
||||
"drizzle-kit": "^0.20.14",
|
||||
"wrangler": "^3.0.0"
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"typescript": "^5.9.3",
|
||||
"wrangler": "^4.69.0"
|
||||
},
|
||||
"overrides": {
|
||||
"esbuild": ">=0.25.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -32,4 +32,14 @@ CREATE TABLE IF NOT EXISTS backup_providers (
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX IF NOT EXISTS idx_vault_service ON vault(service);
|
||||
CREATE INDEX IF NOT EXISTS idx_vault_created_at ON vault(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_vault_created_at ON vault(created_at DESC);
|
||||
|
||||
-- Remove any existing duplicates before enforcing unique constraint; keep the earliest
|
||||
DELETE FROM vault
|
||||
WHERE rowid NOT IN (
|
||||
SELECT MIN(rowid)
|
||||
FROM vault
|
||||
GROUP BY lower(service), lower(account)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS vault_service_account_uq ON vault(lower(service), lower(account));
|
||||
@@ -14,11 +14,10 @@ export abstract class BaseOAuthProvider {
|
||||
constructor(env: EnvBindings) {
|
||||
this.env = env;
|
||||
}
|
||||
|
||||
abstract readonly id: string;
|
||||
abstract readonly name: string;
|
||||
abstract readonly color: string; // 按钮背景色
|
||||
abstract readonly icon: string; // 前端图标 SVG 代码 (完整 <svg> 标签)
|
||||
abstract readonly icon: string; // 前端 Vue 图标组件名,如 'iconGithub'
|
||||
abstract readonly whitelistFields: string[]; // 新增:定义该 Provider 支持哪些白名单字段 (如 ['email', 'username'])
|
||||
|
||||
// 1. 获取授权跳转链接
|
||||
|
||||
@@ -32,7 +32,7 @@ export class CloudflareAccessProvider extends BaseOAuthProvider {
|
||||
readonly id = 'cloudflare';
|
||||
readonly name = 'Cloudflare Access';
|
||||
readonly color = '#F38020';
|
||||
readonly icon = '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 48 48" fill="currentColor"><path d="M36.8 16.6C36.2 11.2 31.7 7 26.2 7c-4.8 0-8.9 3.2-10.2 7.6-5.3.6-9.4 5.1-9.4 10.5 0 5.8 4.7 10.5 10.5 10.5h19.7c4.8 0 8.7-3.9 8.7-8.7 0-4.6-3.6-8.4-8.1-8.7-.2-1.1-.4-2.1-.6-3.1z"/></svg>';
|
||||
readonly icon = 'iconCloudflare';
|
||||
readonly whitelistFields = ['email']; // Cloudflare Access 主要基于邮箱验证
|
||||
|
||||
constructor(env: EnvBindings) {
|
||||
@@ -43,12 +43,12 @@ 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 configuration incomplete: Missing OAUTH_CLOUDFLARE_ORG_DOMAIN', 500);
|
||||
}
|
||||
|
||||
let baseUrl = orgDomain.replace(/\/$/, '');
|
||||
if (!baseUrl.startsWith('http')) {
|
||||
baseUrl = `https://${baseUrl}`;
|
||||
baseUrl = `https://${baseUrl}`;
|
||||
}
|
||||
return baseUrl;
|
||||
}
|
||||
@@ -58,9 +58,9 @@ 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 configuration incomplete', 500);
|
||||
}
|
||||
|
||||
|
||||
const baseUrl = this.getBaseUrl();
|
||||
const { verifier, challenge } = await generatePKCE();
|
||||
|
||||
@@ -74,7 +74,7 @@ export class CloudflareAccessProvider extends BaseOAuthProvider {
|
||||
code_challenge_method: 'S256'
|
||||
});
|
||||
|
||||
const url = `${baseUrl}/cdn-cgi/access/sso/oidc/${clientId}/authorization?${params.toString()}`;
|
||||
const url = `${baseUrl}/cdn-cgi/access/sso/oidc/${clientId}/authorization?${params.toString()}`;
|
||||
return { url, codeVerifier: verifier };
|
||||
}
|
||||
|
||||
@@ -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 configuration incomplete', 500);
|
||||
}
|
||||
|
||||
const baseUrl = this.getBaseUrl();
|
||||
@@ -113,10 +113,10 @@ export class CloudflareAccessProvider extends BaseOAuthProvider {
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
// 增加更详细的错误日志,方便调试
|
||||
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);
|
||||
// 增加更详细的错误日志,方便调试
|
||||
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);
|
||||
}
|
||||
|
||||
const tokenData: any = await tokenResponse.json();
|
||||
|
||||
@@ -4,8 +4,8 @@ import { EnvBindings, AppError } from '@/app/config';
|
||||
export class GiteeProvider extends BaseOAuthProvider {
|
||||
readonly id = 'gitee';
|
||||
readonly name = 'Gitee';
|
||||
readonly color = '#c71d23';
|
||||
readonly icon = '<svg width="1em" height="1em" viewBox="0 0 90 90" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><circle id="Combined-Shape" fill="#C71D23" cx="44.8544363" cy="44.8544363" r="44.8544363"></circle><path d="M67.558546,39.8714292 L42.0857966,39.8714292 C40.8627004,39.8720094 39.8710953,40.8633548 39.8701949,42.0864508 L39.8687448,47.623783 C39.867826,48.8471055 40.8592652,49.8390642 42.0825879,49.8393845 C42.0827874,49.8393846 42.0829869,49.8393846 42.0831864,49.8387862 L57.5909484,49.838657 C58.8142711,49.8386283 59.8059783,50.830319 59.8059885,52.0536417 C59.8059885,52.0536479 59.8059885,52.053654 59.8059701,52.0536602 L59.8059701,52.6073539 L59.8059701,52.6073539 L59.8059701,53.161115 C59.8059701,56.8310831 56.8308731,59.80618 53.160905,59.80618 L32.1165505,59.80618 C30.8934034,59.806119 29.9018373,58.8145802 29.9017425,57.5914331 L29.9011625,36.5491188 C29.9008781,32.8791508 32.8758931,29.9039718 36.5458611,29.9038706 C36.5459222,29.9038706 36.5459833,29.9038706 36.5460443,29.9040538 L67.5523638,29.9040538 C68.77515,29.9026795 69.7666266,28.9118177 69.7687593,27.6890325 L69.7721938,22.1516997 C69.774326,20.928378 68.7832423,19.9360642 67.5599198,19.9353054 C67.5594619,19.9353054 67.5590039,19.9353054 67.558546,19.9366784 L36.5479677,19.9366784 C27.3730474,19.9366784 19.935305,27.3744208 19.935305,36.549341 L19.935305,67.558546 C19.935305,68.7818687 20.927004,69.7735676 22.1503267,69.7735676 L54.8224984,69.7735676 C63.079746,69.7735676 69.7735676,63.079746 69.7735676,54.8224984 L69.7735676,42.0864509 C69.7735676,40.8631282 68.7818687,39.8714292 67.558546,39.8714292 Z" id="G" fill="#FFFFFF"></path></svg>';
|
||||
readonly color = '#C71D23';
|
||||
readonly icon = 'iconGitee';
|
||||
readonly whitelistFields = ['email'];
|
||||
|
||||
constructor(env: EnvBindings) {
|
||||
|
||||
@@ -5,7 +5,7 @@ export class GitHubProvider extends BaseOAuthProvider {
|
||||
readonly id = 'github';
|
||||
readonly name = 'GitHub';
|
||||
readonly color = '#24292e';
|
||||
readonly icon = '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" /></svg>';
|
||||
readonly icon = 'iconGithub';
|
||||
readonly whitelistFields = ['email'];
|
||||
|
||||
constructor(env: EnvBindings) {
|
||||
|
||||
@@ -5,8 +5,7 @@ export class GoogleProvider extends BaseOAuthProvider {
|
||||
readonly id = 'google';
|
||||
readonly name = 'Google';
|
||||
readonly color = '#33A854';
|
||||
// Google "G" Logo SVG
|
||||
readonly icon = '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M21.35 11.1h-9.17v2.73h6.51c-.33 3.81-3.5 5.44-6.5 5.44C8.36 19.27 5 16.25 5 12c0-4.1 3.2-7.27 7.2-7.27c3.09 0 4.9 1.97 4.9 1.97L19 4.72S16.56 2 12.1 2C6.42 2 2.03 6.8 2.03 12c0 5.05 4.13 10 10.22 10c5.35 0 9.25-3.67 9.25-9.09c0-1.15-.15-1.81-.15-1.81Z"/></svg>';
|
||||
readonly icon = 'iconGoogle';
|
||||
readonly whitelistFields = ['email']; // Google 主要基于邮箱验证
|
||||
|
||||
constructor(env: EnvBindings) {
|
||||
@@ -65,8 +64,8 @@ export class GoogleProvider extends BaseOAuthProvider {
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errText = await tokenResponse.text();
|
||||
throw new AppError(`Google Token Exchange failed: ${tokenResponse.status} - ${errText}`, 502);
|
||||
const errText = await tokenResponse.text();
|
||||
throw new AppError(`Google Token Exchange failed: ${tokenResponse.status} - ${errText}`, 502);
|
||||
}
|
||||
|
||||
const tokenData: any = await tokenResponse.json();
|
||||
|
||||
@@ -5,7 +5,7 @@ export class NodeLocProvider extends BaseOAuthProvider {
|
||||
readonly id = 'nodeloc';
|
||||
readonly name = 'NodeLoc';
|
||||
readonly color = '#475569';
|
||||
readonly icon = '<svg xmlns="http://www.w3.org/2000/svg" width="1em" viewBox="0 0 102 66" fill="none"><path d="M51.504 19.32C51.504 23.224 51.312 28.888 50.928 36.312C50.544 43.608 50.352 49.144 50.352 52.92C50.352 54.008 50.416 55.64 50.544 57.816C50.672 59.992 50.736 61.688 50.736 62.904C50.608 63.288 49.36 63.64 46.992 63.96C44.624 64.28 42.992 64.44 42.096 64.44C41.648 64.44 40.944 63.832 39.984 62.616C39.024 61.4 37.808 59.704 36.336 57.528C33.904 54.072 32.368 52.088 31.728 51.576C31.728 51.576 31.216 50.808 30.192 49.272C29.104 47.544 27.76 45.496 26.16 43.128C24.624 40.76 23.856 39.544 23.856 39.48L15.504 29.592C15.44 29.464 15.28 29.112 15.024 28.536C14.768 27.96 14.416 27.672 13.968 27.672C13.776 27.672 13.584 27.864 13.392 28.248C13.264 28.568 13.2 28.92 13.2 29.304C13.2 36.024 13.264 41.304 13.392 45.144C13.456 46.04 13.488 47.16 13.488 48.504C13.488 48.696 13.456 48.92 13.392 49.176C13.328 49.368 13.264 49.528 13.2 49.656V64.44L12.432 65.208L10.32 65.304L0.528 64.536C0.528 62.744 0.592 60.632 0.72 58.2C0.912 55.768 1.008 54.328 1.008 53.88C1.008 52.728 0.944 51.128 0.816 49.08C0.688 46.776 0.624001 44.792 0.624001 43.128L0.816 39.864C1.008 36.216 1.104 34.2 1.104 33.816C1.104 32.28 1.04 29.976 0.912 26.904C0.784 23.832 0.72 21.56 0.72 20.088C0.72 19.192 0.848 17.912 1.104 16.248C1.36 14.712 1.488 13.592 1.488 12.888C1.488 12.696 1.36 11.736 1.104 10.008C0.848 8.216 0.72 6.68 0.72 5.4L0.336 4.72799C0.464 4.088 0.688 3.672 1.008 3.48C1.456 3.416 2.672 3.224 4.656 2.904C6.64 2.52 8.24 2.328 9.456 2.328C10.544 2.328 11.28 2.456 11.664 2.712C12.112 3.096 12.816 3.768 13.776 4.72799C14.736 5.688 15.184 6.264 15.12 6.456L20.016 14.808C20.016 14.872 20.912 16.152 22.704 18.648L24.24 20.856C27.44 25.592 30.256 29.656 32.688 33.048C35.12 36.376 36.688 38.04 37.392 38.04C37.84 38.04 38.096 37.88 38.16 37.56C38.16 36.536 38.096 35.128 37.968 33.336L37.872 29.592C37.872 28.44 37.936 26.648 38.064 24.216L38.16 18.84C38.16 17.368 38.096 15.16 37.968 12.216C37.84 9.4 37.776 7.224 37.776 5.688C37.776 4.152 37.872 3.064 38.064 2.424C38.32 1.784 38.8 1.336 39.504 1.08C40.208 0.759996 41.36 0.599997 42.96 0.599997C46.16 0.599997 48.144 0.759996 48.912 1.08C49.68 1.336 50.096 1.816 50.16 2.51999C50.224 3.16 50.416 3.704 50.736 4.152V10.2L51.024 11.064L50.928 14.328C50.928 16.76 51.12 18.424 51.504 19.32ZM39.696 6.168C39.952 6.168 40.912 5.72 42.576 4.824C44.24 3.864 45.072 3.288 45.072 3.096V2.616C44.304 2.616 43.216 2.712 41.808 2.904C40.4 3.032 39.696 3.224 39.696 3.48V6.168ZM49.2 20.856C49.2 18.424 49.104 16.024 48.912 13.656C48.784 10.584 48.72 8.28 48.72 6.744L48.816 4.632L49.2 4.152C49.008 4.024 48.784 3.736 48.528 3.288C48.272 2.84 47.92 2.616 47.472 2.616C46.96 2.616 45.68 3.32 43.632 4.72799C41.648 6.072 40.656 6.936 40.656 7.32C41.232 7.32 41.808 7.16 42.384 6.84C43.024 6.52 43.408 6.328 43.536 6.264C44.688 5.624 45.552 5.304 46.128 5.304C46.832 5.304 47.344 5.432 47.664 5.688C47.216 6.456 46.48 7.256 45.456 8.088C44.496 8.856 43.472 9.592 42.384 10.296C41.296 11 40.528 11.512 40.08 11.832C40.528 11.832 41.072 11.768 41.712 11.64C42.416 11.512 42.896 11.416 43.152 11.352C44.688 10.968 45.84 10.776 46.608 10.776C46.608 10.904 46.64 11.096 46.704 11.352C46.832 11.608 46.896 11.832 46.896 12.024C46.896 12.408 46.672 12.824 46.224 13.272C45.776 13.656 45.104 14.136 44.208 14.712C42.608 15.736 41.488 16.536 40.848 17.112C41.616 17.112 42.512 16.76 43.536 16.056C44.688 15.416 45.552 15.096 46.128 15.096C46.384 15.096 46.512 15.256 46.512 15.576C46.512 16.024 46.352 16.44 46.032 16.824C45.712 17.144 45.168 17.624 44.4 18.264C43.056 19.224 42 20.248 41.232 21.336C42.128 21.336 43.28 21.048 44.688 20.472C46.352 19.832 47.696 19.512 48.72 19.512C48.72 20.28 48.368 20.984 47.664 21.624C47.024 22.264 46.096 22.968 44.88 23.736C43.856 24.44 43.12 24.984 42.672 25.368C42.224 25.752 42 26.168 42 26.616C42.832 26.168 44.144 25.688 45.936 25.176C47.216 24.856 48.08 24.472 48.528 24.024C48.976 23.512 49.2 22.456 49.2 20.856ZM4.848 21.624C6 21.048 7.088 20.536 8.112 20.088C9.2 19.576 10 19.192 10.512 18.936C12.368 18.232 14.672 17.112 17.424 15.576C17.424 15.576 17.456 15.544 17.52 15.48C17.648 15.352 17.712 15.256 17.712 15.192C17.712 15.128 17.648 15.032 17.52 14.904C17.456 14.712 17.424 14.552 17.424 14.424C17.104 14.104 16.752 13.592 16.368 12.888C16.048 12.12 15.824 11.608 15.696 11.352C14.736 9.24 13.808 7.576 12.912 6.36C12.016 5.144 10.896 4.536 9.552 4.536C8.72 4.536 7.984 4.728 7.344 5.112C6.704 5.496 5.936 6.072 5.04 6.84C4.144 7.608 3.472 8.12 3.024 8.376C3.024 8.44 3.056 8.536 3.12 8.664C3.184 8.728 3.248 8.76 3.312 8.76C4.144 8.76 5.328 8.376 6.864 7.608C8.4 6.84 9.584 6.456 10.416 6.456C10.8 6.456 10.992 6.712 10.992 7.224C10.992 7.8 10.256 8.664 8.784 9.816C7.376 10.904 6.32 11.448 5.616 11.448H5.232L3.024 13.368V13.848C3.6 13.848 4.208 13.656 4.848 13.272C5.552 12.888 6.448 12.312 7.536 11.544C9.328 10.264 10.576 9.56 11.28 9.432L13.488 9.62399V10.2C13.488 10.52 13.136 11 12.432 11.64C11.728 12.216 10.Line truncated
|
||||
readonly icon = 'iconNodeloc';
|
||||
readonly whitelistFields = ['email'];
|
||||
|
||||
constructor(env: EnvBindings) {
|
||||
|
||||
@@ -5,7 +5,7 @@ export class TelegramProvider extends BaseOAuthProvider {
|
||||
readonly id = 'telegram';
|
||||
readonly name = 'Telegram';
|
||||
readonly color = '#54a9eb';
|
||||
readonly icon = '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor"><path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12a12 12 0 0 0 12-12A12 12 0 0 0 11.944 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472c-.18 1.898-.962 6.502-1.36 8.627c-.168.9-.499 1.201-.82 1.23c-.696.065-1.225-.46-1.9-.902c-1.056-.693-1.653-1.124-2.678-1.8c-1.185-.78-.417-1.21.258-1.91c.177-.184 3.247-2.977 3.307-3.23c.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345c-.48.33-.913.49-1.302.48c-.428-.008-1.252-.241-1.865-.44c-.752-.245-1.349-.374-1.297-.789c.027-.216.325-.437.893-.663c3.498-1.524 5.83-2.529 6.998-3.014c3.332-1.386 4.025-1.627 4.476-1.635z"/></svg>';
|
||||
readonly icon = 'iconTelegram';
|
||||
readonly whitelistFields = ['id']; // Telegram 不保证提供 email
|
||||
|
||||
constructor(env: EnvBindings) {
|
||||
|
||||
@@ -53,13 +53,29 @@ 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()}`;
|
||||
}
|
||||
|
||||
async createAccount(userId: string, data: any) {
|
||||
const { service, account, category, secret, digits, period, algorithm } = data;
|
||||
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);
|
||||
}
|
||||
|
||||
// 入库清洗:去掉 account 中可能带有的 Issuer: 前缀
|
||||
if (typeof account === 'string' && account.includes(':')) {
|
||||
account = account.split(':').pop()?.trim() || account;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
const normalizedSecret = secret.replace(/\s/g, '').toUpperCase();
|
||||
const encryptedSecret = await encryptField(normalizedSecret, this.encryptionKey);
|
||||
|
||||
@@ -81,12 +97,17 @@ export class VaultService {
|
||||
* 更新账户
|
||||
*/
|
||||
async updateAccount(id: string, data: any) {
|
||||
const { service, account, category, secret, digits, period, algorithm } = data;
|
||||
let { service, account, category, secret, digits, period, algorithm } = data;
|
||||
|
||||
if (!service || !account) {
|
||||
throw new AppError('Service and account are required', 400);
|
||||
}
|
||||
|
||||
// 入库清洗:去掉 account 中可能带有的 Issuer: 前缀
|
||||
if (typeof account === 'string' && account.includes(':')) {
|
||||
account = account.split(':').pop()?.trim() || account;
|
||||
}
|
||||
|
||||
// secret 可选:若未传则保留数据库中已有的加密值
|
||||
let encryptedSecret: string;
|
||||
if (secret) {
|
||||
@@ -211,15 +232,21 @@ export class VaultService {
|
||||
validAccounts = data.accounts;
|
||||
// 格式 B: 标准导出格式 (data.secrets[] with issuer field)
|
||||
} else if (Array.isArray(data.secrets)) {
|
||||
validAccounts = data.secrets.map((item: any) => ({
|
||||
service: item.issuer || item.service || item.name || 'Unknown',
|
||||
account: item.account || item.label || '',
|
||||
secret: item.secret,
|
||||
algorithm: item.algorithm || 'SHA1',
|
||||
digits: item.digits || 6,
|
||||
period: item.period || 30,
|
||||
category: item.category || '',
|
||||
}));
|
||||
validAccounts = data.secrets.map((item: any) => {
|
||||
let account = item.account || item.label || '';
|
||||
if (typeof account === 'string' && account.includes(':')) {
|
||||
account = account.split(':').pop()?.trim() || account;
|
||||
}
|
||||
return {
|
||||
service: item.issuer || item.service || item.name || 'Unknown',
|
||||
account,
|
||||
secret: item.secret,
|
||||
algorithm: item.algorithm || 'SHA1',
|
||||
digits: item.digits || 6,
|
||||
period: item.period || 30,
|
||||
category: item.category || '',
|
||||
};
|
||||
});
|
||||
// 格式 C: 旧版 2FAuth 开源项目导出 (data.data[])
|
||||
} else if (data.app && data.app.includes('2fauth') && Array.isArray(data.data)) {
|
||||
validAccounts = data.data.map((item: any) => ({ ...item, category: '' }));
|
||||
@@ -228,29 +255,41 @@ export class VaultService {
|
||||
validAccounts = data;
|
||||
// 格式 E: 老版 2FAS JSON (data.services[])
|
||||
} else if (data.services) {
|
||||
validAccounts = data.services.map((s: any) => ({
|
||||
service: s.otp?.issuer || s.name || s.service,
|
||||
account: s.otp?.account || s.account || s.login || '',
|
||||
secret: s.secret,
|
||||
algorithm: s.otp?.algorithm || s.algorithm || 'SHA1',
|
||||
digits: s.otp?.digits || s.digits || 6,
|
||||
period: s.otp?.period || s.period || 30,
|
||||
category: s.group || '',
|
||||
}));
|
||||
validAccounts = data.services.map((s: any) => {
|
||||
let account = s.otp?.account || s.account || s.login || '';
|
||||
if (typeof account === 'string' && account.includes(':')) {
|
||||
account = account.split(':').pop()?.trim() || account;
|
||||
}
|
||||
return {
|
||||
service: s.otp?.issuer || s.name || s.service,
|
||||
account,
|
||||
secret: s.secret,
|
||||
algorithm: s.otp?.algorithm || s.algorithm || 'SHA1',
|
||||
digits: s.otp?.digits || s.digits || 6,
|
||||
period: s.otp?.period || s.period || 30,
|
||||
category: s.group || '',
|
||||
};
|
||||
});
|
||||
}
|
||||
} else if (type === '2fas') {
|
||||
const data = JSON.parse(content);
|
||||
if (Array.isArray(data.services)) {
|
||||
validAccounts = data.services.map((s: any) => ({
|
||||
// 2FAS backup 格式 schemaVersion 4:字段在 otp 子对象中
|
||||
service: s.otp?.issuer || s.name || s.otp?.issuer || 'Unknown',
|
||||
account: s.otp?.account || s.account || s.username || '',
|
||||
secret: s.secret || '',
|
||||
algorithm: (s.otp?.algorithm || s.algorithm || 'SHA1').toUpperCase(),
|
||||
digits: s.otp?.digits || s.digits || 6,
|
||||
period: s.otp?.period || s.period || 30,
|
||||
category: s.group || s.category || '',
|
||||
}));
|
||||
validAccounts = data.services.map((s: any) => {
|
||||
let account = s.otp?.account || s.account || s.username || '';
|
||||
if (typeof account === 'string' && account.includes(':')) {
|
||||
account = account.split(':').pop()?.trim() || account;
|
||||
}
|
||||
return {
|
||||
// 2FAS backup 格式 schemaVersion 4:字段在 otp 子对象中
|
||||
service: s.otp?.issuer || s.name || s.otp?.issuer || 'Unknown',
|
||||
account,
|
||||
secret: s.secret || '',
|
||||
algorithm: (s.otp?.algorithm || s.algorithm || 'SHA1').toUpperCase(),
|
||||
digits: s.otp?.digits || s.digits || 6,
|
||||
period: s.otp?.period || s.period || 30,
|
||||
category: s.group || s.category || '',
|
||||
};
|
||||
});
|
||||
}
|
||||
} else if (type === 'text') {
|
||||
const lines = content.split('\n').filter((line: string) => line.trim());
|
||||
@@ -271,16 +310,21 @@ export class VaultService {
|
||||
|
||||
if (type === 'raw') validAccounts = JSON.parse(content);
|
||||
|
||||
// 获取现有以去重
|
||||
// 获取现有以去重 (使用小写/去空格以避免大小写差异导致重复)
|
||||
const existingItems = await this.repository.findAll();
|
||||
const existingSet = new Set(existingItems.map((row: any) => `${row.service}:${row.account}`));
|
||||
const existingSet = new Set(existingItems.map((row: any) => this.normalizeSignature(row.service, row.account)));
|
||||
|
||||
const uniqueAccountsToInsert = [];
|
||||
const seenInBatch = new Set();
|
||||
const uniqueAccountsToInsert: any[] = [];
|
||||
const seenInBatch = new Set<string>();
|
||||
|
||||
for (const acc of validAccounts) {
|
||||
if (acc.service && acc.account && validateBase32Secret(acc.secret)) {
|
||||
const signature = `${acc.service}:${acc.account}`;
|
||||
// 统一清洗:去掉 account 中可能带有的 Issuer: 前缀
|
||||
if (typeof acc.account === 'string' && acc.account.includes(':')) {
|
||||
acc.account = acc.account.split(':').pop()?.trim() || acc.account;
|
||||
}
|
||||
|
||||
const signature = this.normalizeSignature(acc.service, acc.account);
|
||||
if (!existingSet.has(signature) && !seenInBatch.has(signature)) {
|
||||
uniqueAccountsToInsert.push(acc);
|
||||
seenInBatch.add(signature);
|
||||
|
||||
@@ -61,10 +61,13 @@ export async function batchInsertVaultItems(
|
||||
};
|
||||
}));
|
||||
|
||||
// 2. 逐行写入:D1 每条 SQL 最多 100 个绑定参数,vault 表约 12 列
|
||||
// 单行插入 = 12 变量,远低于限制,彻底规避超限问题
|
||||
for (const item of preparedItems) {
|
||||
await dbClient.insert(schema.vault).values(item).run();
|
||||
// 2. 批量写入:使用 D1 的 batch 功能避免连接超时。
|
||||
// 分批写入,每批最多 50 条。
|
||||
const CHUNK_SIZE = 50;
|
||||
for (let i = 0; i < preparedItems.length; i += CHUNK_SIZE) {
|
||||
const chunk = preparedItems.slice(i, i + CHUNK_SIZE);
|
||||
const stmts = chunk.map(item => dbClient.insert(schema.vault).values(item));
|
||||
await dbClient.batch(stmts as any);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -90,6 +90,24 @@ export class VaultRepository {
|
||||
return result[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 service/account 查找记录 (大小写不敏感,自动 trim)
|
||||
*/
|
||||
async findByServiceAccount(service: string, account: string): Promise<VaultItem | undefined> {
|
||||
// 使用 lower() 函数进行数据库级别的大小写不敏感比较
|
||||
const normalizedService = service.trim().toLowerCase();
|
||||
const normalizedAccount = account.trim().toLowerCase();
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(vault)
|
||||
.where(
|
||||
sql`lower(${vault.service}) = ${normalizedService}`,
|
||||
sql`lower(${vault.account}) = ${normalizedAccount}`
|
||||
)
|
||||
.limit(1);
|
||||
return result[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个新 item
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
// 1. 金库项目表 (Vault Items -> mapped to 'vault' table in D1)
|
||||
// 注意:唯一索引在 D1 上需手动创建,通过迁移命令运行一次即可。
|
||||
// 该项目使用 SQLite schema 定义仅描述列,复杂索引/约束由数据库层管理。
|
||||
export const vault = sqliteTable('vault', {
|
||||
id: text('id').primaryKey(), // UUID
|
||||
service: text('service').notNull(),
|
||||
|
||||
+12
-7
@@ -10,21 +10,26 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@tanstack/vue-query": "^5.0.0",
|
||||
"element-plus": "^2.13.2",
|
||||
"@tanstack/vue-query": "^5.66.0",
|
||||
"element-plus": "^2.9.5",
|
||||
"jsqr": "^1.4.0",
|
||||
"pinia": "^3.0.4",
|
||||
"qrcode": "^1.5.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"scrypt-js": "^3.0.1",
|
||||
"sql.js": "^1.10.3",
|
||||
"vue": "^3.5.25",
|
||||
"vue-router": "^5.0.2"
|
||||
"vue-router": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/node": "^22.13.5",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"unplugin-auto-import": "^0.19.0",
|
||||
"unplugin-vue-components": "^0.26.0",
|
||||
"vite": "^6.0.0",
|
||||
"unplugin-vue-components": "^0.28.0",
|
||||
"vite": "^6.2.0",
|
||||
"vite-plugin-pwa": "^0.21.1"
|
||||
},
|
||||
"overrides": {
|
||||
"serialize-javascript": "^7.0.3"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.3 KiB |
Executable
BIN
Binary file not shown.
@@ -1,5 +1,5 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useUserStore } from '@/features/auth/store/userStore'
|
||||
import { useAuthUserStore } from '@/features/auth/store/authUserStore'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
@@ -33,17 +33,17 @@ const router = createRouter({
|
||||
|
||||
// 路由守卫:拦截未登录用户的访问
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const userStore = useUserStore()
|
||||
const authUserStore = useAuthUserStore()
|
||||
|
||||
// 1. 检查内存中是否有用户信息 (乐观检查)
|
||||
let isAuthenticated = !!(userStore.userInfo && userStore.userInfo.id)
|
||||
let isAuthenticated = !!(authUserStore.userInfo && authUserStore.userInfo.id)
|
||||
|
||||
// 2. 如果内存无状态,但目标页需要登录 OR 是仅游客页面(如登录页),尝试通过 Cookie 恢复会话
|
||||
// (Option 1B: 即使访问登录页,也先确认是否已登录,防止误判)
|
||||
if (!isAuthenticated && (to.meta.requiresAuth || to.meta.guestOnly)) {
|
||||
try {
|
||||
await userStore.fetchUserInfo()
|
||||
isAuthenticated = !!(userStore.userInfo && userStore.userInfo.id)
|
||||
await authUserStore.fetchUserInfo()
|
||||
isAuthenticated = !!(authUserStore.userInfo && authUserStore.userInfo.id)
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
|
||||
@@ -1165,4 +1165,11 @@ main {
|
||||
|
||||
.upload-placeholder:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* 修复 el-dialog 在移动端固定宽溢出 */
|
||||
.el-dialog {
|
||||
width: 90% !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthUserStore } from '@/features/auth/store/authUserStore'
|
||||
import { setIdbItem } from '@/shared/utils/idb'
|
||||
import { authService } from '@/features/auth/service/authService'
|
||||
|
||||
/**
|
||||
* 处理 OAuth 授权重定向回来的回调逻辑 (验证、防伪造、Token 兑换)
|
||||
*/
|
||||
export function useOAuthCallback() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const errorMsg = ref('')
|
||||
const providerName = ref('身份提供商')
|
||||
|
||||
onMounted(async () => {
|
||||
const code = route.query.code
|
||||
const state = route.query.state
|
||||
const error = route.query.error
|
||||
const hash = route.query.hash // Telegram 特有参数
|
||||
|
||||
// 1. 智能识别 Provider
|
||||
let providerId = route.params.provider
|
||||
if (!providerId) {
|
||||
if (hash) providerId = 'telegram'
|
||||
else providerId = localStorage.getItem('oauth_provider') || 'github'
|
||||
}
|
||||
|
||||
// 优化:优先从缓存读取提供商名称
|
||||
try {
|
||||
const cached = localStorage.getItem('oauth_providers_cache')
|
||||
if (cached) {
|
||||
const providers = JSON.parse(cached)
|
||||
const p = providers.find(x => x.id === providerId)
|
||||
if (p) providerName.value = p.name
|
||||
} else {
|
||||
const res = await authService.getProviders()
|
||||
const p = res.providers?.find(x => x.id === providerId)
|
||||
if (p) providerName.value = p.name
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore cache errors
|
||||
}
|
||||
|
||||
if (error) {
|
||||
errorMsg.value = route.query.error_description || `您拒绝了 ${providerName.value} 的授权请求`
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 参数结构校验 (策略分发)
|
||||
let payload = {}
|
||||
|
||||
if (providerId === 'telegram') {
|
||||
if (!hash) {
|
||||
errorMsg.value = 'Telegram 登录缺少签名参数'
|
||||
return
|
||||
}
|
||||
const savedState = localStorage.getItem('oauth_state')
|
||||
payload = { ...route.query, state: savedState }
|
||||
} else {
|
||||
if (!code || !state) {
|
||||
errorMsg.value = 'URL 缺少必要的授权凭证参数'
|
||||
return
|
||||
}
|
||||
|
||||
// 防御 CSRF:必须检查 State 匹不匹配
|
||||
const savedState = localStorage.getItem('oauth_state')
|
||||
if (!savedState || savedState !== state) {
|
||||
errorMsg.value = '安全警告:State 校验失败,请求可能被篡改'
|
||||
return
|
||||
}
|
||||
|
||||
const codeVerifier = localStorage.getItem('oauth_code_verifier')
|
||||
payload = { code, state, codeVerifier }
|
||||
}
|
||||
|
||||
// 无论成功与否,用过一次立马清除防止重放 (Replay Attack)
|
||||
localStorage.removeItem('oauth_state')
|
||||
localStorage.removeItem('oauth_provider')
|
||||
localStorage.removeItem('oauth_code_verifier')
|
||||
|
||||
try {
|
||||
const startTime = Date.now()
|
||||
// Telegram 用户体验优化:强制展示 1.5s 的 Loading 画风
|
||||
const MIN_DISPLAY_TIME = providerId === 'telegram' ? 1500 : 0
|
||||
|
||||
// 向后端服务进行 Token 兑换
|
||||
const data = await authService.loginWithToken(providerId, payload)
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
if (elapsed < MIN_DISPLAY_TIME) {
|
||||
await new Promise(resolve => setTimeout(resolve, MIN_DISPLAY_TIME - elapsed))
|
||||
}
|
||||
|
||||
if (data.success) {
|
||||
// 持久化设备指纹标识 (用于双端加密验证)
|
||||
if (data.deviceKey) {
|
||||
await setIdbItem('device_salt', data.deviceKey)
|
||||
}
|
||||
|
||||
// 更新状态机进入应用主流程
|
||||
const authUserStore = useAuthUserStore()
|
||||
await authUserStore.fetchUserInfo()
|
||||
router.push('/')
|
||||
} else {
|
||||
errorMsg.value = data.error || '登录验证被后端拒绝'
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('OAuth Callback Error:', err)
|
||||
// Error Managed by request interceptor and authError mapping
|
||||
errorMsg.value = err.message || '网络请求异常,请稍后再试'
|
||||
}
|
||||
})
|
||||
|
||||
const goBackToLogin = () => {
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
return {
|
||||
errorMsg,
|
||||
providerName,
|
||||
goBackToLogin
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { authService } from '@/features/auth/service/authService'
|
||||
|
||||
const CACHE_KEY = 'oauth_providers_cache'
|
||||
|
||||
/**
|
||||
* 处理 OAuth 提供商列表加载与授权重定向
|
||||
*/
|
||||
export function useOAuthProviders() {
|
||||
const loadingProvider = ref(null)
|
||||
const providers = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
// 1. 优先从缓存读取,实现秒开 (Offline-first / Stale-while-revalidate)
|
||||
const cached = localStorage.getItem(CACHE_KEY)
|
||||
if (cached) {
|
||||
try {
|
||||
providers.value = JSON.parse(cached)
|
||||
} catch (e) {
|
||||
console.warn('Invalid oauth providers cache', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 后台请求接口更新数据
|
||||
try {
|
||||
const data = await authService.getProviders()
|
||||
if (data.success) {
|
||||
providers.value = data.providers
|
||||
// 只有当后端返回了有效配置时才更新缓存
|
||||
if (data.providers && data.providers.length > 0) {
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(data.providers))
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to sync oauth providers:', e)
|
||||
}
|
||||
})
|
||||
|
||||
const handleLogin = async (providerId) => {
|
||||
if (loadingProvider.value) return
|
||||
loadingProvider.value = providerId
|
||||
|
||||
try {
|
||||
// 1. 获取授权链接及防篡改参数
|
||||
const data = await authService.getAuthorizeUrl(providerId)
|
||||
|
||||
if (data.success && data.authUrl) {
|
||||
// 2. 存储 state 防御 CSRF 并跳转
|
||||
localStorage.setItem('oauth_state', data.state)
|
||||
localStorage.setItem('oauth_provider', providerId)
|
||||
|
||||
// PKCE (Proof Key for Code Exchange) 支持
|
||||
if (data.codeVerifier) {
|
||||
localStorage.setItem('oauth_code_verifier', data.codeVerifier)
|
||||
}
|
||||
|
||||
window.location.href = data.authUrl
|
||||
} else {
|
||||
ElMessage.error(data.error || '获取授权链接失败: 提供商未配置或失效')
|
||||
}
|
||||
} catch (error) {
|
||||
// Error managed by axios request interceptor & authError
|
||||
ElMessage.error(error.message || '获取授权状态遭遇网络异常')
|
||||
} finally {
|
||||
// The loading state mostly clears when the page navigates away,
|
||||
// but we reset it here in case of errors.
|
||||
loadingProvider.value = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
providers,
|
||||
loadingProvider,
|
||||
handleLogin
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { request } from '@/shared/utils/request'
|
||||
import { authError } from '@/shared/utils/errors/authError'
|
||||
|
||||
/**
|
||||
* @typedef {Object} OAuthProvider
|
||||
* @property {string} id - 提供商标识 (如 'github', 'telegram')
|
||||
* @property {string} name - 提供商显示名称
|
||||
* @property {string} [icon] - SVG 图标
|
||||
* @property {string} [color] - 品牌主色调
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AuthorizeResponse
|
||||
* @property {boolean} success - 是否成功
|
||||
* @property {string} authUrl - 授权跳转链接
|
||||
* @property {string} state - 随机状态码 (CSRF防御)
|
||||
* @property {string} [codeVerifier] - PKCE验证码
|
||||
* @property {string} [error] - 错误信息
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} LoginResponse
|
||||
* @property {boolean} success - 是否成功
|
||||
* @property {string} [deviceKey] - 设备指纹标识 (用于后续验证)
|
||||
* @property {string} [error] - 错误信息
|
||||
*/
|
||||
|
||||
export const authService = {
|
||||
/**
|
||||
* 获取系统当前支持的 OAuth 登录提供商列表
|
||||
* @returns {Promise<{success: boolean, providers: OAuthProvider[]}>}
|
||||
* @throws {authError}
|
||||
*/
|
||||
async getProviders() {
|
||||
try {
|
||||
return await request('/api/oauth/providers')
|
||||
} catch (error) {
|
||||
throw new authError(error.message || '获取登录方式失败', error.status)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取指定提供商的授权重定向地址
|
||||
* @param {string} providerId - 提供商 ID
|
||||
* @returns {Promise<AuthorizeResponse>}
|
||||
* @throws {authError}
|
||||
*/
|
||||
async getAuthorizeUrl(providerId) {
|
||||
try {
|
||||
return await request(`/api/oauth/authorize/${providerId}`)
|
||||
} catch (error) {
|
||||
throw new authError(error.message || '获取授权链接失败', error.status)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 回调阶段:使用授权码 (Code) 兑换系统 JWT
|
||||
* @param {string} providerId - 提供商 ID
|
||||
* @param {Object} payload - 需要验证的载荷 (如 code, state, codeVerifier, hash)
|
||||
* @returns {Promise<LoginResponse>}
|
||||
* @throws {authError}
|
||||
*/
|
||||
async loginWithToken(providerId, payload) {
|
||||
try {
|
||||
return await request(`/api/oauth/callback/${providerId}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload) // request 工作器内部使用的是 fetch,需要手动 stringify
|
||||
})
|
||||
} catch (error) {
|
||||
throw new authError(error.message || '登录验证失败', error.status)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 退出登录,注销服务端 Session/Token
|
||||
* @returns {Promise<{success: boolean}>}
|
||||
*/
|
||||
async logout() {
|
||||
try {
|
||||
return await request('/api/oauth/logout', { method: 'POST' })
|
||||
} catch (error) {
|
||||
console.error('Logout request failed', error)
|
||||
return { success: false }
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取当前登录用户信息
|
||||
* @returns {Promise<{success: boolean, userInfo?: Object}>}
|
||||
*/
|
||||
async fetchMe() {
|
||||
try {
|
||||
// 加入时间戳防缓存,silent: true 防止请求拦截器弹出 message
|
||||
return await request(`/api/oauth/me?_t=${Date.now()}`, { silent: true })
|
||||
} catch (error) {
|
||||
return { success: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-17
@@ -1,10 +1,10 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { request } from '@/shared/utils/request'
|
||||
import { authService } from '@/features/auth/service/authService'
|
||||
import { useVaultStore } from '@/features/vault/store/vaultStore'
|
||||
import { removeIdbItem } from '@/shared/utils/idb'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
export const useAuthUserStore = defineStore('authUserInfo', () => {
|
||||
const getStoredUser = () => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem('userInfo') || '{}')
|
||||
@@ -38,27 +38,19 @@ export const useUserStore = defineStore('user', () => {
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await request('/api/oauth/logout', { method: 'POST' })
|
||||
} catch (e) {
|
||||
console.error('Logout request failed', e)
|
||||
} finally {
|
||||
await clearUserInfo()
|
||||
}
|
||||
await authService.logout()
|
||||
await clearUserInfo()
|
||||
}
|
||||
|
||||
const fetchUserInfo = async () => {
|
||||
try {
|
||||
const data = await request(`/api/oauth/me?_t=${Date.now()}`, { silent: true })
|
||||
if (data.success) {
|
||||
setUserInfo(data.userInfo)
|
||||
return true
|
||||
}
|
||||
} catch (e) {
|
||||
const data = await authService.fetchMe()
|
||||
if (data && data.success) {
|
||||
setUserInfo(data.userInfo)
|
||||
return true
|
||||
} else {
|
||||
await clearUserInfo()
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="logo-container">
|
||||
<el-icon :size="54" color="#409EFC"><Lock /></el-icon>
|
||||
<h2>2FAuth Worker</h2>
|
||||
<p class="subtitle">您的专属云端双因素认证管家</p>
|
||||
<p class="subtitle">A Secure 2FA Management Tool</p>
|
||||
</div>
|
||||
|
||||
<div class="action-container">
|
||||
@@ -19,8 +19,9 @@
|
||||
@click="handleLogin(provider.id)"
|
||||
>
|
||||
<template #icon>
|
||||
<el-icon v-if="provider.icon"><span v-html="provider.icon" style="display: flex;"></span></el-icon>
|
||||
<el-icon v-else><Platform /></el-icon>
|
||||
<el-icon>
|
||||
<component :is="iconComponents[provider.icon] || Platform" />
|
||||
</el-icon>
|
||||
</template>
|
||||
通过 {{ provider.name }} 授权登录
|
||||
</el-button>
|
||||
@@ -41,68 +42,27 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Lock, Platform } from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '@/features/auth/store/userStore'
|
||||
import iconGithub from '@/shared/components/icons/iconGithub.vue'
|
||||
import iconGoogle from '@/shared/components/icons/iconGoogle.vue'
|
||||
import iconGitee from '@/shared/components/icons/iconGitee.vue'
|
||||
import iconTelegram from '@/shared/components/icons/iconTelegram.vue'
|
||||
import iconCloudflare from '@/shared/components/icons/iconCloudflare.vue'
|
||||
import iconNodeloc from '@/shared/components/icons/iconNodeloc.vue'
|
||||
import { useOAuthProviders } from '@/features/auth/composables/useOAuthProviders'
|
||||
|
||||
const router = useRouter()
|
||||
const loadingProvider = ref(null)
|
||||
const providers = ref([])
|
||||
const CACHE_KEY = 'oauth_providers_cache'
|
||||
|
||||
onMounted(async () => {
|
||||
// 1. 优先从缓存读取,实现秒开
|
||||
const cached = localStorage.getItem(CACHE_KEY)
|
||||
if (cached) {
|
||||
try {
|
||||
providers.value = JSON.parse(cached)
|
||||
} catch (e) {
|
||||
console.warn('Invalid cache', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 后台请求接口更新数据 (Stale-while-revalidate 策略)
|
||||
try {
|
||||
const res = await fetch('/api/oauth/providers')
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
providers.value = data.providers
|
||||
// 只有当后端返回了有效的登录方式时才更新缓存,防止因配置丢失导致客户端缓存被清空
|
||||
if (data.providers && data.providers.length > 0) {
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(data.providers))
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load providers', e)
|
||||
}
|
||||
})
|
||||
|
||||
const handleLogin = async (providerId) => {
|
||||
loadingProvider.value = providerId
|
||||
try {
|
||||
// 1. 获取授权链接
|
||||
const response = await fetch(`/api/oauth/authorize/${providerId}`)
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success && data.authUrl) {
|
||||
// 2. 存储 state 防御 CSRF 并跳转
|
||||
localStorage.setItem('oauth_state', data.state)
|
||||
localStorage.setItem('oauth_provider', providerId)
|
||||
// 如果有 PKCE verifier,也存起来
|
||||
if (data.codeVerifier) {
|
||||
localStorage.setItem('oauth_code_verifier', data.codeVerifier)
|
||||
}
|
||||
window.location.href = data.authUrl
|
||||
} else {
|
||||
ElMessage.error(data.error || '获取授权链接失败')
|
||||
loadingProvider.value = null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error)
|
||||
ElMessage.error('网络请求失败,请检查后端 API 是否正常运行')
|
||||
loadingProvider.value = null
|
||||
}
|
||||
const iconComponents = {
|
||||
iconGithub,
|
||||
iconGoogle,
|
||||
iconGitee,
|
||||
iconTelegram,
|
||||
iconCloudflare,
|
||||
iconNodeloc,
|
||||
}
|
||||
|
||||
const {
|
||||
providers,
|
||||
loadingProvider,
|
||||
handleLogin
|
||||
} = useOAuthProviders()
|
||||
</script>
|
||||
@@ -27,127 +27,12 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '@/features/auth/store/userStore'
|
||||
import { setIdbItem } from '@/shared/utils/idb'
|
||||
import { useOAuthCallback } from '@/features/auth/composables/useOAuthCallback'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const errorMsg = ref('')
|
||||
const providerName = ref('身份提供商')
|
||||
|
||||
onMounted(async () => {
|
||||
const code = route.query.code
|
||||
const state = route.query.state
|
||||
const error = route.query.error
|
||||
const hash = route.query.hash // Telegram 特有参数
|
||||
|
||||
// 1. 智能识别 Provider
|
||||
// 优先使用路由参数 (如 /callback/telegram),其次检测 Telegram 特征,最后使用缓存
|
||||
let providerId = route.params.provider
|
||||
if (!providerId) {
|
||||
if (hash) providerId = 'telegram'
|
||||
else providerId = localStorage.getItem('oauth_provider') || 'github'
|
||||
}
|
||||
|
||||
// 优化:优先从缓存读取提供商名称,避免不必要的网络请求
|
||||
try {
|
||||
const cached = localStorage.getItem('oauth_providers_cache')
|
||||
if (cached) {
|
||||
const providers = JSON.parse(cached)
|
||||
const p = providers.find(x => x.id === providerId)
|
||||
if (p) providerName.value = p.name
|
||||
} else {
|
||||
const res = await fetch('/api/oauth/providers')
|
||||
const data = await res.json()
|
||||
const p = data.providers?.find(x => x.id === providerId)
|
||||
if (p) providerName.value = p.name
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
if (error) {
|
||||
errorMsg.value = route.query.error_description || `您拒绝了 ${providerName.value} 的授权请求`
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 参数校验 (区分 Telegram 和标准 OAuth)
|
||||
let payload = {}
|
||||
|
||||
if (providerId === 'telegram') {
|
||||
if (!hash) {
|
||||
errorMsg.value = 'Telegram 登录缺少签名参数'
|
||||
return
|
||||
}
|
||||
// Telegram 默认不原样返回 state 参数,因此我们从前端缓存中将其补齐发送给后端校验
|
||||
const savedState = localStorage.getItem('oauth_state')
|
||||
payload = { ...route.query, state: savedState }
|
||||
} else {
|
||||
if (!code || !state) {
|
||||
errorMsg.value = 'URL 缺少必要的授权凭证参数'
|
||||
return
|
||||
}
|
||||
|
||||
// 🛡️ 前端 State 校验:防止 CSRF 攻击
|
||||
const savedState = localStorage.getItem('oauth_state')
|
||||
if (!savedState || savedState !== state) {
|
||||
errorMsg.value = '安全警告:State 校验失败,请求可能被篡改'
|
||||
return
|
||||
}
|
||||
|
||||
const codeVerifier = localStorage.getItem('oauth_code_verifier')
|
||||
payload = { code, state, codeVerifier }
|
||||
}
|
||||
|
||||
localStorage.removeItem('oauth_state') // 校验通过后立即清除,防止重放
|
||||
localStorage.removeItem('oauth_provider')
|
||||
localStorage.removeItem('oauth_code_verifier')
|
||||
|
||||
try {
|
||||
// 记录开始时间,确保加载动画至少展示一段时间
|
||||
const startTime = Date.now()
|
||||
// 只有 Telegram 才强制等待 1.5s,其他方式保持原速
|
||||
const MIN_DISPLAY_TIME = providerId === 'telegram' ? 1500 : 0
|
||||
|
||||
// 关键步骤:把 code 发给后端换取我们自己的 JWT
|
||||
const response = await fetch(`/api/oauth/callback/${providerId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// 计算耗时,如果过快则强制等待,优化用户体验
|
||||
const elapsed = Date.now() - startTime
|
||||
if (elapsed < MIN_DISPLAY_TIME) {
|
||||
await new Promise(resolve => setTimeout(resolve, MIN_DISPLAY_TIME - elapsed))
|
||||
}
|
||||
|
||||
if (data.success) {
|
||||
// 登录成功!Token 已写入 httpOnly Cookie
|
||||
// 持久化客户端参数
|
||||
if (data.deviceKey) {
|
||||
await setIdbItem('device_salt', data.deviceKey)
|
||||
}
|
||||
|
||||
// 立即调用接口确认会话有效性,并更新用户信息
|
||||
const userStore = useUserStore()
|
||||
await userStore.fetchUserInfo()
|
||||
|
||||
// 一把推开大门,进入主界面
|
||||
router.push('/')
|
||||
} else {
|
||||
errorMsg.value = data.error || '登录验证被后端拒绝'
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('OAuth Callback Error:', err)
|
||||
errorMsg.value = '网络请求异常,请稍后再试'
|
||||
}
|
||||
})
|
||||
|
||||
const goBackToLogin = () => {
|
||||
router.push('/login')
|
||||
}
|
||||
const {
|
||||
errorMsg,
|
||||
providerName,
|
||||
goBackToLogin
|
||||
} = useOAuthCallback()
|
||||
</script>
|
||||
@@ -193,67 +193,26 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, CircleCheck, Timer, Loading } from '@element-plus/icons-vue'
|
||||
import { request } from '@/shared/utils/request'
|
||||
import { useLayoutStore } from '@/shared/stores/layoutStore'
|
||||
import { useVaultStore } from '@/features/vault/store/vaultStore'
|
||||
import { migrationService } from '@/features/backup/service/migrationService'
|
||||
import { useBackupProviders } from '@/features/backup/composables/useBackupProviders'
|
||||
import { useBackupActions } from '@/features/backup/composables/useBackupActions'
|
||||
|
||||
const emit = defineEmits(['restore-success'])
|
||||
const layoutStore = useLayoutStore()
|
||||
const vaultStore = useVaultStore()
|
||||
|
||||
// --- 状态定义 ---
|
||||
const providers = ref([])
|
||||
const isLoading = ref(false)
|
||||
const {
|
||||
providers, isLoading, showConfigDialog, isEditing, isTesting, isSaving, form,
|
||||
hasExistingAutoPwd, configUseExistingAutoPwd, fetchProviders, openAddDialog,
|
||||
editProvider, testConnection, saveProvider, deleteProvider
|
||||
} = useBackupProviders()
|
||||
|
||||
// 配置弹窗
|
||||
const showConfigDialog = ref(false)
|
||||
const isEditing = ref(false)
|
||||
const isTesting = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const form = ref({ type: 'webdav', name: '', config: { url: '', username: '', password: '', saveDir: '/', endpoint: '', bucket: '', region: 'auto', accessKeyId: '', secretAccessKey: '' }, autoBackup: false, autoBackupPassword: '', autoBackupRetain: 30 })
|
||||
const currentProviderId = ref(null)
|
||||
const hasExistingAutoPwd = ref(false)
|
||||
const configUseExistingAutoPwd = ref(false)
|
||||
|
||||
// 备份/恢复弹窗
|
||||
const showBackupDialog = ref(false)
|
||||
const backupPassword = ref('')
|
||||
const isBackingUp = ref(false)
|
||||
const useAutoPassword = ref(false)
|
||||
const currentActionProvider = ref(null)
|
||||
|
||||
const showRestoreListDialog = ref(false)
|
||||
const isLoadingFiles = ref(false)
|
||||
const backupFiles = ref([])
|
||||
const showRestoreConfirmDialog = ref(false)
|
||||
const restorePassword = ref('')
|
||||
const selectedFile = ref(null)
|
||||
const isRestoring = ref(false)
|
||||
|
||||
// --- API 交互 ---
|
||||
const fetchProviders = async () => {
|
||||
// 1. 离线优先:尝试加载加密缓存
|
||||
const cachedEncrypted = await vaultStore.getEncryptedBackupProviders()
|
||||
if (cachedEncrypted && Array.isArray(cachedEncrypted)) {
|
||||
providers.value = cachedEncrypted
|
||||
// 有缓存时静默更新,不转圈圈
|
||||
} else {
|
||||
isLoading.value = true
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await request('/api/backups/providers')
|
||||
if (res.success) {
|
||||
providers.value = res.providers
|
||||
// 2. 更新加密缓存
|
||||
await vaultStore.saveEncryptedBackupProviders(res.providers)
|
||||
}
|
||||
} finally { isLoading.value = false }
|
||||
}
|
||||
const {
|
||||
showBackupDialog, backupPassword, isBackingUp, useAutoPassword, currentActionProvider,
|
||||
openBackupDialog, handleBackup, showRestoreListDialog, isLoadingFiles, backupFiles,
|
||||
showRestoreConfirmDialog, restorePassword, selectedFile, isRestoring, openRestoreDialog,
|
||||
selectRestoreFile, handleRestore
|
||||
} = useBackupActions(emit, fetchProviders)
|
||||
|
||||
const getProviderTypeTag = (type) => type === 'webdav' ? 'primary' : (type === 's3' ? 'warning' : 'info')
|
||||
|
||||
@@ -264,179 +223,68 @@ const formatSize = (bytes) => {
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
</script>
|
||||
|
||||
const openAddDialog = () => {
|
||||
isEditing.value = false
|
||||
form.value = { type: 'webdav', name: '', config: { url: '', username: '', password: '', saveDir: '/', endpoint: '', bucket: '', region: 'auto', accessKeyId: '', secretAccessKey: '' }, autoBackup: false, autoBackupPassword: '', autoBackupRetain: 30 }
|
||||
hasExistingAutoPwd.value = false
|
||||
configUseExistingAutoPwd.value = false
|
||||
showConfigDialog.value = true
|
||||
<style scoped>
|
||||
.backup-container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
const editProvider = (provider) => {
|
||||
isEditing.value = true
|
||||
currentProviderId.value = provider.id
|
||||
form.value = JSON.parse(JSON.stringify({
|
||||
type: provider.type,
|
||||
name: provider.name,
|
||||
config: provider.config,
|
||||
autoBackup: !!provider.auto_backup,
|
||||
autoBackupPassword: '', // 编辑时不回显密码,留空表示不修改
|
||||
autoBackupRetain: provider.auto_backup_retain ?? 30
|
||||
}))
|
||||
hasExistingAutoPwd.value = !!provider.auto_backup_password
|
||||
configUseExistingAutoPwd.value = true // 默认保持原密码
|
||||
showConfigDialog.value = true
|
||||
.header-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
const validateForm = () => {
|
||||
if (!form.value.name) return '请输入名称'
|
||||
const c = form.value.config
|
||||
if (form.value.type === 'webdav') {
|
||||
if (!c.url) return '请输入 WebDAV 地址'
|
||||
if (!c.username) return '请输入用户名'
|
||||
if (!c.password) return '请输入密码'
|
||||
} 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 (form.value.autoBackup) {
|
||||
// 如果是编辑模式且有旧密码,并且用户选择保持原密码,则跳过校验
|
||||
if (isEditing.value && hasExistingAutoPwd.value && configUseExistingAutoPwd.value) {
|
||||
return null
|
||||
}
|
||||
// 其他情况(新增、编辑无旧密码、编辑选择设置新密码)都必须校验
|
||||
if (!form.value.autoBackupPassword || form.value.autoBackupPassword.length < 12) {
|
||||
return '自动备份密码长度必须至少 12 位'
|
||||
}
|
||||
}
|
||||
return null
|
||||
.provider-card {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
const testConnection = async () => {
|
||||
const error = validateForm()
|
||||
if (error) return ElMessage.warning(error)
|
||||
|
||||
isTesting.value = true
|
||||
try {
|
||||
const res = await request('/api/backups/providers/test', {
|
||||
method: 'POST', body: JSON.stringify({ type: form.value.type, config: form.value.config })
|
||||
})
|
||||
if (res.success) ElMessage.success('连接成功')
|
||||
} catch (e) {} finally { isTesting.value = false }
|
||||
.provider-card:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
const saveProvider = async () => {
|
||||
const error = validateForm()
|
||||
if (error) return ElMessage.warning(error)
|
||||
|
||||
// 若保持原密码,清空密码字段避免覆盖
|
||||
if (isEditing.value && hasExistingAutoPwd.value && configUseExistingAutoPwd.value) {
|
||||
form.value.autoBackupPassword = ''
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
const url = isEditing.value ? `/api/backups/providers/${currentProviderId.value}` : '/api/backups/providers'
|
||||
const method = isEditing.value ? 'PUT' : 'POST'
|
||||
const res = await request(url, { method, body: JSON.stringify(form.value) })
|
||||
if (res.success) {
|
||||
ElMessage.success('保存成功')
|
||||
showConfigDialog.value = false
|
||||
fetchProviders()
|
||||
}
|
||||
} catch (e) {} finally { isSaving.value = false }
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
const deleteProvider = async (provider) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除该备份源吗?', '警告', { type: 'warning' })
|
||||
await request(`/api/backups/providers/${provider.id}`, { method: 'DELETE' })
|
||||
fetchProviders()
|
||||
} catch (e) {}
|
||||
.provider-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
const openBackupDialog = (provider) => {
|
||||
currentActionProvider.value = provider
|
||||
backupPassword.value = ''
|
||||
useAutoPassword.value = !!provider.auto_backup
|
||||
showBackupDialog.value = true
|
||||
.provider-name {
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
const handleBackup = async () => {
|
||||
if (!useAutoPassword.value && backupPassword.value.length < 12) {
|
||||
return ElMessage.warning('密码至少12位')
|
||||
}
|
||||
|
||||
const pwdToSend = useAutoPassword.value ? '' : backupPassword.value
|
||||
|
||||
isBackingUp.value = true
|
||||
try {
|
||||
const res = await request(`/api/backups/providers/${currentActionProvider.value.id}/backup`, {
|
||||
method: 'POST', body: JSON.stringify({ password: pwdToSend })
|
||||
})
|
||||
if (res.success) {
|
||||
ElMessage.success('备份成功')
|
||||
showBackupDialog.value = false
|
||||
fetchProviders() // Refresh status
|
||||
}
|
||||
} catch (e) {} finally { isBackingUp.value = false }
|
||||
.status-text {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
const openRestoreDialog = async (provider) => {
|
||||
currentActionProvider.value = provider
|
||||
showRestoreListDialog.value = true
|
||||
isLoadingFiles.value = true
|
||||
try {
|
||||
const res = await request(`/api/backups/providers/${provider.id}/files`)
|
||||
if (res.success) backupFiles.value = res.files
|
||||
} finally { isLoadingFiles.value = false }
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
const selectRestoreFile = (file) => {
|
||||
selectedFile.value = file
|
||||
restorePassword.value = ''
|
||||
showRestoreConfirmDialog.value = true
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: 5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
const handleRestore = async () => {
|
||||
isRestoring.value = true
|
||||
try {
|
||||
// 1. 下载加密文件
|
||||
const downloadRes = await request(`/api/backups/providers/${currentActionProvider.value.id}/download`, {
|
||||
method: 'POST', body: JSON.stringify({
|
||||
filename: selectedFile.value.filename
|
||||
})
|
||||
})
|
||||
|
||||
// 2. 前端解密
|
||||
let contentToDecrypt = downloadRes.content
|
||||
try {
|
||||
// 备份文件是 JSON 包装格式,需要提取内部的 data 字段 (Base64)
|
||||
// 修复:如果 contentToDecrypt 已经是对象,直接使用,避免 JSON.parse([object Object]) 报错
|
||||
const json = typeof contentToDecrypt === 'string' ? JSON.parse(contentToDecrypt) : contentToDecrypt
|
||||
if (json && json.encrypted && json.data) {
|
||||
contentToDecrypt = json.data
|
||||
}
|
||||
} catch (e) {}
|
||||
const vault = await migrationService.parseImportData(contentToDecrypt, 'encrypted', restorePassword.value)
|
||||
|
||||
// 3. 保存数据
|
||||
const saveRes = await migrationService.saveImportedVault(vault)
|
||||
|
||||
if (saveRes.success) {
|
||||
ElMessage.success(`恢复成功,已导入 ${saveRes.count} 个账号`)
|
||||
showRestoreConfirmDialog.value = false
|
||||
showRestoreListDialog.value = false
|
||||
emit('restore-success')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '恢复失败')
|
||||
} finally { isRestoring.value = false }
|
||||
.success-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--el-color-success);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
onMounted(fetchProviders)
|
||||
</script>
|
||||
</style>
|
||||
@@ -0,0 +1,146 @@
|
||||
import { ref } from 'vue'
|
||||
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'
|
||||
|
||||
export function useBackupActions(emit, fetchProviders) {
|
||||
const vaultStore = useVaultStore()
|
||||
|
||||
// Backup Action State
|
||||
const showBackupDialog = ref(false)
|
||||
const backupPassword = ref('')
|
||||
const isBackingUp = ref(false)
|
||||
const useAutoPassword = ref(false)
|
||||
const currentActionProvider = ref(null)
|
||||
|
||||
// Restore Action State
|
||||
const showRestoreListDialog = ref(false)
|
||||
const isLoadingFiles = ref(false)
|
||||
const backupFiles = ref([])
|
||||
const showRestoreConfirmDialog = ref(false)
|
||||
const restorePassword = ref('')
|
||||
const selectedFile = ref(null)
|
||||
const isRestoring = ref(false)
|
||||
|
||||
// Backup methods
|
||||
const openBackupDialog = (provider) => {
|
||||
currentActionProvider.value = provider
|
||||
backupPassword.value = ''
|
||||
useAutoPassword.value = !!provider.auto_backup
|
||||
showBackupDialog.value = true
|
||||
}
|
||||
|
||||
const handleBackup = async () => {
|
||||
if (!useAutoPassword.value && backupPassword.value.length < 12) {
|
||||
return ElMessage.warning('密码至少12位')
|
||||
}
|
||||
|
||||
const pwdToSend = useAutoPassword.value ? '' : backupPassword.value
|
||||
|
||||
isBackingUp.value = true
|
||||
try {
|
||||
const res = await backupService.triggerBackup(currentActionProvider.value.id, pwdToSend)
|
||||
if (res.success) {
|
||||
ElNotification({
|
||||
title: '云端备份完成',
|
||||
message: `<div style="color:var(--el-color-success)">🎉 成功备份了数据</div>`,
|
||||
dangerouslyUseHTMLString: true,
|
||||
type: 'success',
|
||||
duration: 5000
|
||||
})
|
||||
showBackupDialog.value = false
|
||||
if (fetchProviders) await fetchProviders()
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '备份失败')
|
||||
} finally { isBackingUp.value = false }
|
||||
}
|
||||
|
||||
// Restore methods
|
||||
const openRestoreDialog = async (provider) => {
|
||||
currentActionProvider.value = provider
|
||||
showRestoreListDialog.value = true
|
||||
isLoadingFiles.value = true
|
||||
try {
|
||||
const res = await backupService.getBackupFiles(provider.id)
|
||||
if (res.success) backupFiles.value = res.files
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '获取备份文件失败')
|
||||
} finally { isLoadingFiles.value = false }
|
||||
}
|
||||
|
||||
const selectRestoreFile = (file) => {
|
||||
selectedFile.value = file
|
||||
restorePassword.value = ''
|
||||
showRestoreConfirmDialog.value = true
|
||||
}
|
||||
|
||||
const handleRestore = async () => {
|
||||
isRestoring.value = true
|
||||
try {
|
||||
const downloadRes = await backupService.downloadBackupFile(currentActionProvider.value.id, selectedFile.value.filename)
|
||||
|
||||
let contentToDecrypt = downloadRes.content
|
||||
try {
|
||||
const json = typeof contentToDecrypt === 'string' ? JSON.parse(contentToDecrypt) : contentToDecrypt
|
||||
if (json && json.encrypted && json.data) {
|
||||
contentToDecrypt = json.data
|
||||
}
|
||||
} catch (e) { }
|
||||
|
||||
const vault = await dataMigrationService.parseImportData(contentToDecrypt, 'encrypted', restorePassword.value)
|
||||
const saveRes = await dataMigrationService.saveImportedVault(vault)
|
||||
|
||||
if (saveRes.success) {
|
||||
let msgHtml = `<div>共处理 <b>1</b> 个备份文件 (${vault.length} 个基准账号)。</div>`
|
||||
if (saveRes.count > 0) {
|
||||
msgHtml += `<div style="color:var(--el-color-success)">🎉 成功导入 <b>${saveRes.count}</b> 个新账户!</div>`
|
||||
} else {
|
||||
msgHtml += `<div style="color:var(--el-color-warning)">⚠️ 恢复的账户皆已存在,无新添项。</div>`
|
||||
}
|
||||
if (saveRes.duplicates > 0) msgHtml += `<div style="color:var(--el-text-color-secondary)">ℹ️ 自动跳过了 <b>${saveRes.duplicates}</b> 个已有账户。</div>`
|
||||
|
||||
ElNotification({
|
||||
title: '恢复数据结束',
|
||||
message: msgHtml,
|
||||
dangerouslyUseHTMLString: true,
|
||||
type: 'success',
|
||||
duration: 8000
|
||||
})
|
||||
|
||||
showRestoreConfirmDialog.value = false
|
||||
showRestoreListDialog.value = false
|
||||
if (saveRes.count > 0) {
|
||||
vaultStore.markDirty()
|
||||
emit('restore-success')
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '恢复失败')
|
||||
} finally { isRestoring.value = false }
|
||||
}
|
||||
|
||||
return {
|
||||
// Backup
|
||||
showBackupDialog,
|
||||
backupPassword,
|
||||
isBackingUp,
|
||||
useAutoPassword,
|
||||
currentActionProvider,
|
||||
openBackupDialog,
|
||||
handleBackup,
|
||||
|
||||
// Restore
|
||||
showRestoreListDialog,
|
||||
isLoadingFiles,
|
||||
backupFiles,
|
||||
showRestoreConfirmDialog,
|
||||
restorePassword,
|
||||
selectedFile,
|
||||
isRestoring,
|
||||
openRestoreDialog,
|
||||
selectRestoreFile,
|
||||
handleRestore
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
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'
|
||||
|
||||
export function useBackupProviders() {
|
||||
const vaultStore = useVaultStore()
|
||||
|
||||
const providers = ref([])
|
||||
const isLoading = ref(false)
|
||||
|
||||
// Form and Dialog State
|
||||
const showConfigDialog = ref(false)
|
||||
const isEditing = ref(false)
|
||||
const isTesting = ref(false)
|
||||
const isSaving = ref(false)
|
||||
|
||||
const initialFormState = () => ({
|
||||
type: 'webdav',
|
||||
name: '',
|
||||
config: { url: '', username: '', password: '', saveDir: '/', endpoint: '', bucket: '', region: 'auto', accessKeyId: '', secretAccessKey: '' },
|
||||
autoBackup: false,
|
||||
autoBackupPassword: '',
|
||||
autoBackupRetain: 30
|
||||
})
|
||||
|
||||
const form = ref(initialFormState())
|
||||
const currentProviderId = ref(null)
|
||||
const hasExistingAutoPwd = ref(false)
|
||||
const configUseExistingAutoPwd = ref(false)
|
||||
|
||||
// Methods
|
||||
const fetchProviders = async () => {
|
||||
const cachedEncrypted = await vaultStore.getEncryptedBackupProviders()
|
||||
if (cachedEncrypted && Array.isArray(cachedEncrypted)) {
|
||||
providers.value = cachedEncrypted
|
||||
} else {
|
||||
isLoading.value = true
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await backupService.getProviders()
|
||||
if (res.success) {
|
||||
providers.value = res.providers
|
||||
await vaultStore.saveEncryptedBackupProviders(res.providers)
|
||||
}
|
||||
} finally { isLoading.value = false }
|
||||
}
|
||||
|
||||
const openAddDialog = () => {
|
||||
isEditing.value = false
|
||||
form.value = initialFormState()
|
||||
hasExistingAutoPwd.value = false
|
||||
configUseExistingAutoPwd.value = false
|
||||
showConfigDialog.value = true
|
||||
}
|
||||
|
||||
const editProvider = (provider) => {
|
||||
isEditing.value = true
|
||||
currentProviderId.value = provider.id
|
||||
form.value = JSON.parse(JSON.stringify({
|
||||
type: provider.type,
|
||||
name: provider.name,
|
||||
config: provider.config,
|
||||
autoBackup: !!provider.auto_backup,
|
||||
autoBackupPassword: '',
|
||||
autoBackupRetain: provider.auto_backup_retain ?? 30
|
||||
}))
|
||||
hasExistingAutoPwd.value = !!provider.auto_backup_password
|
||||
configUseExistingAutoPwd.value = true
|
||||
showConfigDialog.value = true
|
||||
}
|
||||
|
||||
const validateForm = () => {
|
||||
if (!form.value.name) return '请输入名称'
|
||||
const c = form.value.config
|
||||
if (form.value.type === 'webdav') {
|
||||
if (!c.url) return '请输入 WebDAV 地址'
|
||||
if (!c.username) return '请输入用户名'
|
||||
if (!c.password) return '请输入密码'
|
||||
} 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 (form.value.autoBackup) {
|
||||
if (isEditing.value && hasExistingAutoPwd.value && configUseExistingAutoPwd.value) {
|
||||
return null
|
||||
}
|
||||
if (!form.value.autoBackupPassword || form.value.autoBackupPassword.length < 12) {
|
||||
return '自动备份密码长度必须至少 12 位'
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const testConnection = async () => {
|
||||
const error = validateForm()
|
||||
if (error) return ElMessage.warning(error)
|
||||
|
||||
isTesting.value = true
|
||||
try {
|
||||
const res = await backupService.testConnection(form.value.type, form.value.config)
|
||||
if (res.success) ElMessage.success('连接成功')
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '连接失败')
|
||||
} finally { isTesting.value = false }
|
||||
}
|
||||
|
||||
const saveProvider = async () => {
|
||||
const error = validateForm()
|
||||
if (error) return ElMessage.warning(error)
|
||||
|
||||
if (isEditing.value && hasExistingAutoPwd.value && configUseExistingAutoPwd.value) {
|
||||
form.value.autoBackupPassword = ''
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
const res = isEditing.value
|
||||
? await backupService.updateProvider(currentProviderId.value, form.value)
|
||||
: await backupService.createProvider(form.value)
|
||||
if (res.success) {
|
||||
ElMessage.success('保存成功')
|
||||
showConfigDialog.value = false
|
||||
await fetchProviders()
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '保存失败')
|
||||
} finally { isSaving.value = false }
|
||||
}
|
||||
|
||||
const deleteProvider = async (provider) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除该备份源吗?', '警告', { type: 'warning' })
|
||||
await backupService.deleteProvider(provider.id)
|
||||
await fetchProviders()
|
||||
} catch (e) {
|
||||
if (e !== 'cancel') {
|
||||
ElMessage.error(e.message || '删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchProviders)
|
||||
|
||||
return {
|
||||
providers,
|
||||
isLoading,
|
||||
showConfigDialog,
|
||||
isEditing,
|
||||
isTesting,
|
||||
isSaving,
|
||||
form,
|
||||
hasExistingAutoPwd,
|
||||
configUseExistingAutoPwd,
|
||||
fetchProviders,
|
||||
openAddDialog,
|
||||
editProvider,
|
||||
testConnection,
|
||||
saveProvider,
|
||||
deleteProvider
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { request } from '@/shared/utils/request'
|
||||
import { backupError } from '@/shared/utils/errors/backupError'
|
||||
|
||||
/**
|
||||
* @typedef {Object} BackupProviderConfig
|
||||
* @property {string} url - WebDAV URL
|
||||
* @property {string} username - WebDAV Username
|
||||
* @property {string} password - WebDAV Password
|
||||
* @property {string} saveDir - Save Directory
|
||||
* @property {string} endpoint - S3 Endpoint
|
||||
* @property {string} bucket - S3 Bucket
|
||||
* @property {string} region - S3 Region
|
||||
* @property {string} accessKeyId - S3 Access Key ID
|
||||
* @property {string} secretAccessKey - S3 Secret Access Key
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} BackupProvider
|
||||
* @property {string} [id] - Provider ID
|
||||
* @property {string} name - Provider Name
|
||||
* @property {string} type - Provider Type ('webdav' | 's3')
|
||||
* @property {BackupProviderConfig} config - Provider Configuration
|
||||
* @property {boolean} auto_backup - Auto backup enabled
|
||||
* @property {string} [auto_backup_password] - Auto backup encryption password
|
||||
* @property {number} [auto_backup_retain] - Number of backups to retain
|
||||
*/
|
||||
|
||||
export const backupService = {
|
||||
/**
|
||||
* 获取所有备份源配置
|
||||
* @returns {Promise<{success: boolean, providers: BackupProvider[]}>}
|
||||
* @throws {backupError}
|
||||
*/
|
||||
async getProviders() {
|
||||
try {
|
||||
return await request('/api/backups/providers')
|
||||
} catch (e) {
|
||||
throw new backupError('Failed to fetch backup providers', 'PROVIDERS_FETCH_FAILED', e)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 测试备份源连接
|
||||
* @param {string} type - 'webdav' 或 's3'
|
||||
* @param {BackupProviderConfig} config
|
||||
* @returns {Promise<{success: boolean}>}
|
||||
* @throws {backupError}
|
||||
*/
|
||||
async testConnection(type, config) {
|
||||
try {
|
||||
return await request('/api/backups/providers/test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ type, config })
|
||||
})
|
||||
} catch (e) {
|
||||
throw new backupError('Failed to test connection', 'CONNECTION_TEST_FAILED', e)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建新的备份源
|
||||
* @param {BackupProvider} providerData
|
||||
* @returns {Promise<{success: boolean, provider: BackupProvider}>}
|
||||
* @throws {backupError}
|
||||
*/
|
||||
async createProvider(providerData) {
|
||||
try {
|
||||
return await request('/api/backups/providers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(providerData)
|
||||
})
|
||||
} catch (e) {
|
||||
throw new backupError('Failed to create provider', 'PROVIDER_CREATE_FAILED', e)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新现有备份源
|
||||
* @param {string} id
|
||||
* @param {BackupProvider} providerData
|
||||
* @returns {Promise<{success: boolean}>}
|
||||
* @throws {backupError}
|
||||
*/
|
||||
async updateProvider(id, providerData) {
|
||||
try {
|
||||
return await request(`/api/backups/providers/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(providerData)
|
||||
})
|
||||
} catch (e) {
|
||||
throw new backupError('Failed to update provider', 'PROVIDER_UPDATE_FAILED', e)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除备份源
|
||||
* @param {string} id
|
||||
* @returns {Promise<{success: boolean}>}
|
||||
* @throws {backupError}
|
||||
*/
|
||||
async deleteProvider(id) {
|
||||
try {
|
||||
return await request(`/api/backups/providers/${id}`, { method: 'DELETE' })
|
||||
} catch (e) {
|
||||
throw new backupError('Failed to delete provider', 'PROVIDER_DELETE_FAILED', e)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 手动触发一次备份
|
||||
* @param {string} id
|
||||
* @param {string} password - 备份加密密码 (若使用自动备份密码则为空)
|
||||
* @returns {Promise<{success: boolean}>}
|
||||
* @throws {backupError}
|
||||
*/
|
||||
async triggerBackup(id, password = '') {
|
||||
try {
|
||||
return await request(`/api/backups/providers/${id}/backup`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password })
|
||||
})
|
||||
} catch (e) {
|
||||
throw new backupError('Failed to trigger backup', 'BACKUP_TRIGGER_FAILED', e)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取备份历史文件列表
|
||||
* @param {string} id
|
||||
* @returns {Promise<{success: boolean, files: Object[]}>}
|
||||
* @throws {backupError}
|
||||
*/
|
||||
async getBackupFiles(id) {
|
||||
try {
|
||||
return await request(`/api/backups/providers/${id}/files`)
|
||||
} catch (e) {
|
||||
throw new backupError('Failed to fetch backup files', 'FILES_FETCH_FAILED', e)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 下载加密的备份文件
|
||||
* @param {string} id
|
||||
* @param {string} filename
|
||||
* @returns {Promise<{success: boolean, content: string|Object}>}
|
||||
* @throws {backupError}
|
||||
*/
|
||||
async downloadBackupFile(id, filename) {
|
||||
try {
|
||||
return await request(`/api/backups/providers/${id}/download`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ filename })
|
||||
})
|
||||
} catch (e) {
|
||||
throw new backupError('Failed to download backup file', 'FILE_DOWNLOAD_FAILED', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
import { request } from '@/shared/utils/request'
|
||||
import { encryptDataWithPassword, decryptDataWithPassword } from '@/shared/utils/crypto' // 现在已指向标准实现
|
||||
import { parseOtpUri } from '@/shared/utils/totp'
|
||||
|
||||
export const migrationService = {
|
||||
// 1. 获取所有数据 (用于导出)
|
||||
async fetchAllVault() {
|
||||
// 请求后端获取所有数据 (limit=9999)
|
||||
const res = await request('/api/vault?limit=9999')
|
||||
if (!res.success) throw new Error('无法获取账号数据')
|
||||
return res.vault || []
|
||||
},
|
||||
|
||||
// 2. 处理导出逻辑 (前端加密/格式化)
|
||||
async exportData(vault, type, password) {
|
||||
const timestamp = new Date().toISOString()
|
||||
const baseData = { version: "2.0", app: "2fauth", timestamp }
|
||||
|
||||
// 格式 A: 本系统加密备份 (使用 crypto-vault AES-GCM)
|
||||
if (type === 'encrypted') {
|
||||
if (!password) throw new Error('加密导出需要密码')
|
||||
const payload = { ...baseData, accounts: vault }
|
||||
const encryptedData = await encryptDataWithPassword(payload, password)
|
||||
return JSON.stringify({
|
||||
...baseData,
|
||||
encrypted: true,
|
||||
data: encryptedData,
|
||||
note: "This file is encrypted with your export password (AES-GCM-256 + PBKDF2)."
|
||||
}, null, 2)
|
||||
}
|
||||
|
||||
// 格式 B: 标准 JSON
|
||||
if (type === 'json') {
|
||||
return JSON.stringify({ ...baseData, encrypted: false, accounts: vault }, null, 2)
|
||||
}
|
||||
|
||||
// 格式 C: 2FAS 兼容格式 (schemaVersion 4 标准)
|
||||
if (type === '2fas') {
|
||||
const services = vault.map(acc => ({
|
||||
name: acc.service,
|
||||
secret: acc.secret,
|
||||
otp: {
|
||||
tokenType: 'TOTP',
|
||||
issuer: acc.service,
|
||||
account: acc.account,
|
||||
digits: acc.digits,
|
||||
period: acc.period,
|
||||
algorithm: (acc.algorithm || 'SHA1').replace('SHA-', 'SHA'),
|
||||
counter: 0,
|
||||
},
|
||||
order: { position: 0 },
|
||||
}))
|
||||
return JSON.stringify({ schemaVersion: 4, appOrigin: 'export', services }, null, 2)
|
||||
}
|
||||
|
||||
// 格式 D: OTPAuth 纯文本格式
|
||||
if (type === 'text') {
|
||||
return vault.map(acc => {
|
||||
const label = encodeURIComponent(`${acc.service}:${acc.account}`)
|
||||
const issuer = encodeURIComponent(acc.service)
|
||||
return `otpauth://totp/${label}?secret=${acc.secret}&issuer=${issuer}&digits=${acc.digits}&period=${acc.period}`
|
||||
}).join('\n')
|
||||
}
|
||||
|
||||
throw new Error('未知的导出类型')
|
||||
},
|
||||
|
||||
// 3. 处理导入逻辑 (前端解密/解析)
|
||||
async parseImportData(content, type, password) {
|
||||
let rawVault = []
|
||||
|
||||
// 场景 A: 加密备份
|
||||
if (type === 'encrypted') {
|
||||
if (!password) throw new Error('导入加密文件需要密码')
|
||||
try {
|
||||
let ciphertext = content
|
||||
const json = typeof content === 'string' ? tryParseJSON(content) : content
|
||||
if (json && typeof json === 'object' && json.data) {
|
||||
ciphertext = json.data
|
||||
}
|
||||
const input = typeof ciphertext === 'object' ? JSON.stringify(ciphertext) : ciphertext
|
||||
const decrypted = await decryptDataWithPassword(input, password)
|
||||
rawVault = decrypted.vault || decrypted.accounts || []
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
throw new Error('解密失败:密码错误或文件格式不兼容')
|
||||
}
|
||||
}
|
||||
// 场景 B: 标准 JSON / 2FAuth
|
||||
else if (type === 'json') {
|
||||
const json = typeof content === 'string' ? JSON.parse(content) : content
|
||||
// 本项目导出格式: data.accounts[] 或 data.vault[]
|
||||
if (Array.isArray(json.accounts)) {
|
||||
rawVault = json.accounts
|
||||
} else if (Array.isArray(json.vault)) {
|
||||
rawVault = json.vault
|
||||
// 旧导出格式: data.data[]
|
||||
} else if (Array.isArray(json.data)) {
|
||||
rawVault = json.data
|
||||
// 标准第三方格式: data.secrets[] (issuer + algorithm 字段)
|
||||
} else if (Array.isArray(json.secrets)) {
|
||||
rawVault = json.secrets.map(s => ({
|
||||
service: s.issuer || s.service || s.name || 'Unknown',
|
||||
account: s.account || s.label || '',
|
||||
secret: s.secret || '',
|
||||
algorithm: s.algorithm || 'SHA1',
|
||||
digits: s.digits || 6,
|
||||
period: s.period || 30,
|
||||
}))
|
||||
// 简单数组
|
||||
} else if (Array.isArray(json)) {
|
||||
rawVault = json
|
||||
// 误选 JSON 但实际是 2FAS 格式
|
||||
} else if (json.services) {
|
||||
rawVault = json.services.map(s => ({
|
||||
service: s.otp?.issuer || s.name || 'Unknown',
|
||||
account: s.otp?.account || s.account || s.username || '',
|
||||
secret: s.secret || '',
|
||||
algorithm: s.otp?.algorithm || s.algorithm || 'SHA1',
|
||||
digits: s.otp?.digits || s.digits || 6,
|
||||
period: s.otp?.period || s.period || 30,
|
||||
}))
|
||||
}
|
||||
}
|
||||
// 场景 C: 2FAS (schemaVersion 4, 字段在 otp 子对象中)
|
||||
else if (type === '2fas') {
|
||||
const json = typeof content === 'string' ? JSON.parse(content) : content
|
||||
if (Array.isArray(json.services)) {
|
||||
rawVault = json.services.map(s => ({
|
||||
service: s.otp?.issuer || s.name || 'Unknown',
|
||||
account: s.otp?.account || s.account || s.username || '',
|
||||
secret: s.secret || '',
|
||||
algorithm: (s.otp?.algorithm || s.algorithm || 'SHA1').toUpperCase(),
|
||||
digits: s.otp?.digits || s.digits || 6,
|
||||
period: s.otp?.period || s.period || 30,
|
||||
}))
|
||||
}
|
||||
}
|
||||
// 场景 D: OTPAuth 纯文本格式
|
||||
else if (type === 'text') {
|
||||
const lines = content.split('\n')
|
||||
lines.forEach(line => {
|
||||
const acc = parseOtpUri(line.trim())
|
||||
if (acc) rawVault.push(acc)
|
||||
})
|
||||
}
|
||||
|
||||
// 过滤无效数据
|
||||
return rawVault.filter(acc => acc && acc.secret && acc.service && acc.account)
|
||||
},
|
||||
|
||||
|
||||
// 4. 批量保存到后端s
|
||||
async saveImportedVault(vault) {
|
||||
// 调用后端批量导入接口 (需要后端支持接收纯 JSON 数组)
|
||||
// 我们将复用 /api/vault/import 接口,但修改其实现以支持 'raw' 类型
|
||||
return await request('/api/vault/import', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ type: 'raw', content: JSON.stringify(vault) })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数:安全解析 JSON,失败返回 null
|
||||
function tryParseJSON(str) {
|
||||
try {
|
||||
return JSON.parse(str)
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
<template>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div style="max-width: 600px; margin: 0 auto;">
|
||||
<div class="export-options">
|
||||
<el-button size="large" plain @click="openExportDialog('encrypted')">🔒 本系统加密备份 (.json)</el-button>
|
||||
<el-button size="large" plain @click="openExportDialog('json')">📄 标准 JSON / 2FAuth (.json)</el-button>
|
||||
<el-button size="large" plain @click="openExportDialog('2fas')">📱 2FAS 备份 (.2fas)</el-button>
|
||||
<el-button size="large" plain @click="openExportDialog('text')">📝 纯文本 URI (.txt)</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加密导出密码弹窗 -->
|
||||
<el-dialog v-model="showPasswordDialog" title="设置导出密码" width="400px" destroy-on-close>
|
||||
<el-form :model="exportForm" label-position="top" v-loading="isExporting" :element-loading-text="loadingText">
|
||||
<el-form-item label="加密密码 (至少 12 位)">
|
||||
<el-input v-model="exportForm.password" type="password" show-password placeholder="请输入高强度密码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码">
|
||||
<el-input v-model="exportForm.confirm" type="password" show-password placeholder="请再次输入" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showPasswordDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="isExporting" @click="executeExport">开始加密并下载</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 />
|
||||
<p style="margin-top: 15px;">确定要继续吗?</p>
|
||||
<template #footer>
|
||||
<el-button @click="showWarningDialog = false">取消</el-button>
|
||||
<el-button type="danger" @click="executeExport" :loading="isExporting">确定导出</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { migrationService } from '@/features/backup/service/migrationService'
|
||||
import { downloadBlob } from '@/shared/utils/common'
|
||||
|
||||
const showPasswordDialog = ref(false)
|
||||
const showWarningDialog = ref(false)
|
||||
const isExporting = ref(false)
|
||||
const exportForm = ref({ password: '', confirm: '' })
|
||||
const loadingText = ref('')
|
||||
const currentType = ref('encrypted')
|
||||
|
||||
const openExportDialog = (type) => {
|
||||
currentType.value = type
|
||||
if (type === 'encrypted') {
|
||||
exportForm.value = { password: '', confirm: '' }
|
||||
showPasswordDialog.value = true
|
||||
} else {
|
||||
showWarningDialog.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const executeExport = async () => {
|
||||
const type = currentType.value
|
||||
let password = ''
|
||||
|
||||
if (type === 'encrypted') {
|
||||
if (exportForm.value.password !== exportForm.value.confirm) {
|
||||
return ElMessage.error('两次输入的密码不一致!')
|
||||
}
|
||||
if (exportForm.value.password.length < 12) {
|
||||
return ElMessage.error('密码太弱!至少需要 12 个字符。')
|
||||
}
|
||||
password = exportForm.value.password
|
||||
}
|
||||
|
||||
isExporting.value = true
|
||||
try {
|
||||
// 1. 获取所有账号
|
||||
loadingText.value = '正在获取所有账号数据...'
|
||||
const vault = await migrationService.fetchAllVault()
|
||||
if (!vault || vault.length === 0) {
|
||||
throw new Error('没有可导出的账号')
|
||||
}
|
||||
|
||||
// 2. 在前端处理数据
|
||||
loadingText.value = type === 'encrypted' ? '正在进行高强度 AES-GCM 加密...' : '正在生成导出文件...'
|
||||
const fileContent = await migrationService.exportData(vault, type, password)
|
||||
|
||||
// 3. 创建 Blob 并下载
|
||||
let filename, mimeType
|
||||
const date = new Date().toISOString().split('T')[0]
|
||||
|
||||
if (type === 'text') {
|
||||
mimeType = 'text/plain'
|
||||
filename = `2fa-export-${date}.txt`
|
||||
} else {
|
||||
mimeType = 'application/json'
|
||||
filename = `2fa-export-${type}-${date}.${type === '2fas' ? '2fas' : 'json'}`
|
||||
}
|
||||
|
||||
downloadBlob(fileContent, filename, mimeType)
|
||||
|
||||
ElMessage.success('导出成功!')
|
||||
showPasswordDialog.value = false
|
||||
showWarningDialog.value = false
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error)
|
||||
ElMessage.error(error.message || '导出失败,请稍后重试')
|
||||
} finally { isExporting.value = false }
|
||||
}
|
||||
</script>
|
||||
@@ -1,117 +0,0 @@
|
||||
<template>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div style="max-width: 600px; margin: 0 auto;" v-loading="isImporting && !showDecryptDialog" :element-loading-text="loadingText">
|
||||
<div class="import-options">
|
||||
<el-button size="large" plain @click="triggerImport('encrypted', '.json')">🔒 本系统加密备份 (.json)</el-button>
|
||||
<el-button size="large" plain @click="triggerImport('json', '.json')">📄 标准 JSON / 2FAuth (.json)</el-button>
|
||||
<el-button size="large" plain @click="triggerImport('2fas', '.2fas,.json')">📱 2FAS 备份 (.2fas)</el-button>
|
||||
<el-button size="large" plain @click="triggerImport('text', '.txt')">📝 OTPAuth 纯文本格式 (.txt)</el-button>
|
||||
</div>
|
||||
<input type="file" ref="fileInputRef" :accept="acceptType" style="display: none" @change="handleFileUpload" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="showDecryptDialog" title="🔓 解密备份文件" width="400px" destroy-on-close>
|
||||
<el-alert title="检测到加密文件" type="success" :closable="false" style="margin-bottom: 15px;" />
|
||||
<el-form label-position="top" v-loading="isImporting" :element-loading-text="loadingText">
|
||||
<el-form-item label="请输入该备份的解密密码:">
|
||||
<el-input v-model="importPassword" type="password" show-password placeholder="输入当时设置的导出密码" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showDecryptDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="isImporting" @click="submitImportData">确认解密并导入</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { migrationService } from '@/features/backup/service/migrationService'
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
|
||||
const fileInputRef = ref(null)
|
||||
const acceptType = ref('.json')
|
||||
const currentImportType = ref('')
|
||||
const currentFileContent = ref('')
|
||||
const showDecryptDialog = ref(false)
|
||||
const importPassword = ref('')
|
||||
const isImporting = ref(false)
|
||||
const loadingText = ref('')
|
||||
|
||||
const triggerImport = (type, accept) => {
|
||||
currentImportType.value = type
|
||||
acceptType.value = accept
|
||||
setTimeout(() => fileInputRef.value.click(), 0)
|
||||
}
|
||||
|
||||
const handleFileUpload = (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
ElMessage.error('文件太大,不能超过 10MB')
|
||||
e.target.value = ''
|
||||
return
|
||||
}
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
currentFileContent.value = event.target.result
|
||||
if (currentImportType.value === 'encrypted') {
|
||||
importPassword.value = ''
|
||||
showDecryptDialog.value = true
|
||||
} else {
|
||||
submitImportData()
|
||||
}
|
||||
}
|
||||
reader.onerror = () => ElMessage.error('文件读取失败')
|
||||
reader.readAsText(file)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const submitImportData = async () => {
|
||||
if (currentImportType.value === 'encrypted' && !importPassword.value) {
|
||||
return ElMessage.warning('请输入解密密码')
|
||||
}
|
||||
|
||||
isImporting.value = true
|
||||
try {
|
||||
// 1. 前端解析/解密
|
||||
loadingText.value = currentImportType.value === 'encrypted' ? '正在使用 AES-GCM 解密数据...' : '正在解析数据...'
|
||||
const vault = await migrationService.parseImportData(
|
||||
currentFileContent.value,
|
||||
currentImportType.value,
|
||||
importPassword.value
|
||||
)
|
||||
|
||||
// 2. 发送给后端保存
|
||||
loadingText.value = `正在保存 ${vault.length} 个账号...`
|
||||
const data = await migrationService.saveImportedVault(vault)
|
||||
|
||||
if (data.success) {
|
||||
showDecryptDialog.value = false
|
||||
if (data.count > 0) {
|
||||
let msg = `✅ 成功导入 ${data.count} 个账户!`
|
||||
if (data.duplicates > 0) msg += ` (自动跳过了 ${data.duplicates} 个重复账户)`
|
||||
ElMessage.success({ message: msg, duration: 5000 })
|
||||
emit('success') // 有实际导入才切换到账号列表
|
||||
} else {
|
||||
// 0 条:留在导入页,给出明确提示
|
||||
const reason = data.total > 0 ? `文件中 ${data.total} 个账户均已存在,无需重复导入` : '未能从文件中解析到有效账户,请检查文件格式'
|
||||
ElMessage.warning({ message: `⚠️ 导入完成,${reason}`, duration: 6000 })
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
ElMessage.error(error.message || '导入失败')
|
||||
} finally { isImporting.value = false }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,208 @@
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { dataMigrationService } from '@/features/migration/service/dataMigrationService'
|
||||
import { downloadBlob } from '@/shared/utils/common'
|
||||
|
||||
export function useDataExport() {
|
||||
const showPasswordDialog = ref(false)
|
||||
const showWarningDialog = ref(false)
|
||||
const showGaDialog = ref(false)
|
||||
const isExporting = ref(false)
|
||||
const exportForm = ref({ password: '', confirm: '' })
|
||||
const loadingText = ref('')
|
||||
|
||||
const currentType = ref('')
|
||||
const currentVariant = ref('generic')
|
||||
|
||||
// GA 分批导出相关
|
||||
const gaQrDataUrls = ref([])
|
||||
const gaCurrentIndex = ref(0)
|
||||
|
||||
// GA 按需导出选择器相关
|
||||
const showAccountSelectDialog = ref(false)
|
||||
const fullVault = ref([])
|
||||
const searchKeyword = ref('')
|
||||
const selectedAccountIds = ref([])
|
||||
|
||||
const openExportDialog = (type, variant = 'generic') => {
|
||||
currentType.value = type
|
||||
currentVariant.value = variant
|
||||
exportForm.value = { password: '', confirm: '' }
|
||||
showPasswordDialog.value = true
|
||||
}
|
||||
|
||||
const openWarningDialog = (type, variant = 'generic') => {
|
||||
currentType.value = type
|
||||
currentVariant.value = variant
|
||||
showWarningDialog.value = true
|
||||
}
|
||||
|
||||
// 计算属性:过滤后的账号列表
|
||||
const filteredVault = computed(() => {
|
||||
const keyword = searchKeyword.value.toLowerCase().trim()
|
||||
if (!keyword) return fullVault.value
|
||||
return fullVault.value.filter(acc =>
|
||||
(acc.service && acc.service.toLowerCase().includes(keyword)) ||
|
||||
(acc.account && acc.account.toLowerCase().includes(keyword))
|
||||
)
|
||||
})
|
||||
|
||||
// 直接开启 Google Auth 导出流程(跳过明文警告弹窗)
|
||||
const openGaDialogDirectly = () => {
|
||||
currentType.value = 'gauth'
|
||||
executeExport()
|
||||
}
|
||||
|
||||
// 全选/取消全选 (仅作用于当前过滤结果)
|
||||
const toggleSelectAll = (val) => {
|
||||
const filteredIds = filteredVault.value.map(acc => acc.id)
|
||||
if (val) {
|
||||
// 将不在已选列表中的过滤结果加入已选列表
|
||||
const newIds = filteredIds.filter(id => !selectedAccountIds.value.includes(id))
|
||||
selectedAccountIds.value.push(...newIds)
|
||||
} else {
|
||||
// 将过滤结果从已选列表中移除
|
||||
selectedAccountIds.value = selectedAccountIds.value.filter(id => !filteredIds.includes(id))
|
||||
}
|
||||
}
|
||||
|
||||
const executeExport = async () => {
|
||||
const type = currentType.value
|
||||
const variant = currentVariant.value
|
||||
let password = ''
|
||||
|
||||
if (type === 'encrypted') {
|
||||
if (exportForm.value.password !== exportForm.value.confirm) {
|
||||
return ElMessage.error('两次输入的密码不一致!')
|
||||
}
|
||||
if (exportForm.value.password.length < 12) {
|
||||
return ElMessage.error('密码太弱!至少需要 12 个字符。')
|
||||
}
|
||||
password = exportForm.value.password
|
||||
}
|
||||
|
||||
isExporting.value = true
|
||||
showPasswordDialog.value = false
|
||||
showWarningDialog.value = false
|
||||
|
||||
try {
|
||||
loadingText.value = '正在获取账号数据...'
|
||||
const vault = await dataMigrationService.fetchAllVault()
|
||||
if (!vault || vault.length === 0) {
|
||||
throw new Error('没有可导出的账号')
|
||||
}
|
||||
|
||||
loadingText.value = type === 'encrypted' ? '正在加密...' : '正在生成导出文件...'
|
||||
|
||||
// 特殊处理 Google Auth (不再直接生成,而是进入选择界面)
|
||||
if (type === 'gauth') {
|
||||
fullVault.value = vault
|
||||
// 默认全不选 (由用户的偏好修改)
|
||||
selectedAccountIds.value = []
|
||||
searchKeyword.value = ''
|
||||
showAccountSelectDialog.value = true
|
||||
isExporting.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// 特殊处理 HTML 报告导出
|
||||
if (type === 'html') {
|
||||
const htmlContent = await dataMigrationService.exportAsHtml(vault)
|
||||
downloadBlob(htmlContent, `2fa-backup-report-${new Date().toISOString().split('T')[0]}.html`, 'text/html')
|
||||
ElMessage.success('导出完成!请在浏览器中打开该文件。')
|
||||
isExporting.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const fileContent = await dataMigrationService.exportData(vault, type, password, variant)
|
||||
|
||||
let filename, mimeType
|
||||
const date = new Date().toISOString().split('T')[0]
|
||||
|
||||
switch (type) {
|
||||
case 'text':
|
||||
mimeType = 'text/plain'
|
||||
filename = `2fa-export-otpauth-${date}.txt`
|
||||
break
|
||||
case 'csv':
|
||||
mimeType = 'text/csv'
|
||||
filename = `2fa-export-csv-${date}.csv`
|
||||
break
|
||||
case 'bwauth':
|
||||
mimeType = 'application/json'
|
||||
filename = `2fa-export-bwauth-${date}.json`
|
||||
break
|
||||
case '2fas':
|
||||
mimeType = 'application/json'
|
||||
filename = `2fa-export-2fas-${date}.2fas`
|
||||
break
|
||||
case 'generic_json':
|
||||
mimeType = 'application/json'
|
||||
filename = `2fa-export-generic-json-${date}.json`
|
||||
break
|
||||
default:
|
||||
// Covers 'encrypted', 'json', 'aegis'
|
||||
mimeType = 'application/json'
|
||||
filename = `2fa-export-${type}-${date}.json`
|
||||
break
|
||||
}
|
||||
|
||||
downloadBlob(fileContent, filename, mimeType)
|
||||
ElMessage.success('导出成功!')
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error)
|
||||
ElMessage.error(error.message || '导出失败,请稍后重试')
|
||||
} finally {
|
||||
isExporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 执行生成所选的 Google Auth 二维码
|
||||
const executeGaExport = async () => {
|
||||
if (selectedAccountIds.value.length === 0) {
|
||||
return ElMessage.warning('请至少选择一个账号')
|
||||
}
|
||||
|
||||
isExporting.value = true
|
||||
showAccountSelectDialog.value = false
|
||||
loadingText.value = '正在生成二维码...'
|
||||
|
||||
try {
|
||||
const selectedVault = fullVault.value.filter(acc => selectedAccountIds.value.includes(acc.id))
|
||||
showGaDialog.value = true
|
||||
gaQrDataUrls.value = []
|
||||
gaCurrentIndex.value = 0
|
||||
gaQrDataUrls.value = await dataMigrationService.exportAsGaMigration(selectedVault)
|
||||
} catch (error) {
|
||||
console.error('GA Export failed:', error)
|
||||
ElMessage.error(error.message || '生成二维码失败')
|
||||
} finally {
|
||||
isExporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
showPasswordDialog,
|
||||
showWarningDialog,
|
||||
showAccountSelectDialog,
|
||||
showGaDialog,
|
||||
isExporting,
|
||||
exportForm,
|
||||
loadingText,
|
||||
gaQrDataUrls,
|
||||
gaCurrentIndex,
|
||||
fullVault,
|
||||
searchKeyword,
|
||||
selectedAccountIds,
|
||||
filteredVault,
|
||||
|
||||
// Methods
|
||||
openExportDialog,
|
||||
openWarningDialog,
|
||||
openGaDialogDirectly,
|
||||
executeExport,
|
||||
executeGaExport,
|
||||
toggleSelectAll
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
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'
|
||||
|
||||
export function useDataImport(emitFn) {
|
||||
const vaultStore = useVaultStore()
|
||||
|
||||
// --- State ---
|
||||
const currentFileContent = ref('')
|
||||
const currentFile = ref(null)
|
||||
const currentImportType = ref('')
|
||||
const showDecryptDialog = ref(false)
|
||||
const importPassword = ref('')
|
||||
const importingJobs = ref(0)
|
||||
const isImporting = computed(() => importingJobs.value > 0)
|
||||
const isDecrypting = ref(false)
|
||||
const isDialogHandled = ref(false)
|
||||
|
||||
// Batch properties
|
||||
const batchTotalJobs = ref(0)
|
||||
const batchProcessedJobs = ref(0)
|
||||
const batchAccumulatedVault = ref([])
|
||||
const batchErrors = ref([])
|
||||
const batchCurrentTaskName = ref('')
|
||||
const showBatchProgress = ref(false)
|
||||
const batchProgressPercent = computed(() => {
|
||||
if (batchTotalJobs.value === 0) return 0
|
||||
return Math.floor((batchProcessedJobs.value / batchTotalJobs.value) * 100)
|
||||
})
|
||||
|
||||
let finalizeTimer = null
|
||||
|
||||
// --- Watchers ---
|
||||
watch(importingJobs, (newVal) => {
|
||||
if (newVal === 0 && batchTotalJobs.value > 0 && !showDecryptDialog.value) {
|
||||
if (finalizeTimer) clearTimeout(finalizeTimer)
|
||||
finalizeTimer = setTimeout(() => {
|
||||
finishBatchImport()
|
||||
}, 300)
|
||||
}
|
||||
})
|
||||
|
||||
// --- Core Logic ---
|
||||
const initBatchTask = (taskCount) => {
|
||||
if (batchTotalJobs.value === 0) {
|
||||
batchAccumulatedVault.value = []
|
||||
batchErrors.value = []
|
||||
showBatchProgress.value = true
|
||||
}
|
||||
importingJobs.value += taskCount
|
||||
batchTotalJobs.value += taskCount
|
||||
}
|
||||
|
||||
const resetBatchState = () => {
|
||||
batchTotalJobs.value = 0
|
||||
batchProcessedJobs.value = 0
|
||||
importingJobs.value = 0
|
||||
batchAccumulatedVault.value = []
|
||||
batchErrors.value = []
|
||||
batchCurrentTaskName.value = ''
|
||||
}
|
||||
|
||||
const handleFileUpload = async (uploadFile) => {
|
||||
const file = uploadFile.raw
|
||||
if (!file) return
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
ElMessage.error(`文件 ${file.name} 太大,不能超过 10MB`)
|
||||
return
|
||||
}
|
||||
|
||||
initBatchTask(1)
|
||||
batchCurrentTaskName.value = `准备解析:${file.name}`
|
||||
|
||||
if (file.type.startsWith('image/')) {
|
||||
await handleImageFile(file)
|
||||
} else {
|
||||
handleNonImageFile(file)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImageFile = async (file) => {
|
||||
try {
|
||||
batchCurrentTaskName.value = `截取图中二维码:${file.name}`
|
||||
const vault = await dataMigrationService.parseGaQrImageFile(file)
|
||||
batchAccumulatedVault.value.push(...vault)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
batchErrors.value.push(`[${file.name}] 解析图片失败: ${err.message}`)
|
||||
} finally {
|
||||
batchProcessedJobs.value++
|
||||
importingJobs.value--
|
||||
}
|
||||
}
|
||||
|
||||
const handleNonImageFile = (file) => {
|
||||
try {
|
||||
currentFile.value = file
|
||||
const reader = new FileReader()
|
||||
|
||||
// 无论文件名为何,都先读取成二进制,再由 detectFileType 判断类型
|
||||
reader.onload = async (event) => {
|
||||
const buffer = event.target.result
|
||||
currentFileContent.value = buffer
|
||||
|
||||
const detectedType = dataMigrationService.detectFileType(buffer, file.name)
|
||||
currentImportType.value = detectedType
|
||||
|
||||
if (detectedType === 'unknown') {
|
||||
batchErrors.value.push(`[${file.name}] 无法识别该文件格式。`)
|
||||
batchProcessedJobs.value++
|
||||
importingJobs.value--
|
||||
return
|
||||
}
|
||||
|
||||
// 如果不是 phonefactor,后续解析需要字符串内容
|
||||
let contentForParse = buffer
|
||||
if (detectedType !== 'phonefactor') {
|
||||
try {
|
||||
contentForParse = new TextDecoder().decode(buffer)
|
||||
} catch {
|
||||
// fallback to array buffer if decoding fails; service handles it
|
||||
}
|
||||
}
|
||||
|
||||
if (detectedType === 'encrypted' || detectedType === 'aegis_encrypted') {
|
||||
importPassword.value = ''
|
||||
isDialogHandled.value = false
|
||||
showDecryptDialog.value = true
|
||||
// keep job active until unblocked by decrypt action
|
||||
currentFileContent.value = contentForParse
|
||||
return
|
||||
}
|
||||
|
||||
batchCurrentTaskName.value = `构建数据:${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}`)
|
||||
} finally {
|
||||
batchProcessedJobs.value++
|
||||
importingJobs.value--
|
||||
}
|
||||
}
|
||||
|
||||
reader.onerror = () => {
|
||||
batchErrors.value.push(`[${file.name}] 读取失败`)
|
||||
batchProcessedJobs.value++
|
||||
importingJobs.value--
|
||||
}
|
||||
|
||||
reader.readAsArrayBuffer(file)
|
||||
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
batchErrors.value.push(`[${file.name}] 读取异常`)
|
||||
batchProcessedJobs.value++
|
||||
importingJobs.value--
|
||||
}
|
||||
}
|
||||
|
||||
const submitEncryptedData = async () => {
|
||||
if (!importPassword.value) {
|
||||
return ElMessage.warning('请输入解密密码')
|
||||
}
|
||||
|
||||
isDecrypting.value = true
|
||||
try {
|
||||
batchCurrentTaskName.value = '正在使用密码解密数据...'
|
||||
const vault = await dataMigrationService.parseImportData(
|
||||
currentFileContent.value,
|
||||
currentImportType.value,
|
||||
importPassword.value
|
||||
)
|
||||
isDialogHandled.value = true
|
||||
showDecryptDialog.value = false
|
||||
batchAccumulatedVault.value.push(...vault)
|
||||
batchProcessedJobs.value++
|
||||
importingJobs.value--
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
ElMessage.error(`解密失败:密码错误或不支持加密标准`)
|
||||
// do not decrement job, user can try again
|
||||
} finally {
|
||||
isDecrypting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDecryptDialogClose = () => {
|
||||
if (!isDialogHandled.value) {
|
||||
batchErrors.value.push(`[${currentFile.value?.name || '加密文件'}] 用户取消密码输入`)
|
||||
batchProcessedJobs.value++
|
||||
importingJobs.value--
|
||||
isDialogHandled.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const finishBatchImport = async () => {
|
||||
if (batchAccumulatedVault.value.length === 0) {
|
||||
showBatchProgress.value = false
|
||||
if (batchErrors.value.length > 0) {
|
||||
ElNotification({
|
||||
title: '导入异常',
|
||||
message: batchErrors.value.join('<br>'),
|
||||
type: 'error',
|
||||
dangerouslyUseHTMLString: true,
|
||||
duration: 0
|
||||
})
|
||||
} else {
|
||||
ElMessage.warning('没有可导入的有效账户数据')
|
||||
}
|
||||
resetBatchState()
|
||||
return
|
||||
}
|
||||
|
||||
// 先在前端做简单的去重处理(大小写/空格不敏感),避免重复发送相同条目
|
||||
const normalize = (s, a) => `${(s||'').toString().trim().toLowerCase()}:${(a||'').toString().trim().toLowerCase()}`;
|
||||
const seen = new Set();
|
||||
const filteredVault = [];
|
||||
for (const acc of batchAccumulatedVault.value) {
|
||||
const sig = normalize(acc.service, acc.account);
|
||||
if (!seen.has(sig)) {
|
||||
seen.add(sig);
|
||||
filteredVault.push(acc);
|
||||
}
|
||||
}
|
||||
batchAccumulatedVault.value = filteredVault;
|
||||
batchCurrentTaskName.value = `合并保存中... (${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>`
|
||||
if (data.count > 0) {
|
||||
msgHtml += `<div style="color:var(--el-color-success)">🎉 成功导入 <b>${data.count}</b> 个新账户!</div>`
|
||||
} else {
|
||||
msgHtml += `<div style="color:var(--el-color-warning)">⚠️ 导入账户皆已存在,无需添加。</div>`
|
||||
}
|
||||
|
||||
if (data.duplicates > 0) msgHtml += `<div style="color:var(--el-text-color-secondary)">ℹ️ 自动跳过了 <b>${data.duplicates}</b> 个已拥有的账户。</div>`
|
||||
|
||||
if (batchErrors.value.length > 0) {
|
||||
msgHtml += `<div style="color:var(--el-color-danger); margin-top: 5px;">❌ <b>错误摘要:</b><br>${batchErrors.value.join('<br>')}</div>`
|
||||
}
|
||||
|
||||
ElNotification({
|
||||
title: '批量导入结束',
|
||||
message: msgHtml,
|
||||
dangerouslyUseHTMLString: true,
|
||||
type: batchErrors.value.length > 0 ? 'warning' : 'success',
|
||||
duration: batchErrors.value.length > 0 ? 0 : 8000
|
||||
})
|
||||
|
||||
if (data.count > 0) {
|
||||
vaultStore.markDirty()
|
||||
emitFn('success')
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(`后端合并保存失败,请检查网络`)
|
||||
}
|
||||
} catch (err) {
|
||||
showBatchProgress.value = false
|
||||
ElMessage.error(`最终保存异常:${err.message}`)
|
||||
} finally {
|
||||
resetBatchState()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
currentImportType,
|
||||
showDecryptDialog,
|
||||
importPassword,
|
||||
isDecrypting,
|
||||
showBatchProgress,
|
||||
batchCurrentTaskName,
|
||||
batchProcessedJobs,
|
||||
batchTotalJobs,
|
||||
batchProgressPercent,
|
||||
// Handlers
|
||||
handleFileUpload,
|
||||
submitEncryptedData,
|
||||
handleDecryptDialogClose
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,722 @@
|
||||
import { vaultService } from '@/features/vault/service/vaultService'
|
||||
import { encryptDataWithPassword, decryptDataWithPassword } from '@/shared/utils/crypto'
|
||||
import { parseOtpUri } from '@/shared/utils/totp'
|
||||
import { tryParseJSON, base64ToBytes } from '@/shared/utils/encoding'
|
||||
import { gaMigrationStrategy } from '@/shared/utils/serializers/gauthStrategy'
|
||||
import { csvStrategy } from '@/shared/utils/serializers/csvStrategy'
|
||||
import { aegisStrategy } from '@/shared/utils/serializers/aegisStrategy'
|
||||
import { migrationError } from '@/shared/utils/errors/migrationError'
|
||||
import jsQR from 'jsqr'
|
||||
import initSqlJs from 'sql.js'
|
||||
|
||||
/**
|
||||
* 数据迁移服务核心控制器
|
||||
* 负责智能分发不同来源与格式的导入导出请求
|
||||
*/
|
||||
export const dataMigrationService = {
|
||||
/**
|
||||
* 智能识别导入内容或文件的类型
|
||||
* @param {string|ArrayBuffer|Uint8Array} content - 文件文本内容或二进制数据
|
||||
* @param {string} filename - 文件名
|
||||
* @returns {'bwauth_csv'|'generic_csv'|'text'|'bwauth_json'|'encrypted'|'json'|'2fas'|'aegis'|'aegis_encrypted'|'phonefactor'|'unknown'} 返回类型标识
|
||||
*/
|
||||
detectFileType(content, filename) {
|
||||
// 如果是二进制数据,尝试用更宽容的方式判断 PhoneFactor
|
||||
if (content instanceof ArrayBuffer || content instanceof Uint8Array) {
|
||||
const bytes = content instanceof Uint8Array ? content : new Uint8Array(content)
|
||||
// 1) 检查 SQLite 文件头
|
||||
const header = 'SQLite format 3'
|
||||
let headerMatch = true
|
||||
for (let i = 0; i < header.length && i < bytes.length; i++) {
|
||||
if (bytes[i] !== header.charCodeAt(i)) { headerMatch = false; break }
|
||||
}
|
||||
if (headerMatch) return 'phonefactor'
|
||||
|
||||
// 2) 快速 ASCII 搜索表/列名(容错,某些导出会保留表名字符串在文件内部)
|
||||
try {
|
||||
const hay = new TextDecoder('utf-8', { fatal: false }).decode(bytes)
|
||||
if (hay.includes('accounts') && (hay.includes('oath_secret_key') || hay.includes('encrypted_oath_secret_key'))) return 'phonefactor'
|
||||
} catch (e) { /* ignore decode errors */ }
|
||||
}
|
||||
|
||||
// 宽容的文件名匹配(包含 phonefactor 字样)
|
||||
if (filename && filename.toLowerCase().includes('phonefactor')) {
|
||||
// 如果文件名包含 phonefactor,则谨慎标记为 phonefactor(上层读取时还会再校验头/表)
|
||||
return 'phonefactor'
|
||||
}
|
||||
|
||||
if (filename && filename.toLowerCase().endsWith('.csv')) {
|
||||
const firstLine = content.split('\n')[0].toLowerCase()
|
||||
if (firstLine.includes('login_totp')) return 'bwauth_csv'
|
||||
return 'generic_csv'
|
||||
}
|
||||
|
||||
if (typeof content === 'string' && content.trim().startsWith('otpauth://')) {
|
||||
return 'text'
|
||||
}
|
||||
|
||||
if (typeof content === 'string') {
|
||||
const json = tryParseJSON(content)
|
||||
if (json) {
|
||||
if (Array.isArray(json.items) && json.items.length > 0 && json.items[0].login && json.items[0].login.totp) return 'bwauth_json'
|
||||
if (json.encrypted === true && json.app === '2fauth') return 'encrypted'
|
||||
if (json.app === '2fauth' || Array.isArray(json.accounts) || Array.isArray(json.vault) || Array.isArray(json.secrets)) return 'json'
|
||||
if (json.schemaVersion && Array.isArray(json.services)) return '2fas'
|
||||
if (json.version === 1 && json.db && typeof json.db === 'object' && Array.isArray(json.db.entries)) return 'aegis' // Aegis unencrypted
|
||||
if (json.version === 1 && json.header && json.db && typeof json.db === 'string') return 'aegis_encrypted'
|
||||
}
|
||||
}
|
||||
|
||||
if (filename) {
|
||||
const ext = filename.toLowerCase()
|
||||
if (ext.endsWith('.2fas')) return '2fas'
|
||||
if (ext.endsWith('.txt')) return 'text'
|
||||
}
|
||||
|
||||
return 'unknown'
|
||||
},
|
||||
|
||||
/**
|
||||
* 1. 获取所有数据 (用于导出)
|
||||
* @returns {Promise<Object[]>}
|
||||
*/
|
||||
async fetchAllVault() {
|
||||
const res = await vaultService.getVault({ limit: 9999 })
|
||||
if (!res.success) throw new migrationError('无法获取账号数据', 'VAULT_FETCH_FAILED')
|
||||
return res.vault || []
|
||||
},
|
||||
|
||||
/**
|
||||
* 2. 处理导出逻辑 (前端加密/格式化)
|
||||
* @param {Object[]} vault
|
||||
* @param {string} type
|
||||
* @param {string} password
|
||||
* @param {string} [variant='generic']
|
||||
* @returns {Promise<string>} 导出的文件字符串内容
|
||||
*/
|
||||
async exportData(vault, type, password, variant = 'generic') {
|
||||
const timestamp = new Date().toISOString()
|
||||
const baseData = { version: "2.0", app: "2fauth", timestamp }
|
||||
|
||||
if (type === 'encrypted') {
|
||||
if (!password) throw new migrationError('加密导出需要密码', 'MISSING_PASSWORD')
|
||||
const payload = { ...baseData, accounts: vault }
|
||||
const encryptedData = await encryptDataWithPassword(payload, password)
|
||||
return JSON.stringify({
|
||||
...baseData,
|
||||
encrypted: true,
|
||||
data: encryptedData,
|
||||
note: "This file is encrypted with your export password (AES-GCM-256 + PBKDF2)."
|
||||
}, null, 2)
|
||||
}
|
||||
|
||||
if (type === 'generic_json') {
|
||||
const secrets = vault.map(acc => ({
|
||||
issuer: acc.service || 'Unknown',
|
||||
account: acc.account || '',
|
||||
secret: acc.secret,
|
||||
type: 'TOTP',
|
||||
digits: acc.digits || 6,
|
||||
period: acc.period || 30,
|
||||
algorithm: (acc.algorithm || 'SHA1').toUpperCase().replace('SHA-', 'SHA')
|
||||
}))
|
||||
return JSON.stringify({
|
||||
version: "1.0",
|
||||
exportDate: new Date().toISOString(),
|
||||
count: secrets.length,
|
||||
secrets
|
||||
}, null, 2)
|
||||
}
|
||||
|
||||
if (type === 'json') {
|
||||
return JSON.stringify({ ...baseData, encrypted: false, accounts: vault }, null, 2)
|
||||
}
|
||||
|
||||
if (type === '2fas') {
|
||||
const services = vault.map((acc, index) => {
|
||||
const algo = (acc.algorithm || 'SHA1')
|
||||
.replace('SHA-1', 'SHA1')
|
||||
.replace('SHA-256', 'SHA256')
|
||||
.replace('SHA-512', 'SHA512')
|
||||
return {
|
||||
name: acc.service,
|
||||
secret: acc.secret,
|
||||
otp: {
|
||||
source: 'manual',
|
||||
account: acc.account || '',
|
||||
digits: acc.digits || 6,
|
||||
period: acc.period || 30,
|
||||
algorithm: algo,
|
||||
tokenType: 'TOTP',
|
||||
counter: 0,
|
||||
},
|
||||
order: { position: index },
|
||||
badge: { color: 'Default' },
|
||||
updatedAt: Date.now(),
|
||||
icon: {
|
||||
selected: 'Label',
|
||||
label: {
|
||||
text: (acc.service || '?').slice(0, 2).toUpperCase(),
|
||||
backgroundColor: 'Default',
|
||||
},
|
||||
iconCollection: { id: 'A5B3FB65-4EC5-43E6-8EC1-49E24CA9E7AD' },
|
||||
},
|
||||
}
|
||||
})
|
||||
return JSON.stringify({
|
||||
schemaVersion: 4,
|
||||
appVersionCode: 50316,
|
||||
appVersionName: '5.3.16',
|
||||
appOrigin: 'ios',
|
||||
groups: [],
|
||||
services
|
||||
})
|
||||
}
|
||||
|
||||
if (type === 'aegis') {
|
||||
const entries = vault.map(acc => ({
|
||||
type: 'totp',
|
||||
uuid: crypto.randomUUID(),
|
||||
name: acc.account || acc.service,
|
||||
issuer: acc.service,
|
||||
info: {
|
||||
secret: acc.secret,
|
||||
algo: (acc.algorithm || 'SHA1').replace('SHA-', 'SHA'),
|
||||
digits: acc.digits || 6,
|
||||
period: acc.period || 30
|
||||
}
|
||||
}))
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
header: { slots: null, params: null },
|
||||
db: { version: 3, entries }
|
||||
}, null, 2)
|
||||
}
|
||||
|
||||
if (type === 'text') {
|
||||
return vault.map(acc => {
|
||||
const label = encodeURIComponent(`${acc.service}:${acc.account}`)
|
||||
const issuer = encodeURIComponent(acc.service)
|
||||
return `otpauth://totp/${label}?secret=${acc.secret}&issuer=${issuer}&digits=${acc.digits}&period=${acc.period}`
|
||||
}).join('\n')
|
||||
}
|
||||
|
||||
if (type === 'csv') {
|
||||
if (variant === 'bitwarden') {
|
||||
let csv = 'name,secret,totp,favorite,folder\n'
|
||||
vault.forEach(acc => {
|
||||
const name = `"${acc.service}${acc.account ? ':' + acc.account : ''}"`
|
||||
const label = encodeURIComponent(`${acc.service}:${acc.account}`)
|
||||
const issuer = encodeURIComponent(acc.service)
|
||||
const totp = `"otpauth://totp/${label}?secret=${acc.secret}&issuer=${issuer}&digits=${acc.digits}&period=${acc.period}"`
|
||||
csv += `${name},${acc.secret},${totp},0,\n`
|
||||
})
|
||||
return csv
|
||||
} else {
|
||||
// generic csv
|
||||
let csv = 'name,issuer,secret,algorithm,digits,period,type\n'
|
||||
vault.forEach(acc => {
|
||||
const name = `"${acc.account}"`
|
||||
const issuer = `"${acc.service}"`
|
||||
csv += `${name},${issuer},${acc.secret},${acc.algorithm},${acc.digits},${acc.period},TOTP\n`
|
||||
})
|
||||
return csv
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'bwauth') {
|
||||
const items = vault.map(acc => {
|
||||
const label = encodeURIComponent(acc.account ? `${acc.service}:${acc.account}` : acc.service)
|
||||
const issuer = encodeURIComponent(acc.service)
|
||||
const algo = (acc.algorithm || 'SHA1').replace('SHA-1', 'SHA1').replace('SHA-256', 'SHA256').replace('SHA-512', 'SHA512')
|
||||
const totp = `otpauth://totp/${label}?secret=${acc.secret}&issuer=${issuer}&algorithm=${algo}&digits=${acc.digits || 6}&period=${acc.period || 30}`
|
||||
return {
|
||||
favorite: false,
|
||||
id: crypto.randomUUID().toUpperCase(),
|
||||
login: { totp, username: acc.account || '' },
|
||||
name: acc.service,
|
||||
type: 1
|
||||
}
|
||||
})
|
||||
return JSON.stringify({ encrypted: false, items })
|
||||
}
|
||||
|
||||
throw new migrationError('未知的导出类型: ' + type, 'UNKNOWN_EXPORT_TYPE')
|
||||
},
|
||||
|
||||
/**
|
||||
* 导出为 Google Authenticator 迁移协议二维码 (分批生成图片 URI)
|
||||
* @param {Object[]} vault
|
||||
* @returns {Promise<string[]>} QR Code Data URLs
|
||||
*/
|
||||
async exportAsGaMigration(vault) {
|
||||
if (!vault || vault.length === 0) throw new migrationError('没有账户可以迁移', 'EMPTY_VAULT')
|
||||
|
||||
const BATCH_SIZE = 10;
|
||||
const batches = [];
|
||||
for (let i = 0; i < vault.length; i += BATCH_SIZE) {
|
||||
batches.push(vault.slice(i, i + BATCH_SIZE));
|
||||
}
|
||||
|
||||
const batchId = Math.floor(Math.random() * 0x7fffffff);
|
||||
const QRCode = await import('qrcode')
|
||||
const qrDataUrls = [];
|
||||
|
||||
function writeVarint(val, arr) {
|
||||
while (val >= 0x80) {
|
||||
arr.push((val & 0x7f) | 0x80)
|
||||
val >>>= 7
|
||||
}
|
||||
arr.push(val)
|
||||
}
|
||||
|
||||
function writeString(str, arr) {
|
||||
const bytes = new TextEncoder().encode(str)
|
||||
writeVarint(bytes.length, arr)
|
||||
for (let i = 0; i < bytes.length; i++) arr.push(bytes[i])
|
||||
}
|
||||
|
||||
function writeBytes(bytes, arr) {
|
||||
writeVarint(bytes.length, arr)
|
||||
for (let i = 0; i < bytes.length; i++) arr.push(bytes[i])
|
||||
}
|
||||
|
||||
for (let bIndex = 0; bIndex < batches.length; bIndex++) {
|
||||
const currentBatch = batches[bIndex];
|
||||
const payloadBytes = [];
|
||||
|
||||
payloadBytes.push(16) // version = 1
|
||||
writeVarint(1, payloadBytes)
|
||||
payloadBytes.push(24) // batch_size
|
||||
writeVarint(batches.length, payloadBytes)
|
||||
payloadBytes.push(32) // batch_index
|
||||
writeVarint(bIndex, payloadBytes)
|
||||
payloadBytes.push(40) // batch_id
|
||||
writeVarint(batchId, payloadBytes)
|
||||
|
||||
for (const acc of currentBatch) {
|
||||
const otpBytes = []
|
||||
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
|
||||
const secretStr = acc.secret.toUpperCase().replace(/=+$/, '').replace(/[^A-Z2-7]/g, '')
|
||||
const sBytes = []
|
||||
let bits = 0, value = 0
|
||||
for (let i = 0; i < secretStr.length; i++) {
|
||||
const val = ALPHABET.indexOf(secretStr[i])
|
||||
if (val === -1) continue
|
||||
value = (value << 5) | val
|
||||
bits += 5
|
||||
if (bits >= 8) {
|
||||
sBytes.push((value >>> (bits - 8)) & 0xFF)
|
||||
bits -= 8
|
||||
}
|
||||
}
|
||||
if (sBytes.length > 0) {
|
||||
otpBytes.push(10)
|
||||
writeBytes(sBytes, otpBytes)
|
||||
}
|
||||
|
||||
const name = acc.account || acc.service
|
||||
if (name) {
|
||||
otpBytes.push(18)
|
||||
writeString(name, otpBytes)
|
||||
}
|
||||
|
||||
if (acc.service) {
|
||||
otpBytes.push(26)
|
||||
writeString(acc.service, otpBytes)
|
||||
}
|
||||
|
||||
let algoVal = 1 // SHA1
|
||||
if (acc.algorithm === 'SHA256') algoVal = 2
|
||||
else if (acc.algorithm === 'SHA512') algoVal = 3
|
||||
otpBytes.push(32)
|
||||
writeVarint(algoVal, otpBytes)
|
||||
|
||||
let digVal = 1 // SIX
|
||||
if (acc.digits === 8) digVal = 2
|
||||
otpBytes.push(40)
|
||||
writeVarint(digVal, otpBytes)
|
||||
|
||||
otpBytes.push(48)
|
||||
writeVarint(2, otpBytes) // TOTP = 2
|
||||
|
||||
payloadBytes.push(10)
|
||||
writeVarint(otpBytes.length, payloadBytes)
|
||||
for (let i = 0; i < otpBytes.length; i++) payloadBytes.push(otpBytes[i])
|
||||
}
|
||||
|
||||
let binary = ''
|
||||
for (let i = 0; i < payloadBytes.length; i++) binary += String.fromCharCode(payloadBytes[i])
|
||||
const uri = `otpauth-migration://offline?data=${encodeURIComponent(btoa(binary))}`
|
||||
const dataUrl = await QRCode.toDataURL(uri, { errorCorrectionLevel: 'M', width: 480, margin: 2 })
|
||||
qrDataUrls.push(dataUrl);
|
||||
}
|
||||
|
||||
return qrDataUrls;
|
||||
},
|
||||
|
||||
async exportAsHtml(vault) {
|
||||
const QRCode = await import('qrcode')
|
||||
const htmlContent = []
|
||||
|
||||
htmlContent.push(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>2FAuth 备份报告</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, system-ui, sans-serif; padding: 20px; color: #333; max-width: 1000px; margin: 0 auto; line-height: 1.5; }
|
||||
.header { text-align: center; margin-bottom: 40px; border-bottom: 2px solid #eaeaea; padding-bottom: 20px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 20px; }
|
||||
.card { border: 1px solid #ddd; border-radius: 8px; padding: 15px; text-align: center; display: flex; flex-direction: column; align-items: center; background: white; page-break-inside: avoid; }
|
||||
.qr-img { width: 160px; height: 160px; margin: 10px 0; border: 1px solid #eee; }
|
||||
.service { font-weight: bold; font-size: 1.1em; color: #1a73e8; margin-bottom: 5px; word-break: break-all; }
|
||||
.account { color: #555; font-size: 0.9em; margin-bottom: 15px; word-break: break-all; }
|
||||
.code { font-family: monospace; background: #f5f5f5; padding: 5px 10px; border-radius: 4px; font-size: 1.2em; letter-spacing: 2px; }
|
||||
.footer { text-align: center; margin-top: 50px; color: #888; font-size: 0.9em; page-break-before: auto; }
|
||||
@media print {
|
||||
body { padding: 0; }
|
||||
.no-print { display: none; }
|
||||
.card { box-shadow: none; border: 1px solid #999; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>2FAuth 二步验证账户备份</h1>
|
||||
<p>生成时间:${new Date().toLocaleString()}</p>
|
||||
<p class="no-print" style="color: #d93025; font-weight: bold;">⚠️ 警告:此页面包含敏感信息,请妥善保管。请使用浏览器打印功能将其保存为 PDF 或打印成纸质备份。</p>
|
||||
<button class="no-print" onclick="window.print()" style="padding: 10px 20px; background: #1a73e8; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 16px;">打印 / 导出 PDF</button>
|
||||
</div>
|
||||
<div class="grid">
|
||||
`)
|
||||
|
||||
for (const acc of vault) {
|
||||
const label = encodeURIComponent(`${acc.account}`)
|
||||
const issuer = encodeURIComponent(acc.service)
|
||||
const uri = `otpauth://totp/${label}?secret=${acc.secret}&issuer=${issuer}&digits=${acc.digits}&period=${acc.period}`
|
||||
|
||||
try {
|
||||
const qrDataUrl = await QRCode.toDataURL(uri, { errorCorrectionLevel: 'M', margin: 2 })
|
||||
htmlContent.push(`
|
||||
<div class="card">
|
||||
<div class="service">${acc.service}</div>
|
||||
<div class="account">${acc.account || '-'}</div>
|
||||
<img class="qr-img" src="${qrDataUrl}" alt="QR Code">
|
||||
<div class="code">${acc.secret.replace(/(.{4})/g, '$1 ').trim()}</div>
|
||||
<div style="font-size: 0.8em; color: #888; margin-top: 8px;">${acc.algorithm} / ${acc.digits} / ${acc.period}s</div>
|
||||
</div>
|
||||
`)
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
htmlContent.push(`
|
||||
</div>
|
||||
<div class="footer"><p>This report was securely generated in the browser for backup purposes.</p></div>
|
||||
</body>
|
||||
</html>
|
||||
`)
|
||||
return htmlContent.join('\n')
|
||||
},
|
||||
|
||||
/**
|
||||
* 3.0 解析PhoneFactor SQLite数据库
|
||||
* @param {ArrayBuffer} dbBuffer - SQLite数据库二进制数据
|
||||
* @returns {Promise<Object[]>} 标准化的账户列表
|
||||
* @throws {migrationError}
|
||||
*/
|
||||
async parsePhoneFactor(dbBuffer) {
|
||||
try {
|
||||
// 初始化 sql.js:浏览器端优先从 /sql-wasm.wasm 或 CDN 加载 wasm,Node 环境从 node_modules 加载
|
||||
let SQL
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
SQL = await initSqlJs({ locateFile: file => `/sql-wasm.wasm` })
|
||||
} catch (e) {
|
||||
// 如果本地 public 中不存在 wasm,回退到 CDN
|
||||
SQL = await initSqlJs({ locateFile: file => `https://unpkg.com/sql.js@1.10.3/dist/${file}` })
|
||||
}
|
||||
} else {
|
||||
SQL = await initSqlJs({ locateFile: file => require('path').resolve('node_modules/sql.js/dist/', file) })
|
||||
}
|
||||
} catch (e) {
|
||||
// 最后兜底,直接初始化(可能仍然抛错)
|
||||
SQL = await initSqlJs()
|
||||
}
|
||||
const db = new SQL.Database(new Uint8Array(dbBuffer))
|
||||
|
||||
// 检查accounts表是否存在
|
||||
const tables = db.exec("SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'")
|
||||
if (tables.length === 0) {
|
||||
throw new migrationError('不是有效的Microsoft Authenticator数据文件', 'INVALID_PHONEFACTOR_FILE')
|
||||
}
|
||||
|
||||
// 使用 prepared statement 逐行读取,避免依赖 exec 返回的 columns/values 结构不一致
|
||||
const vault = []
|
||||
let skippedEncrypted = 0
|
||||
let skippedEmpty = 0
|
||||
const base32Re = /^[A-Z2-7]+=*$/i
|
||||
|
||||
try {
|
||||
const stmt = db.prepare("SELECT name, username, oath_secret_key, encrypted_oath_secret_key FROM accounts WHERE account_type=0")
|
||||
while (stmt.step()) {
|
||||
const record = stmt.getAsObject()
|
||||
const name = record.name
|
||||
const username = record.username
|
||||
const oath_secret_key = record.oath_secret_key
|
||||
const encrypted_oath_secret_key = record.encrypted_oath_secret_key
|
||||
|
||||
// 跳过条件:name 和 username 同时为空
|
||||
if ((!name || String(name).trim() === '') && (!username || String(username).trim() === '')) {
|
||||
skippedEmpty++
|
||||
continue
|
||||
}
|
||||
|
||||
const secret = (oath_secret_key || '').toString().trim()
|
||||
if (!secret) {
|
||||
if (encrypted_oath_secret_key && String(encrypted_oath_secret_key).trim() !== '') {
|
||||
skippedEncrypted++
|
||||
continue
|
||||
}
|
||||
skippedEmpty++
|
||||
continue
|
||||
}
|
||||
|
||||
const normalized = secret.replace(/\s+/g, '').replace(/=+$/, '')
|
||||
if (!base32Re.test(normalized)) {
|
||||
if (encrypted_oath_secret_key && String(encrypted_oath_secret_key).trim() !== '') {
|
||||
skippedEncrypted++
|
||||
continue
|
||||
}
|
||||
skippedEmpty++
|
||||
continue
|
||||
}
|
||||
|
||||
vault.push({
|
||||
service: name || 'Unknown Service',
|
||||
account: username || 'Unknown Account',
|
||||
secret: secret,
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30
|
||||
})
|
||||
}
|
||||
stmt.free()
|
||||
} catch (e) {
|
||||
db.close()
|
||||
throw new migrationError('解析 PhoneFactor 数据库失败', 'INVALID_PHONEFACTOR_FILE', e)
|
||||
}
|
||||
|
||||
db.close()
|
||||
if (vault.length === 0) {
|
||||
if (skippedEncrypted > 0) throw new migrationError('PhoneFactor 文件仅包含加密的密钥,无法在前端导入', 'PHONEFACTOR_ONLY_ENCRYPTED')
|
||||
throw new migrationError('未能从 PhoneFactor 文件中提取到可导入的 TOTP 记录', 'PHONEFACTOR_NO_IMPORTABLE_ROWS')
|
||||
}
|
||||
return vault
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof migrationError) {
|
||||
console.error('parsePhoneFactor migrationError:', error)
|
||||
throw error
|
||||
}
|
||||
// 打印详细调试信息到控制台,包含底层错误与缓冲长度
|
||||
try {
|
||||
const bufLen = (dbBuffer && (dbBuffer.byteLength || dbBuffer.length)) || 0
|
||||
console.error('parsePhoneFactor failed:', { message: error && error.message, stack: error && error.stack, bufferLength: bufLen, error })
|
||||
} catch (logErr) {
|
||||
console.error('parsePhoneFactor failed (logging error):', logErr)
|
||||
}
|
||||
const msg = error && error.message ? `${error.message}` : String(error)
|
||||
throw new migrationError(`不是有效的Microsoft Authenticator数据文件: ${msg}`, 'INVALID_PHONEFACTOR_FILE', error)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 3. 导入逻辑:前端解密/分发解析序列化策略
|
||||
* @param {string|ArrayBuffer} content - 文件内容或二进制数据
|
||||
* @param {string} type - 探测出的类型
|
||||
* @param {string} [password] - 加密文件的密码
|
||||
* @returns {Promise<Object[]>} 返回标准化后的金库清单
|
||||
* @throws {migrationError}
|
||||
*/
|
||||
async parseImportData(content, type, password) {
|
||||
let rawVault = []
|
||||
|
||||
// 处理PhoneFactor SQLite数据库
|
||||
if (type === 'phonefactor') {
|
||||
return await this.parsePhoneFactor(content)
|
||||
}
|
||||
|
||||
if (type === 'bwauth_csv' || type === 'generic_csv') {
|
||||
rawVault = csvStrategy.parseCsv(content)
|
||||
content = JSON.stringify(rawVault)
|
||||
type = 'raw'
|
||||
}
|
||||
|
||||
if (type === 'aegis_encrypted') {
|
||||
const parsedAegis = tryParseJSON(content)
|
||||
const decryptedDb = await aegisStrategy.decryptDatabase(parsedAegis, password)
|
||||
content = JSON.stringify(decryptedDb)
|
||||
type = 'aegis'
|
||||
password = undefined
|
||||
} else if (type === 'aegis') {
|
||||
const parsedAegis = tryParseJSON(content)
|
||||
content = JSON.stringify(parsedAegis.db)
|
||||
}
|
||||
|
||||
if (type === 'encrypted') {
|
||||
if (!password) throw new migrationError('导入加密文件需要密码', 'MISSING_PASSWORD')
|
||||
try {
|
||||
let ciphertext = content
|
||||
const json = typeof content === 'string' ? tryParseJSON(content) : content
|
||||
if (json && typeof json === 'object' && json.data) ciphertext = json.data
|
||||
const decrypted = await decryptDataWithPassword(ciphertext, password)
|
||||
rawVault = decrypted.vault || decrypted.accounts || []
|
||||
} catch (e) {
|
||||
throw new migrationError('解密失败:密码错误或文件格式不兼容', 'DECRYPTION_FAILED', e)
|
||||
}
|
||||
}
|
||||
else if (type === 'json') {
|
||||
const json = typeof content === 'string' ? JSON.parse(content) : content
|
||||
if (Array.isArray(json.accounts)) rawVault = json.accounts
|
||||
else if (Array.isArray(json.vault)) rawVault = json.vault
|
||||
else if (Array.isArray(json.data)) rawVault = json.data
|
||||
else if (json.secrets) {
|
||||
rawVault = json.secrets.map(s => {
|
||||
let account = s.account || s.label || '';
|
||||
if (typeof account === 'string' && account.includes(':')) {
|
||||
account = account.split(':').pop()?.trim() || account;
|
||||
}
|
||||
return {
|
||||
service: s.issuer || s.service || s.name || 'Unknown',
|
||||
account,
|
||||
secret: s.secret || '',
|
||||
algorithm: s.algorithm || 'SHA1',
|
||||
digits: s.digits || 6,
|
||||
period: s.period || 30,
|
||||
}
|
||||
})
|
||||
} else if (Array.isArray(json)) rawVault = json
|
||||
}
|
||||
else if (type === '2fas') {
|
||||
const json = typeof content === 'string' ? JSON.parse(content) : content
|
||||
if (Array.isArray(json.services)) {
|
||||
rawVault = json.services.map(s => {
|
||||
let account = s.otp?.account || s.account || s.username || '';
|
||||
if (typeof account === 'string' && account.includes(':')) {
|
||||
account = account.split(':').pop()?.trim() || account;
|
||||
}
|
||||
return {
|
||||
service: s.otp?.issuer || s.name || 'Unknown',
|
||||
account,
|
||||
secret: s.secret || '',
|
||||
algorithm: (s.otp?.algorithm || s.algorithm || 'SHA1').toUpperCase(),
|
||||
digits: s.otp?.digits || s.digits || 6,
|
||||
period: s.otp?.period || s.period || 30,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
else if (type === 'bwauth_json') {
|
||||
const json = typeof content === 'string' ? JSON.parse(content) : content
|
||||
if (Array.isArray(json.items)) {
|
||||
json.items.forEach(item => {
|
||||
if (item.login && item.login.totp) {
|
||||
const accInfo = parseOtpUri(item.login.totp)
|
||||
if (accInfo) {
|
||||
accInfo.service = item.name || accInfo.service
|
||||
accInfo.account = item.login.username || accInfo.account
|
||||
rawVault.push(accInfo)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
else if (type === 'aegis') {
|
||||
const json = typeof content === 'string' ? JSON.parse(content) : content
|
||||
const entries = json.entries || (json.db && json.db.entries) || []
|
||||
rawVault = entries.map(s => ({
|
||||
service: s.issuer || s.name || 'Unknown',
|
||||
account: s.name || '',
|
||||
secret: s.info?.secret || '',
|
||||
algorithm: s.info?.algo || 'SHA1',
|
||||
digits: s.info?.digits || 6,
|
||||
period: s.info?.period || 30,
|
||||
}))
|
||||
}
|
||||
else if (type === 'text') {
|
||||
const lines = content.split('\n')
|
||||
lines.forEach(line => {
|
||||
const acc = parseOtpUri(line.trim())
|
||||
if (acc) rawVault.push(acc)
|
||||
})
|
||||
}
|
||||
else if (type === 'bwauth_csv' || type === 'generic_csv') rawVault = csvStrategy.parseCsv(content)
|
||||
|
||||
return rawVault.map(acc => {
|
||||
if (typeof acc.account === 'string' && acc.account.includes(':')) {
|
||||
acc.account = acc.account.split(':').pop()?.trim() || acc.account
|
||||
}
|
||||
if (!acc.account || acc.account.trim() === '') acc.account = acc.service || 'Unknown Account'
|
||||
return acc
|
||||
}).filter(acc => acc && acc.secret && acc.service)
|
||||
},
|
||||
|
||||
/**
|
||||
* 3.1 解析 GA 二维码图片
|
||||
* @param {File} file
|
||||
* @returns {Promise<Object[]>}
|
||||
*/
|
||||
async parseGaQrImageFile(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
const url = URL.createObjectURL(file)
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
const canvas = document.createElement('canvas')
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true })
|
||||
const scales = [1.0, 1.5, 0.5, 2.0, 0.8]
|
||||
let code = null
|
||||
for (const scale of scales) {
|
||||
canvas.width = img.width * scale
|
||||
canvas.height = img.height * scale
|
||||
context.imageSmoothingEnabled = false
|
||||
context.drawImage(img, 0, 0, canvas.width, canvas.height)
|
||||
const imageData = context.getImageData(0, 0, canvas.width, canvas.height)
|
||||
code = jsQR(imageData.data, imageData.width, imageData.height, { inversionAttempts: "attemptBoth" })
|
||||
if (code) break;
|
||||
}
|
||||
|
||||
if (!code) return reject(new migrationError('未能识别出二维码,请确认为完整清晰的截图。', 'QR_RECOGNITION_FAILED'))
|
||||
const uri = code.data
|
||||
if (!uri.startsWith('otpauth-migration://offline?data=')) return reject(new migrationError('不是有效的 Google Authenticator 迁移二维码', 'INVALID_GA_QR'))
|
||||
|
||||
try {
|
||||
const urlParams = new URL(uri).searchParams
|
||||
const dataBase64Url = urlParams.get('data')
|
||||
let base64 = dataBase64Url.replace(/-/g, '+').replace(/_/g, '/')
|
||||
while (base64.length % 4) base64 += '='
|
||||
const raw = atob(base64)
|
||||
const dataBytes = new Uint8Array(raw.length)
|
||||
for (let i = 0; i < raw.length; i++) dataBytes[i] = raw.charCodeAt(i)
|
||||
resolve(gaMigrationStrategy.decodePayload(dataBytes))
|
||||
} catch (e) { reject(new migrationError('解析 Google Authenticator 数据失败', 'GA_DECODE_FAILED', e)) }
|
||||
}
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
reject(new migrationError('图片读取失败,文件可能已损坏', 'IMAGE_LOAD_FAILED'))
|
||||
}
|
||||
img.src = url
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 4. 批量保存到后端
|
||||
* @param {Object[]} vault
|
||||
*/
|
||||
async saveImportedVault(vault) {
|
||||
return await vaultService.importVault(vault)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
<template>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="export-groups" v-loading="isExporting" :element-loading-text="loadingText">
|
||||
|
||||
<!-- 1. 本系统备份 -->
|
||||
<div class="export-group-card">
|
||||
<div class="group-header">
|
||||
<el-icon><Lock /></el-icon>
|
||||
<span>本系统备份</span>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<el-button plain @click="openExportDialog('encrypted')" class="button-with-icon">
|
||||
<el-icon><Lock /></el-icon> 加密备份 (.json)
|
||||
</el-button>
|
||||
<el-button plain @click="openWarningDialog('json')" class="button-with-icon">
|
||||
<el-icon><Unlock /></el-icon> 明文备份 (.json)
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. 移动端 2FA App -->
|
||||
<div class="export-group-card">
|
||||
<div class="group-header">
|
||||
<el-icon><Iphone /></el-icon>
|
||||
<span>移动端 2FA App</span>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<el-button plain @click="openWarningDialog('2fas')" class="button-with-icon">
|
||||
<el-icon><icon2FAS /></el-icon> 2FAS (.2fas)
|
||||
</el-button>
|
||||
<el-button plain @click="openWarningDialog('aegis')" class="button-with-icon">
|
||||
<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-button>
|
||||
<el-button plain @click="openWarningDialog('bwauth')" class="button-with-icon">
|
||||
<el-icon><iconBitwarden /></el-icon> Bitwarden Auth (.json)
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. 通用格式 -->
|
||||
<div class="export-group-card">
|
||||
<div class="group-header">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>通用格式</span>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<el-button plain @click="openWarningDialog('generic_json')" class="button-with-icon">
|
||||
<el-icon><Document /></el-icon> 通用格式 (.json)
|
||||
</el-button>
|
||||
<el-button plain @click="openWarningDialog('text')" class="button-with-icon">
|
||||
<el-icon><Tickets /></el-icon> OTPAuth URI (.txt)
|
||||
</el-button>
|
||||
<el-button plain @click="openWarningDialog('csv', 'generic')" class="button-with-icon">
|
||||
<el-icon><Grid /></el-icon> 电子表格 (.csv)
|
||||
</el-button>
|
||||
<el-button plain @click="openWarningDialog('html')" class="button-with-icon">
|
||||
<el-icon><Monitor /></el-icon> 普通网页 (.html)
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加密导出密码弹窗 -->
|
||||
<el-dialog v-model="showPasswordDialog" 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>
|
||||
<el-form-item label="确认密码">
|
||||
<el-input v-model="exportForm.confirm" type="password" show-password @keyup.enter="executeExport" placeholder="请再次输入" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showPasswordDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="isExporting" @click="executeExport">开始加密并下载</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 />
|
||||
<template #footer>
|
||||
<el-button @click="showWarningDialog = false">取消</el-button>
|
||||
<el-button type="danger" @click="executeExport" :loading="isExporting">确定导出</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Google Auth 二维码弹窗 -->
|
||||
<el-dialog v-model="showGaDialog" title="迁移到 Google Authenticator" 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/>
|
||||
<span v-if="gaQrDataUrls.length > 1" style="color: var(--el-color-warning); font-weight: bold;">
|
||||
(账号较多,已分批生成,请依次扫码)
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else v-loading="isExporting" style="min-height: 200px;"></div>
|
||||
<template #footer>
|
||||
<el-button @click="showGaDialog = false">完成</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<!-- 账号选择弹窗 (用于 Google Auth 选择性导出) -->
|
||||
<el-dialog v-model="showAccountSelectDialog" title="选择要导出的账号" width="450px" destroy-on-close>
|
||||
<div class="account-select-toolbar">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索服务或账号..."
|
||||
clearable
|
||||
:prefix-icon="Search"
|
||||
style="margin-bottom: 15px;"
|
||||
/>
|
||||
<div class="toolbar-actions">
|
||||
<el-checkbox
|
||||
v-model="isAllSelected"
|
||||
:indeterminate="isIndeterminate"
|
||||
@change="toggleSelectAll"
|
||||
>
|
||||
{{ selectAllText }}
|
||||
</el-checkbox>
|
||||
<span class="selected-count">已选: {{ selectedAccountIds.length }} / {{ fullVault.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="account-list-container">
|
||||
<el-checkbox-group v-model="selectedAccountIds">
|
||||
<el-scrollbar max-height="300px">
|
||||
<template v-if="filteredVault.length > 0">
|
||||
<div v-for="acc in filteredVault" :key="acc.id" class="account-item">
|
||||
<el-checkbox :label="acc.id" size="large">
|
||||
<div class="account-item-content">
|
||||
<span class="service-name">{{ acc.service || 'Unknown Service' }}</span>
|
||||
<span v-if="acc.account" class="account-name">{{ acc.account }}</span>
|
||||
</div>
|
||||
</el-checkbox>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty v-else description="无匹配账号" :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>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
Lock, Iphone, Document, Search,
|
||||
Unlock, Tickets, Grid, Monitor
|
||||
} from '@element-plus/icons-vue'
|
||||
import icon2FAS from '@/shared/components/icons/icon2FAS.vue'
|
||||
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'
|
||||
|
||||
const {
|
||||
showPasswordDialog,
|
||||
showWarningDialog,
|
||||
showAccountSelectDialog,
|
||||
showGaDialog,
|
||||
isExporting,
|
||||
exportForm,
|
||||
loadingText,
|
||||
gaQrDataUrls,
|
||||
gaCurrentIndex,
|
||||
|
||||
fullVault,
|
||||
searchKeyword,
|
||||
selectedAccountIds,
|
||||
filteredVault,
|
||||
|
||||
openExportDialog,
|
||||
openWarningDialog,
|
||||
openGaDialogDirectly,
|
||||
executeExport,
|
||||
executeGaExport,
|
||||
toggleSelectAll
|
||||
} = useDataExport()
|
||||
|
||||
// 动态计算“全选”按钮文案
|
||||
const selectAllText = computed(() => {
|
||||
if (!searchKeyword.value || searchKeyword.value.trim() === '') {
|
||||
return '全选所有账号'
|
||||
} else {
|
||||
return `全选搜索出的 ${filteredVault.value.length} 个账号`
|
||||
}
|
||||
})
|
||||
|
||||
// 计算当前过滤列表的全选状态
|
||||
const isAllSelected = computed({
|
||||
get: () => {
|
||||
if (filteredVault.value.length === 0) return false
|
||||
return filteredVault.value.every(acc => selectedAccountIds.value.includes(acc.id))
|
||||
},
|
||||
set: (val) => {
|
||||
// 触发由 toggleSelectAll 在 handle change 时处理
|
||||
}
|
||||
})
|
||||
|
||||
// 半选状态
|
||||
const isIndeterminate = computed(() => {
|
||||
const selectedInFilter = filteredVault.value.filter(acc => selectedAccountIds.value.includes(acc.id)).length
|
||||
return selectedInFilter > 0 && selectedInFilter < filteredVault.value.length
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.export-groups {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.export-group-card {
|
||||
padding: 20px;
|
||||
background-color: var(--el-bg-color-overlay);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 8px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 15px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.group-header .el-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 18px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.button-row .el-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.button-with-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.button-with-icon .el-icon {
|
||||
margin-right: 6px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 账号选择列表样式 */
|
||||
.account-select-toolbar {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 5px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.selected-count {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.account-list-container {
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 4px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.account-item {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.account-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.account-item-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-left: 8px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.service-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.account-name {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
/* 修复由于 el-checkbox content-box 导致的文字变形 */
|
||||
:deep(.el-checkbox__label) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div style="max-width: 600px; margin: 0 auto;">
|
||||
|
||||
<!-- 统一的拖拽上传区域 -->
|
||||
<el-upload
|
||||
class="import-upload"
|
||||
drag
|
||||
action="#"
|
||||
multiple
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
:on-change="handleFileUpload"
|
||||
>
|
||||
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
|
||||
<div class="el-upload__text">
|
||||
将备份文件或迁移二维码截图拖到此处,或 <em>点击上传</em>
|
||||
</div>
|
||||
<template #tip>
|
||||
<div class="import-tips">
|
||||
<el-alert
|
||||
title="💡 自动识别文件类型进行导入"
|
||||
type="success"
|
||||
:closable="false"
|
||||
style="margin-bottom: 20px;"
|
||||
/>
|
||||
|
||||
<div class="format-groups">
|
||||
<div class="format-group">
|
||||
<h4>📁 本系统备份</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="format-group">
|
||||
<h4>📱 移动端 2FA App</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>
|
||||
<el-tag type="info" effect="light"><iconBitwarden /> Bitwarden Auth (.json/.csv)</el-tag>
|
||||
<el-tag type="info" effect="light"><iconGoogleAuth /> Google Auth (.png/.jpg)</el-tag>
|
||||
<el-tag type="info" effect="light"><iconMicrosoftAuth /> Microsoft Auth (PhoneFactor)</el-tag>
|
||||
</div>
|
||||
<div class="ga-tip">
|
||||
<span>Google Authenticator</span>
|
||||
<p>请在 App 内导出"迁移二维码",将截图直接上传 (.png / .jpg)</p>
|
||||
</div>
|
||||
<div class="ms-tip">
|
||||
<span>Microsoft Authenticator <small>(仅支持 Android)</small></span>
|
||||
<p>将 PhoneFactor 文件直接上传,Android 手机该文件路径为: <code>/data/data/com.azure.authenticator/databases/PhoneFactor</code></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="format-group">
|
||||
<h4>📄 通用格式</h4>
|
||||
<div class="tags">
|
||||
<el-tag type="info" effect="light"><el-icon><Document /></el-icon> OTPAuth URI (.txt)</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 批量导入进度弹窗 -->
|
||||
<el-dialog
|
||||
v-model="showBatchProgress"
|
||||
title="📦 批量导入处理中"
|
||||
width="450px"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="false"
|
||||
:show-close="false"
|
||||
>
|
||||
<div style="text-align: center; padding: 10px 0;">
|
||||
<el-progress
|
||||
type="dashboard"
|
||||
:percentage="batchProgressPercent"
|
||||
:status="batchProgressPercent === 100 ? 'success' : ''"
|
||||
/>
|
||||
<h3 style="margin-top: 20px;">
|
||||
{{ batchProcessedJobs }} / {{ batchTotalJobs }} 任务已完成
|
||||
</h3>
|
||||
<p style="color: var(--el-text-color-secondary); margin-top: 10px;">
|
||||
{{ batchCurrentTaskName }}
|
||||
</p>
|
||||
</div>
|
||||
</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 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-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showDecryptDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="isDecrypting" @click="submitEncryptedData">确认解密并导入</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { UploadFilled, Lock, Unlock, Document } from '@element-plus/icons-vue'
|
||||
import { useDataImport } from '@/features/migration/composables/useDataImport'
|
||||
|
||||
// icons for import options (follow export page style)
|
||||
import icon2FAS from '@/shared/components/icons/icon2FAS.vue'
|
||||
import iconAegis from '@/shared/components/icons/iconAegis.vue'
|
||||
import iconGoogleAuth from '@/shared/components/icons/iconGoogleAuth.vue'
|
||||
import iconBitwarden from '@/shared/components/icons/iconBitwarden.vue'
|
||||
import iconMicrosoftAuth from '@/shared/components/icons/iconMicrosoftAuth.vue'
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
|
||||
const {
|
||||
currentImportType,
|
||||
showDecryptDialog,
|
||||
importPassword,
|
||||
isDecrypting,
|
||||
showBatchProgress,
|
||||
batchCurrentTaskName,
|
||||
batchProcessedJobs,
|
||||
batchTotalJobs,
|
||||
batchProgressPercent,
|
||||
handleFileUpload,
|
||||
submitEncryptedData,
|
||||
handleDecryptDialogClose
|
||||
} = useDataImport(emit)
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.import-upload {
|
||||
margin-top: 20px;
|
||||
}
|
||||
:deep(.el-upload-dragger) {
|
||||
padding: 40px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-color: var(--el-border-color);
|
||||
}
|
||||
:deep(.el-upload-dragger:hover) {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
:deep(.el-icon--upload) {
|
||||
font-size: 48px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* 提示区美化 */
|
||||
.import-tips {
|
||||
text-align: left;
|
||||
margin-top: 25px;
|
||||
background-color: var(--el-fill-color-lighter);
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px dashed var(--el-border-color);
|
||||
}
|
||||
|
||||
.format-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.format-group h4 {
|
||||
margin: 0 0 10px 0;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tags span.el-tag {
|
||||
padding: 8px 15px;
|
||||
height: auto;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.tags span i.el-icon {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ga-tip {
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid var(--el-color-primary);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.ga-tip span {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--el-color-primary);
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.ga-tip p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ms-tip {
|
||||
background-color: var(--el-color-success-light-9);
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid var(--el-color-success);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.ms-tip span {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--el-color-success);
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.ms-tip p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--el-color-regular);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.ms-tip p code {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div class="migration-container">
|
||||
<el-tabs v-model="activeTab" class="migration-tabs">
|
||||
<el-tab-pane label="数据导出" name="export">
|
||||
<DataExport />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="数据导入" name="import">
|
||||
<DataImport @success="$emit('success')" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import DataExport from '@/features/migration/views/dataExport.vue'
|
||||
import DataImport from '@/features/migration/views/dataImport.vue'
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
const activeTab = ref('export')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.migration-container {
|
||||
padding: 10px;
|
||||
}
|
||||
.migration-tabs :deep(.el-tabs__nav-wrap::after) {
|
||||
height: 1px;
|
||||
}
|
||||
</style>
|
||||
@@ -38,54 +38,33 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { request } from '@/shared/utils/request'
|
||||
import { useTimeSync } from '@/features/tools/composables/useTimeSync'
|
||||
|
||||
const localTime = ref(Date.now())
|
||||
const offset = ref(null)
|
||||
const rtt = ref(null)
|
||||
const isSyncing = ref(false)
|
||||
let clockTimer = null
|
||||
const {
|
||||
localTime,
|
||||
serverTime,
|
||||
offset,
|
||||
rtt,
|
||||
isSyncing,
|
||||
syncStatus,
|
||||
syncTime: _syncTime
|
||||
} = useTimeSync()
|
||||
|
||||
const serverTime = computed(() => localTime.value + (offset.value || 0))
|
||||
const formatTime = (ts) => new Date(ts).toLocaleTimeString()
|
||||
|
||||
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 syncTime = async () => {
|
||||
isSyncing.value = true
|
||||
const start = Date.now()
|
||||
try {
|
||||
const res = await request('/api/tools/server-time')
|
||||
const end = Date.now()
|
||||
if (res.success) {
|
||||
const serverTs = res.time
|
||||
rtt.value = end - start
|
||||
const estimatedServerTime = serverTs + (rtt.value / 2)
|
||||
offset.value = Math.round(estimatedServerTime - end)
|
||||
ElMessage.success(`校准完成,偏差 ${offset.value}ms`)
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '无法连接服务器')
|
||||
} finally {
|
||||
isSyncing.value = false
|
||||
const result = await _syncTime()
|
||||
if (result.success) {
|
||||
ElMessage.success(`校准完成,偏差 ${result.offset}ms`)
|
||||
} else {
|
||||
ElMessage.error(result.error?.message || '无法连接服务器')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
clockTimer = setInterval(() => { localTime.value = Date.now() }, 1000)
|
||||
syncTime()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (clockTimer) clearInterval(clockTimer)
|
||||
})
|
||||
</script>
|
||||
@@ -154,203 +154,39 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, defineAsyncComponent } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import { CopyDocument, Refresh, Timer, Camera, CircleCheck, Download } from '@element-plus/icons-vue'
|
||||
import QRCode from 'qrcode'
|
||||
import { request } from '@/shared/utils/request'
|
||||
import { base32ToBytes, bytesToBase32, hexToBytes, bytesToHex, asciiToBytes, bytesToAscii, generateTOTP, parseOtpUri } from '@/shared/utils/totp'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { copyToClipboard, triggerDownload } from '@/shared/utils/common'
|
||||
import { useTotpToolbox } from '@/features/tools/composables/useTotpToolbox'
|
||||
import { useTotpToolboxActions } from '@/features/tools/composables/useTotpToolboxActions'
|
||||
|
||||
const QrScanner = defineAsyncComponent(() => import('@/shared/components/qrScanner.vue'))
|
||||
|
||||
// --- State ---
|
||||
const queryClient = useQueryClient()
|
||||
const activeTab = ref('base32')
|
||||
const secretBase32 = ref('')
|
||||
const secretHex = ref('')
|
||||
const secretAscii = ref('')
|
||||
|
||||
// Metadata & Settings
|
||||
const issuer = ref('2FAuth-Tool')
|
||||
const account = ref('TestUser')
|
||||
const algorithm = ref('SHA-1')
|
||||
const digits = ref(6)
|
||||
const period = ref(30)
|
||||
const timeOffset = ref(0)
|
||||
// 1. 获取核心工具箱状态机 (纯运算逻辑与时钟循环)
|
||||
const toolbox = useTotpToolbox()
|
||||
const {
|
||||
activeTab,
|
||||
secretBase32, secretHex, secretAscii,
|
||||
issuer, account, algorithm, digits, period, timeOffset,
|
||||
currentUri, currentCode, remaining,
|
||||
handleBase32Input, handleHexInput, handleAsciiInput, updateUri,
|
||||
refreshBase32, refreshHex, refreshAscii,
|
||||
adjustTime
|
||||
} = toolbox
|
||||
|
||||
const qrCodeUrl = ref('')
|
||||
const currentUri = ref('')
|
||||
const currentCode = ref('')
|
||||
const remaining = ref(30)
|
||||
const isSaving = ref(false)
|
||||
const showScanner = ref(false)
|
||||
let timer = null
|
||||
|
||||
// --- Handlers ---
|
||||
const updateAll = async (sourceType) => {
|
||||
try {
|
||||
// 1. Sync Inputs
|
||||
if (sourceType === 'base32') {
|
||||
const bytes = base32ToBytes(secretBase32.value)
|
||||
secretHex.value = bytesToHex(bytes)
|
||||
secretAscii.value = bytesToAscii(bytes)
|
||||
} else if (sourceType === 'hex') {
|
||||
const bytes = hexToBytes(secretHex.value)
|
||||
if (bytes.length > 0) secretBase32.value = bytesToBase32(bytes)
|
||||
secretAscii.value = bytesToAscii(bytes)
|
||||
} else if (sourceType === 'ascii') {
|
||||
const bytes = asciiToBytes(secretAscii.value)
|
||||
secretBase32.value = bytesToBase32(bytes)
|
||||
secretHex.value = bytesToHex(bytes)
|
||||
}
|
||||
|
||||
// 2. Generate QR
|
||||
if (secretBase32.value) {
|
||||
const label = encodeURIComponent(`${issuer.value}:${account.value}`)
|
||||
const algoParam = algorithm.value.replace('-', '') // SHA-1 -> SHA1
|
||||
const uri = `otpauth://totp/${label}?secret=${secretBase32.value}&issuer=${encodeURIComponent(issuer.value)}&algorithm=${algoParam}&period=${period.value}&digits=${digits.value}`
|
||||
currentUri.value = uri
|
||||
qrCodeUrl.value = await QRCode.toDataURL(uri, { width: 200, margin: 1 })
|
||||
} else {
|
||||
qrCodeUrl.value = ''
|
||||
currentUri.value = ''
|
||||
currentCode.value = ''
|
||||
}
|
||||
|
||||
// 3. Update TOTP immediately
|
||||
calcTotp()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
// Ignore decoding errors during typing
|
||||
}
|
||||
}
|
||||
|
||||
const handleBase32Input = () => updateAll('base32')
|
||||
const handleHexInput = () => updateAll('hex')
|
||||
const handleAsciiInput = () => updateAll('ascii')
|
||||
const updateUri = () => updateAll('settings')
|
||||
|
||||
const refreshBase32 = () => {
|
||||
// Generate 20 random bytes (160 bits) - Standard high security
|
||||
const array = new Uint8Array(20)
|
||||
window.crypto.getRandomValues(array)
|
||||
secretBase32.value = bytesToBase32(array)
|
||||
updateAll('base32')
|
||||
ElMessage.success('已生成新的随机密钥')
|
||||
}
|
||||
|
||||
const refreshHex = () => {
|
||||
// Generate 20 random bytes
|
||||
const array = new Uint8Array(20)
|
||||
window.crypto.getRandomValues(array)
|
||||
secretHex.value = bytesToHex(array)
|
||||
updateAll('hex')
|
||||
ElMessage.success('已生成新的 Hex 密钥')
|
||||
}
|
||||
|
||||
const refreshAscii = () => {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()'
|
||||
let result = ''
|
||||
const array = new Uint32Array(20)
|
||||
window.crypto.getRandomValues(array)
|
||||
for (let i = 0; i < 20; i++) result += chars[array[i] % chars.length]
|
||||
secretAscii.value = result
|
||||
updateAll('ascii')
|
||||
ElMessage.success('已生成随机 ASCII 密钥')
|
||||
}
|
||||
|
||||
// --- TOTP Calculation ---
|
||||
const calcTotp = async () => {
|
||||
if (!secretBase32.value) return
|
||||
|
||||
const p = period.value
|
||||
const now = (Date.now() / 1000) + timeOffset.value
|
||||
const epoch = Math.floor(now / p)
|
||||
remaining.value = Math.ceil(p - (now % p))
|
||||
|
||||
try {
|
||||
// 计算周期偏移量 (Time Travel)
|
||||
const periodOffset = Math.floor(timeOffset.value / p)
|
||||
currentCode.value = await generateTOTP(secretBase32.value, p, digits.value, algorithm.value, periodOffset)
|
||||
} catch (e) {
|
||||
currentCode.value = 'ERROR'
|
||||
}
|
||||
}
|
||||
|
||||
// --- Time Travel ---
|
||||
const adjustTime = (delta, reset = false) => {
|
||||
if (reset) timeOffset.value = 0
|
||||
else timeOffset.value += delta
|
||||
calcTotp()
|
||||
}
|
||||
|
||||
// --- Import QR ---
|
||||
const handleScanSuccess = (uri) => {
|
||||
showScanner.value = false
|
||||
handleParsedUri(uri)
|
||||
}
|
||||
|
||||
const handleParsedUri = (uri) => {
|
||||
const parsed = parseOtpUri(uri)
|
||||
if (parsed) {
|
||||
if (parsed.secret) {
|
||||
secretBase32.value = parsed.secret
|
||||
updateAll('base32')
|
||||
}
|
||||
if (parsed.service) issuer.value = parsed.service
|
||||
if (parsed.account) account.value = parsed.account
|
||||
if (parsed.digits) digits.value = parsed.digits
|
||||
if (parsed.period) period.value = parsed.period
|
||||
if (parsed.algorithm) algorithm.value = parsed.algorithm
|
||||
|
||||
updateAll('settings')
|
||||
ElMessage.success('二维码解析成功')
|
||||
} else {
|
||||
ElMessage.warning('无效的 OTP URI')
|
||||
}
|
||||
}
|
||||
|
||||
// --- Save to Vault ---
|
||||
const saveToVault = async () => {
|
||||
if (!secretBase32.value) return ElMessage.warning('密钥不能为空')
|
||||
if (!issuer.value || !account.value) return ElMessage.warning('请填写服务商和账号')
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
const res = await request('/api/vault', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
service: issuer.value,
|
||||
account: account.value,
|
||||
secret: secretBase32.value,
|
||||
digits: digits.value,
|
||||
period: period.value,
|
||||
category: '工具箱添加'
|
||||
})
|
||||
})
|
||||
if (res.success) {
|
||||
ElMessage.success('已保存到我的账户')
|
||||
// 刷新账号列表缓存
|
||||
queryClient.invalidateQueries(['vault'])
|
||||
}
|
||||
} catch (e) {
|
||||
// Error handled by request
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
// 2. 获取外部副作用处理器 (QR生成、扫码注入与后端保存)
|
||||
const {
|
||||
isSaving,
|
||||
showScanner,
|
||||
qrCodeUrl,
|
||||
handleScanSuccess,
|
||||
saveToVault
|
||||
} = useTotpToolboxActions(toolbox, queryClient)
|
||||
|
||||
const downloadQrCode = () => {
|
||||
if (!qrCodeUrl.value) return
|
||||
triggerDownload(qrCodeUrl.value, `2fa-qr-${account.value || 'code'}.png`)
|
||||
if (!qrCodeUrl.value) return
|
||||
triggerDownload(qrCodeUrl.value, `2fa-qr-${account.value || 'code'}.png`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshBase32() // Init with a random key
|
||||
timer = setInterval(calcTotp, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => { if (timer) clearInterval(timer) })
|
||||
</script>
|
||||
@@ -0,0 +1,65 @@
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { toolService } from '@/features/tools/service/toolService'
|
||||
|
||||
/**
|
||||
* 时间校准逻辑提取
|
||||
*
|
||||
* @returns {Object} 包含校准状态与方法的响应式对象
|
||||
*/
|
||||
export function useTimeSync() {
|
||||
const localTime = ref(Date.now())
|
||||
const offset = ref(null)
|
||||
const rtt = ref(null)
|
||||
const isSyncing = ref(false)
|
||||
let clockTimer = null
|
||||
|
||||
const serverTime = computed(() => localTime.value + (offset.value || 0))
|
||||
|
||||
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 syncTime = async () => {
|
||||
isSyncing.value = true
|
||||
const start = Date.now()
|
||||
try {
|
||||
const res = await toolService.getServerTime()
|
||||
const end = Date.now()
|
||||
if (res.success) {
|
||||
const serverTs = res.time
|
||||
rtt.value = end - start
|
||||
// 假设往返时间是对称的,服务器返回时间为收到请求时的 serverTs + 单程网络延迟
|
||||
const estimatedServerTime = serverTs + (rtt.value / 2)
|
||||
offset.value = Math.round(estimatedServerTime - end)
|
||||
return { success: true, offset: offset.value }
|
||||
}
|
||||
return { success: false, error: new Error('返回结构异常') }
|
||||
} catch (error) {
|
||||
return { success: false, error }
|
||||
} finally {
|
||||
isSyncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
clockTimer = setInterval(() => { localTime.value = Date.now() }, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (clockTimer) clearInterval(clockTimer)
|
||||
})
|
||||
|
||||
return {
|
||||
localTime,
|
||||
serverTime,
|
||||
offset,
|
||||
rtt,
|
||||
isSyncing,
|
||||
syncStatus,
|
||||
syncTime
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { base32ToBytes, bytesToBase32, hexToBytes, bytesToHex, asciiToBytes, bytesToAscii, generateTOTP, parseOtpUri } from '@/shared/utils/totp'
|
||||
|
||||
/**
|
||||
* 提取 TOTP 工具箱的状态机与计算逻辑
|
||||
*
|
||||
* 架构说明 (Architecture Notes):
|
||||
* 1. 状态内聚: 将 Base32, Hex, ASCII 相互转换的逻辑和时间便宜量计算封入本 Hook。
|
||||
* 2. 分离视图: 使得 Vue 组件单纯负责数据绑定,不再关心 bytes 到 string 的转换细节和 setInterval 时序。
|
||||
*
|
||||
* @returns 包含状态与操作方法的响应式对象
|
||||
*/
|
||||
export function useTotpToolbox() {
|
||||
const activeTab = ref('base32')
|
||||
const secretBase32 = ref('')
|
||||
const secretHex = ref('')
|
||||
const secretAscii = ref('')
|
||||
|
||||
// Metadata & Settings
|
||||
const issuer = ref('2FAuth-Tool')
|
||||
const account = ref('TestUser')
|
||||
const algorithm = ref('SHA-1')
|
||||
const digits = ref(6)
|
||||
const period = ref(30)
|
||||
const timeOffset = ref(0)
|
||||
|
||||
const currentUri = ref('')
|
||||
const currentCode = ref('')
|
||||
const remaining = ref(30)
|
||||
let timer = null
|
||||
|
||||
// --- 同步输入联动 ---
|
||||
const updateAll = async (sourceType) => {
|
||||
try {
|
||||
// 1. 同步各类输入制式
|
||||
if (sourceType === 'base32') {
|
||||
const bytes = base32ToBytes(secretBase32.value)
|
||||
secretHex.value = bytesToHex(bytes)
|
||||
secretAscii.value = bytesToAscii(bytes)
|
||||
} else if (sourceType === 'hex') {
|
||||
const bytes = hexToBytes(secretHex.value)
|
||||
if (bytes.length > 0) secretBase32.value = bytesToBase32(bytes)
|
||||
secretAscii.value = bytesToAscii(bytes)
|
||||
} else if (sourceType === 'ascii') {
|
||||
const bytes = asciiToBytes(secretAscii.value)
|
||||
secretBase32.value = bytesToBase32(bytes)
|
||||
secretHex.value = bytesToHex(bytes)
|
||||
}
|
||||
|
||||
// 2. URI 字符串拼接
|
||||
if (secretBase32.value) {
|
||||
const label = encodeURIComponent(`${issuer.value}:${account.value}`)
|
||||
const algoParam = algorithm.value.replace('-', '') // SHA-1 -> SHA1
|
||||
currentUri.value = `otpauth://totp/${label}?secret=${secretBase32.value}&issuer=${encodeURIComponent(issuer.value)}&algorithm=${algoParam}&period=${period.value}&digits=${digits.value}`
|
||||
} else {
|
||||
currentUri.value = ''
|
||||
currentCode.value = ''
|
||||
}
|
||||
|
||||
// 3. 立即重算一次 TOTP
|
||||
calcTotp()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
// 打字过程中的解码失败不做打断处理
|
||||
}
|
||||
}
|
||||
|
||||
const handleBase32Input = () => updateAll('base32')
|
||||
const handleHexInput = () => updateAll('hex')
|
||||
const handleAsciiInput = () => updateAll('ascii')
|
||||
const updateUri = () => updateAll('settings')
|
||||
|
||||
// --- 随机生成 ---
|
||||
const refreshBase32 = () => {
|
||||
const array = new Uint8Array(20)
|
||||
window.crypto.getRandomValues(array)
|
||||
secretBase32.value = bytesToBase32(array)
|
||||
updateAll('base32')
|
||||
}
|
||||
|
||||
const refreshHex = () => {
|
||||
const array = new Uint8Array(20)
|
||||
window.crypto.getRandomValues(array)
|
||||
secretHex.value = bytesToHex(array)
|
||||
updateAll('hex')
|
||||
}
|
||||
|
||||
const refreshAscii = () => {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()'
|
||||
let result = ''
|
||||
const array = new Uint32Array(20)
|
||||
window.crypto.getRandomValues(array)
|
||||
for (let i = 0; i < 20; i++) result += chars[array[i] % chars.length]
|
||||
secretAscii.value = result
|
||||
updateAll('ascii')
|
||||
}
|
||||
|
||||
// --- TOTP 主力计算核心 ---
|
||||
const calcTotp = async () => {
|
||||
if (!secretBase32.value) return
|
||||
|
||||
const p = period.value
|
||||
// 加入时空穿梭偏移量
|
||||
const now = (Date.now() / 1000) + timeOffset.value
|
||||
remaining.value = Math.ceil(p - (now % p))
|
||||
|
||||
try {
|
||||
const periodOffset = Math.floor(timeOffset.value / p)
|
||||
currentCode.value = await generateTOTP(secretBase32.value, p, digits.value, algorithm.value, periodOffset)
|
||||
} catch (e) {
|
||||
currentCode.value = 'ERROR'
|
||||
}
|
||||
}
|
||||
|
||||
// --- 时间调节器 (Time Travel) ---
|
||||
const adjustTime = (delta, reset = false) => {
|
||||
if (reset) timeOffset.value = 0
|
||||
else timeOffset.value += delta
|
||||
calcTotp()
|
||||
}
|
||||
|
||||
// --- 扫码解析注入 ---
|
||||
const handleParsedUri = (uri) => {
|
||||
const parsed = parseOtpUri(uri)
|
||||
if (parsed) {
|
||||
if (parsed.secret) {
|
||||
secretBase32.value = parsed.secret
|
||||
updateAll('base32')
|
||||
}
|
||||
if (parsed.service) issuer.value = parsed.service
|
||||
if (parsed.account) account.value = parsed.account
|
||||
if (parsed.digits) digits.value = parsed.digits
|
||||
if (parsed.period) period.value = parsed.period
|
||||
if (parsed.algorithm) algorithm.value = parsed.algorithm
|
||||
|
||||
updateAll('settings')
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 初始装载随机码
|
||||
refreshBase32()
|
||||
timer = setInterval(calcTotp, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => { if (timer) clearInterval(timer) })
|
||||
|
||||
return {
|
||||
activeTab,
|
||||
secretBase32, secretHex, secretAscii,
|
||||
issuer, account, algorithm, digits, period, timeOffset,
|
||||
currentUri, currentCode, remaining,
|
||||
handleBase32Input, handleHexInput, handleAsciiInput, updateUri,
|
||||
refreshBase32, refreshHex, refreshAscii,
|
||||
adjustTime, handleParsedUri
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import QRCode from 'qrcode'
|
||||
import { vaultService } from '@/features/vault/service/vaultService'
|
||||
|
||||
/**
|
||||
* TOTP 工具箱外部协同调度逻辑
|
||||
*
|
||||
* 架构说明:
|
||||
* 专门处理工具箱产生的与外部 API / 库的桥接:如触发二维码生成库 QRCode,与后端 Vault 保存 API 进行联调。
|
||||
* 这保证了 useTotpToolbox.js 作为纯状态机,不涉及第三方 UI 或非标准 API 边界效应。
|
||||
*/
|
||||
export function useTotpToolboxActions(toolboxState, queryClient) {
|
||||
const isSaving = ref(false)
|
||||
const showScanner = ref(false)
|
||||
const qrCodeUrl = ref('')
|
||||
|
||||
// 监听 currentUri 自动重绘二维码
|
||||
watch(() => toolboxState.currentUri.value, async (newUri) => {
|
||||
if (newUri) {
|
||||
try {
|
||||
qrCodeUrl.value = await QRCode.toDataURL(newUri, { width: 200, margin: 1 })
|
||||
} catch (e) {
|
||||
qrCodeUrl.value = ''
|
||||
}
|
||||
} else {
|
||||
qrCodeUrl.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
const handleScanSuccess = (uri) => {
|
||||
showScanner.value = false
|
||||
const success = toolboxState.handleParsedUri(uri)
|
||||
if (success) {
|
||||
ElMessage.success('二维码解析成功')
|
||||
} else {
|
||||
ElMessage.warning('无效的 OTP URI')
|
||||
}
|
||||
}
|
||||
|
||||
const saveToVault = async () => {
|
||||
if (!toolboxState.secretBase32.value) return ElMessage.warning('密钥不能为空')
|
||||
if (!toolboxState.issuer.value || !toolboxState.account.value) return ElMessage.warning('请填写服务商和账号')
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
const res = await vaultService.createAccount({
|
||||
service: toolboxState.issuer.value,
|
||||
account: toolboxState.account.value,
|
||||
secret: toolboxState.secretBase32.value,
|
||||
digits: toolboxState.digits.value,
|
||||
period: toolboxState.period.value,
|
||||
category: '工具箱添加'
|
||||
})
|
||||
if (res.success) {
|
||||
ElMessage.success('已保存到我的账户')
|
||||
// 刷新账号列表缓存
|
||||
queryClient.invalidateQueries(['vault'])
|
||||
}
|
||||
} catch (e) {
|
||||
// Error managed by axios request interceptor & vaultService wrapping
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isSaving,
|
||||
showScanner,
|
||||
qrCodeUrl,
|
||||
handleScanSuccess,
|
||||
saveToVault
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { request } from '@/shared/utils/request'
|
||||
import { toolsError } from '@/shared/utils/errors/toolsError'
|
||||
|
||||
/**
|
||||
* @typedef {Object} ServerTimeResponse
|
||||
* @property {boolean} success - 请求是否成功
|
||||
* @property {number} time - 服务器时间戳 (毫秒)
|
||||
*/
|
||||
|
||||
export const toolService = {
|
||||
/**
|
||||
* 获取服务器当前时间
|
||||
* @returns {Promise<ServerTimeResponse>}
|
||||
* @throws {toolsError}
|
||||
*/
|
||||
async getServerTime() {
|
||||
try {
|
||||
return await request('/api/tools/server-time')
|
||||
} catch (error) {
|
||||
throw new toolsError(error.message || '获取服务器时间失败', error.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,9 +60,9 @@
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { request } from '@/shared/utils/request'
|
||||
import { bytesToBase32 } from '@/shared/utils/totp'
|
||||
import { useVaultStore } from '@/features/vault/store/vaultStore'
|
||||
import { vaultService } from '@/features/vault/service/vaultService'
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
|
||||
@@ -106,10 +106,7 @@ const submitAddVault = async () => {
|
||||
if (valid) {
|
||||
submitting.value = true
|
||||
try {
|
||||
const data = await request('/api/vault', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(newVault.value)
|
||||
})
|
||||
const data = await vaultService.createAccount(newVault.value)
|
||||
if (data.success) {
|
||||
ElMessage.success('账号添加成功!')
|
||||
newVault.value = { service: '', account: '', secret: '', category: '', digits: 6, period: 30, algorithm: 'SHA1' }
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<script setup>
|
||||
import { h, defineAsyncComponent } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { request } from '@/shared/utils/request'
|
||||
import { parseOtpUri } from '@/shared/utils/totp'
|
||||
import { useVaultStore } from '@/features/vault/store/vaultStore'
|
||||
import { vaultService } from '@/features/vault/service/vaultService'
|
||||
|
||||
const QrScanner = defineAsyncComponent(() => import('@/shared/components/qrScanner.vue'))
|
||||
|
||||
@@ -60,10 +60,7 @@ const handleScanSuccess = async (uri) => {
|
||||
}
|
||||
)
|
||||
|
||||
const addData = await request('/api/vault/add-from-uri', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ uri, category: '手机扫码' })
|
||||
})
|
||||
const addData = await vaultService.addFromUri(uri, '手机扫码')
|
||||
|
||||
if (addData.success) {
|
||||
ElMessage.success('扫码账号添加成功!')
|
||||
|
||||
@@ -115,7 +115,7 @@ import {
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useLayoutStore } from '@/shared/stores/layoutStore'
|
||||
import { useThemeStore } from '@/shared/stores/themeStore'
|
||||
import { useUserStore } from '@/features/auth/store/userStore'
|
||||
import { useAuthUserStore } from '@/features/auth/store/authUserStore'
|
||||
|
||||
const props = defineProps({
|
||||
activeTab: {
|
||||
@@ -128,7 +128,7 @@ const emit = defineEmits(['select'])
|
||||
|
||||
const layoutStore = useLayoutStore()
|
||||
const themeStore = useThemeStore()
|
||||
const userStore = useUserStore()
|
||||
const authUserStore = useAuthUserStore()
|
||||
const router = useRouter()
|
||||
|
||||
const isCollapse = ref(localStorage.getItem('sidebar_collapse') === 'true')
|
||||
@@ -148,7 +148,7 @@ const handleLogout = async () => {
|
||||
if (layoutStore.showMobileMenu) {
|
||||
layoutStore.showMobileMenu = false
|
||||
}
|
||||
await userStore.logout()
|
||||
await authUserStore.logout()
|
||||
router.replace('/login')
|
||||
ElMessage.success('已安全退出')
|
||||
}
|
||||
|
||||
@@ -28,8 +28,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 1. 加载状态 (本地缓存尚未挂载,或者正在首次/后台请求时但无数据) -->
|
||||
<div v-if="(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);">
|
||||
<!-- 1. 加载状态 -->
|
||||
<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>
|
||||
</div>
|
||||
@@ -77,7 +78,7 @@
|
||||
</el-dropdown>
|
||||
</div>
|
||||
|
||||
<p class="vault-name">{{ vaultItem.account }}</p>
|
||||
<p class="vault-name">{{ vaultItem.account?.includes(':') ? vaultItem.account.split(':').pop() : vaultItem.account }}</p>
|
||||
|
||||
<div class="code-display-area">
|
||||
<div class="code-left">
|
||||
@@ -170,334 +171,80 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, shallowRef, onMounted, onUnmounted, watch, computed, defineAsyncComponent, nextTick, triggerRef } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
/**
|
||||
* 核心金库列表组件 (Vault List Root Component)
|
||||
*
|
||||
* 架构说明 (Architecture Notes):
|
||||
* 1. 离线优先与秒开 (Offline-First Initialization):
|
||||
* `isInitializing` 状态拦截了组件初筛。生命周期 `onMounted` 时优先呼叫 `handleUnlocked` 读取
|
||||
* `localStorage` 加密缓存 (`vaultStore.getData()`)。读取闭环后关闭 `isInitializing` 使得
|
||||
* UI 瞬间呈现历史数据,随后交由 Vue Query 在后台默默触发真实网络同步 (`fetchVault`) 更新状态,
|
||||
* 达到丝滑“秒开”极致体验。
|
||||
* 2. 消除瀑布流计算 (Heavy Compute Deferment):
|
||||
* 将获取到缓存后极其冗长的 Hash 计算任务 `updateVaultStatus()` 通过 `setTimeout(..., 0)`
|
||||
* 推至浏览器的后续事件队列中,彻底让出 JavaScript 渲染主线程,保障金库大列表在手机端冷启动不白屏、不阻塞。
|
||||
* 3. 循环依赖解构 (Dependency Inversion):
|
||||
* 鉴于获取数据的 `useVaultList` 和执行计算的 `useTotpTimer` 各自闭环又存在先后依赖,此处以
|
||||
* `afterLoadRef` 作媒介进行延迟绑定:
|
||||
* `useVaultList` (接收 `afterLoadRef`) -> `useTotpTimer` 实例生成 -> 绑定回调 ->
|
||||
* 下一次 Vue Query 拉回新数据时,直接调用绑定的 `updateVaultStatus` 进行后台预运算。
|
||||
*/
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { MoreFilled, Edit, Delete, Picture, View, Hide, CopyDocument, Loading, Search } from '@element-plus/icons-vue'
|
||||
import QRCode from 'qrcode'
|
||||
import { request } from '@/shared/utils/request'
|
||||
import { useLayoutStore } from '@/shared/stores/layoutStore'
|
||||
import { useVaultStore } from '@/features/vault/store/vaultStore'
|
||||
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/vue-query'
|
||||
import { generateTOTP } from '@/shared/utils/totp'
|
||||
import { copyToClipboard } from '@/shared/utils/common'
|
||||
import { useVaultList } from '@/features/vault/composables/useVaultList'
|
||||
import { useTotpTimer } from '@/features/vault/composables/useTotpTimer'
|
||||
import { useVaultActions } from '@/features/vault/composables/useVaultActions'
|
||||
|
||||
const emit = defineEmits(['switch-tab'])
|
||||
const queryClient = useQueryClient()
|
||||
const vaultStore = useVaultStore()
|
||||
const layoutStore = useLayoutStore()
|
||||
const vaultStore = useVaultStore()
|
||||
|
||||
// --- 状态定义 ---
|
||||
const vault = shallowRef([])
|
||||
const searchQuery = ref('')
|
||||
const pageSize = ref(12)
|
||||
// afterLoadRef 用于解决循环依赖:useVaultList 先于 useTotpTimer 创建,
|
||||
// 通过 ref 延迟绑定 updateVaultStatus,确保每次分页加载后立即刷新验证码
|
||||
const afterLoadRef = ref(null)
|
||||
|
||||
// --- 批量操作 ---
|
||||
const selectedIds = ref([])
|
||||
const isBulkDeleting = ref(false)
|
||||
const {
|
||||
vault, searchQuery, isLoading, isFetching, isFetchingNextPage,
|
||||
hasNextPage, isLoadMoreDisabled, fetchVault, handleLoadMore
|
||||
} = useVaultList(afterLoadRef)
|
||||
|
||||
// --- 弹窗状态 ---
|
||||
const showEditDialog = ref(false)
|
||||
const isEditing = ref(false)
|
||||
const editVaultData = ref({ id: '', service: '', account: '', category: '' })
|
||||
const { updateVaultStatus } = useTotpTimer(vault)
|
||||
|
||||
const showQrDialog = ref(false)
|
||||
const currentQrItem = ref(null)
|
||||
const showSecret = ref(false)
|
||||
const qrCodeUrl = ref('')
|
||||
// 绑定数据加载后的回调(数据合并完成后立即触发 TOTP 计算,无需等待 1 秒间隔)
|
||||
afterLoadRef.value = updateVaultStatus
|
||||
|
||||
let globalTimer = null
|
||||
const {
|
||||
selectedIds, isBulkDeleting,
|
||||
showEditDialog, isEditing, editVaultData,
|
||||
showQrDialog, currentQrItem, showSecret, qrCodeUrl,
|
||||
toggleSelection, selectAllLoaded, handleBulkDelete, copyCode,
|
||||
submitEditVault, openQrDialog, copySecret, copyOtpUrl,
|
||||
formatSecret, handleCommand,
|
||||
} = useVaultActions(fetchVault, vault)
|
||||
|
||||
// --- 定时更新 (提升定义顺序) ---
|
||||
// 更新倒计时与验证码
|
||||
const updateVaultStatus = async (targetList) => {
|
||||
// 支持传入特定列表进行预计算(用于渲染前),如果不传则默认更新当前显示的 vault
|
||||
const list = Array.isArray(targetList) ? targetList : vault.value
|
||||
if (!list || list.length === 0) return
|
||||
// mount 到 handleUnlocked 完成前保持 true,确保本地缓存读取期间显示 loading 动画
|
||||
const isInitializing = ref(true)
|
||||
|
||||
const now = Date.now() / 1000
|
||||
|
||||
// 关键修复:使用 Promise.all 并行处理所有账号的计算,避免列表较长时串行 await 导致验证码生成延迟或卡顿
|
||||
await Promise.all(list.map(async (acc) => {
|
||||
const period = acc.period || 30
|
||||
const remaining = Math.ceil(period - (now % period))
|
||||
acc.remaining = remaining
|
||||
acc.percentage = (remaining / period) * 100
|
||||
|
||||
// 颜色逻辑
|
||||
if (remaining > 10) acc.color = '#67C23A'
|
||||
else if (remaining > 5) acc.color = '#E6A23C'
|
||||
else acc.color = '#F56C6C'
|
||||
|
||||
// 计算当前验证码 (如果 epoch 变了或者还没计算)
|
||||
const currentEpoch = Math.floor(now / period)
|
||||
const algorithm = acc.algorithm ? acc.algorithm.replace('SHA', 'SHA-').replace('SHA--', 'SHA-') : 'SHA-1'
|
||||
if (acc.lastEpoch !== currentEpoch || !acc.currentCode || acc.currentCode === '------') {
|
||||
acc.currentCode = await generateTOTP(acc.secret, period, acc.digits, algorithm)
|
||||
acc.lastEpoch = currentEpoch
|
||||
}
|
||||
|
||||
// 剩余5秒时计算下一个验证码
|
||||
if (remaining <= 5) {
|
||||
if (!acc.nextCode || acc.lastNextEpoch !== currentEpoch + 1) {
|
||||
acc.nextCode = await generateTOTP(acc.secret, period, acc.digits, algorithm, 1)
|
||||
acc.lastNextEpoch = currentEpoch + 1
|
||||
}
|
||||
} else {
|
||||
acc.nextCode = null
|
||||
}
|
||||
}))
|
||||
|
||||
// 关键优化:因为 vault 是 shallowRef,必须在每轮更新完毕后执行一次通知
|
||||
// 从而使得 1000 次深层属性变更合并成 1 次 UI 组件挂载重绘
|
||||
if (list === vault.value) {
|
||||
triggerRef(vault)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 工具函数 ---
|
||||
let searchTimer = null
|
||||
|
||||
// --- Vue Query 集成 ---
|
||||
|
||||
// 1. 定义数据获取函数
|
||||
const fetchVaultPage = async ({ pageParam = 1 }) => {
|
||||
// 如果未解锁,直接返回空
|
||||
if (!vaultStore.isUnlocked) return { vault: [], pagination: { totalPages: 0 } }
|
||||
|
||||
const query = new URLSearchParams({
|
||||
page: pageParam,
|
||||
limit: pageSize.value,
|
||||
search: searchQuery.value
|
||||
}).toString()
|
||||
|
||||
return await request(`/api/vault?${query}`)
|
||||
}
|
||||
|
||||
// 2. 使用 useInfiniteQuery
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
refetch
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ['vault', searchQuery], // 搜索词变化自动触发重载
|
||||
queryFn: fetchVaultPage,
|
||||
getNextPageParam: (lastPage) => {
|
||||
if (!lastPage || !lastPage.pagination) return undefined
|
||||
const { page, totalPages } = lastPage.pagination
|
||||
return page < totalPages ? page + 1 : undefined
|
||||
},
|
||||
enabled: computed(() => vaultStore.isUnlocked), // 仅在解锁后启用查询
|
||||
staleTime: 0, // 始终视为过期,确保 invalidateQueries 能立即触发重新请求
|
||||
})
|
||||
|
||||
// 3. 监听数据变化,同步到本地 vault 引用和保险箱
|
||||
watch(data, async (newData) => {
|
||||
if (!newData) return
|
||||
|
||||
// 展平分页数据
|
||||
const flatVault = newData.pages.flatMap(page => page.vault || [])
|
||||
|
||||
// 智能合并:保留现有的 TOTP 计算状态 (currentCode, remaining 等),仅更新静态字段
|
||||
// 这样可以避免每次 fetch 导致倒计时重置
|
||||
const merged = flatVault.map(newAcc => {
|
||||
const existing = vault.value.find(a => a.id === newAcc.id)
|
||||
if (existing) {
|
||||
return { ...existing, ...newAcc }
|
||||
}
|
||||
// Initialize new vault with all reactive properties to ensure Vue tracks them
|
||||
return {
|
||||
...newAcc,
|
||||
currentCode: '------',
|
||||
nextCode: null,
|
||||
remaining: 30,
|
||||
percentage: 0,
|
||||
color: ''
|
||||
}
|
||||
})
|
||||
|
||||
// 1. Pre-calculate codes before assigning to the reactive ref
|
||||
await updateVaultStatus(merged)
|
||||
|
||||
// 2. Update the view
|
||||
vault.value = merged
|
||||
|
||||
// 3. Force trigger reactivity as a safeguard against missed updates
|
||||
triggerRef(vault)
|
||||
|
||||
// 4. 加密保存到保险箱 (仅在无搜索词时保存,作为全量缓存)
|
||||
if (!searchQuery.value && vaultStore.isUnlocked) {
|
||||
try {
|
||||
await vaultStore.saveData({ vault: merged })
|
||||
vaultStore.clearDirty() // 此次获取的是最新数据,清除 dirty 标记
|
||||
} catch (e) {
|
||||
// Silently fail, or add more robust error handling if needed
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 5. 解锁回调
|
||||
// --- 解锁回调:离线优先,秒开首屏 ---
|
||||
const handleUnlocked = async () => {
|
||||
// 若有未保存的变更,跳过旧缓存,直接等待服务器数据
|
||||
if (vaultStore.isDirty) return
|
||||
|
||||
// 1. 优先加载离线数据,实现秒开 (移除耗时的 await updateVaultStatus 控制权)
|
||||
const vaultData = await vaultStore.getData()
|
||||
if (vaultData && vaultData.vault) {
|
||||
vault.value = vaultData.vault
|
||||
triggerRef(vault) // 瞬间触发 UI 大列表首屏渲染 (携带缓存旧状态)
|
||||
|
||||
// 把耗时的 200次+ HMACS Hash 计算扔进异步宏任务/微任务中,让出关键渲染主线程
|
||||
setTimeout(() => updateVaultStatus(), 0)
|
||||
}
|
||||
|
||||
// 2. Vue Query 的 enabled 变为 true,会自动触发网络请求更新数据
|
||||
}
|
||||
|
||||
// 6. 滚动加载处理
|
||||
const isLoadMoreDisabled = computed(() => {
|
||||
return isLoading.value || isFetchingNextPage.value || !hasNextPage.value || !vaultStore.isUnlocked || isError.value
|
||||
})
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (!isLoadMoreDisabled.value) {
|
||||
fetchNextPage()
|
||||
}
|
||||
}
|
||||
|
||||
// 暴露给父组件的方法 (保持兼容性)
|
||||
defineExpose({
|
||||
fetchVault: () => queryClient.invalidateQueries({ queryKey: ['vault'] })
|
||||
})
|
||||
|
||||
watch(searchQuery, () => {
|
||||
// Vue Query 会自动处理列表更新
|
||||
})
|
||||
|
||||
// 批量选择
|
||||
const toggleSelection = (id) => {
|
||||
const index = selectedIds.value.indexOf(id)
|
||||
if (index > -1) selectedIds.value.splice(index, 1)
|
||||
else selectedIds.value.push(id)
|
||||
}
|
||||
|
||||
const selectAllLoaded = () => {
|
||||
// 全选当前已加载的所有账号
|
||||
selectedIds.value = vault.value.map(acc => acc.id)
|
||||
}
|
||||
|
||||
// 批量删除
|
||||
const handleBulkDelete = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除选中的 ${selectedIds.value.length} 个账号吗?`, '警告', { type: 'error' })
|
||||
isBulkDeleting.value = true
|
||||
const res = await request('/api/vault/batch-delete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids: selectedIds.value })
|
||||
})
|
||||
if (res.success) {
|
||||
ElMessage.success(`成功删除了 ${res.count} 个账号`)
|
||||
selectedIds.value = []
|
||||
vaultStore.markDirty()
|
||||
queryClient.invalidateQueries({ queryKey: ['vault'] }) // 强制过期,绕过 staleTime
|
||||
}
|
||||
} catch (e) {} finally { isBulkDeleting.value = false }
|
||||
}
|
||||
|
||||
// 单个账号操作 (删除/编辑/导出)
|
||||
const handleCommand = async (command, item) => {
|
||||
if (command === 'delete') {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除 [${item.service}] 吗?`, '警告', { type: 'error' })
|
||||
const data = await request(`/api/vault/${item.id}`, { method: 'DELETE' })
|
||||
if (data.success) {
|
||||
ElMessage.success('账号已删除')
|
||||
vaultStore.markDirty()
|
||||
queryClient.invalidateQueries({ queryKey: ['vault'] }) // 强制过期,绕过 staleTime
|
||||
}
|
||||
} catch (e) {}
|
||||
} else if (command === 'edit') {
|
||||
editVaultData.value = { ...item, category: item.category || '' }
|
||||
showEditDialog.value = true
|
||||
} else if (command === 'qr') {
|
||||
currentQrItem.value = item
|
||||
try {
|
||||
const uri = getOtpAuthUrl(item)
|
||||
qrCodeUrl.value = await QRCode.toDataURL(uri, { width: 200, margin: 1, errorCorrectionLevel: 'M' })
|
||||
showQrDialog.value = true
|
||||
} catch (e) {
|
||||
ElMessage.error('二维码生成失败')
|
||||
if (vaultStore.isDirty) return
|
||||
const vaultData = await vaultStore.getData()
|
||||
if (vaultData && vaultData.vault) {
|
||||
vault.value = vaultData.vault
|
||||
// 把耗时 TOTP 计算推进异步,让出关键渲染主线程
|
||||
setTimeout(() => updateVaultStatus(), 0)
|
||||
}
|
||||
} finally {
|
||||
isInitializing.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 提交编辑
|
||||
const submitEditVault = async () => {
|
||||
if (!editVaultData.value.service || !editVaultData.value.account) return ElMessage.warning('必填项不能为空')
|
||||
isEditing.value = true
|
||||
try {
|
||||
const data = await request(`/api/vault/${editVaultData.value.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
service: editVaultData.value.service,
|
||||
account: editVaultData.value.account,
|
||||
category: editVaultData.value.category
|
||||
})
|
||||
})
|
||||
if (data.success) {
|
||||
ElMessage.success('账号修改成功')
|
||||
showEditDialog.value = false
|
||||
vaultStore.markDirty()
|
||||
queryClient.invalidateQueries({ queryKey: ['vault'] }) // 强制过期,绕过 staleTime
|
||||
}
|
||||
} catch (e) {} finally { isEditing.value = false }
|
||||
}
|
||||
// 暴露给父组件的刷新方法(保持接口兼容)
|
||||
defineExpose({ fetchVault })
|
||||
|
||||
// --- 辅助功能 ---
|
||||
const copyCode = async (item) => {
|
||||
const code = (item.remaining <= 5 && item.nextCode) ? item.nextCode : item.currentCode
|
||||
if (!code || code === '------') return
|
||||
copyToClipboard(code, '验证码已复制')
|
||||
}
|
||||
|
||||
const getOtpAuthUrl = (acc) => {
|
||||
if (!acc) return ''
|
||||
const label = encodeURIComponent(`${acc.service}:${acc.account}`)
|
||||
const params = new URLSearchParams()
|
||||
params.set('secret', acc.secret)
|
||||
params.set('issuer', acc.service)
|
||||
if (acc.algorithm) params.set('algorithm', acc.algorithm)
|
||||
if (acc.digits) params.set('digits', acc.digits)
|
||||
if (acc.period) params.set('period', acc.period)
|
||||
return `otpauth://totp/${label}?${params.toString()}`
|
||||
}
|
||||
|
||||
const copyOtpUrl = async () => {
|
||||
const url = getOtpAuthUrl(currentQrItem.value)
|
||||
copyToClipboard(url, 'otpauth已复制')
|
||||
}
|
||||
|
||||
const formatSecret = (secret) => {
|
||||
return secret ? secret.replace(/(.{4})/g, '$1 ').trim() : ''
|
||||
}
|
||||
|
||||
const copySecret = async () => {
|
||||
if (!currentQrItem.value?.secret) return
|
||||
copyToClipboard(currentQrItem.value.secret, '密钥已复制')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 组件加载即尝试读取本地离线数据
|
||||
handleUnlocked()
|
||||
globalTimer = setInterval(updateVaultStatus, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (globalTimer) clearInterval(globalTimer)
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
})
|
||||
onMounted(handleUnlocked)
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { onMounted, onUnmounted, triggerRef } from 'vue'
|
||||
import { generateTOTP } from '@/shared/utils/totp'
|
||||
|
||||
/**
|
||||
* 管理所有保险箱账号的 TOTP 验证码与倒计时刷新
|
||||
*
|
||||
* 架构说明 (Architecture Notes):
|
||||
* 1. 性能优化 (Parallel Computation): 循环列表触发 HMAC-SHA-1 等 Hash 计算是昂贵的 CPU 任务。
|
||||
* 因此,遍历 Vault 时摒弃串行 `await`,采取 `Promise.all` 全并发模式,榨干宏任务的吞吐量,
|
||||
* 以实现成百上千条账号依然不阻塞验证码更新的渲染节奏。
|
||||
* 2. 批量渲染 (Reactivity Batches): 受益于 Vue 的依赖收集,将 `vault` 定义为 `shallowRef`,
|
||||
* 在批量计算结束后调用唯一一次 `triggerRef(vault)`,从而使得深层的 1000 次元素级属性修改合并成了
|
||||
* Vue 的一次虚拟 DOM 树 diff 与重绘。
|
||||
* 3. 异步分流: 将 5 秒即将变色的逻辑与下次新 Code 的提前量生成机制解耦,确保证预计算。
|
||||
*
|
||||
* @param {import('vue').ShallowRef} vault - 当前账号列表的 shallowRef
|
||||
* @returns Composable lifecycle bindings (auto-mounted via onMounted/onUnmounted)
|
||||
*/
|
||||
export function useTotpTimer(vault) {
|
||||
let globalTimer = null
|
||||
|
||||
/**
|
||||
* 并发计算所有账号的 TOTP 验证码与剩余时间
|
||||
* @param {Object[]} [targetList] - 可选,传入用于首次渲染前的预计算
|
||||
*/
|
||||
const updateVaultStatus = async (targetList) => {
|
||||
const list = Array.isArray(targetList) ? targetList : vault.value
|
||||
if (!list || list.length === 0) return
|
||||
|
||||
const now = Date.now() / 1000
|
||||
|
||||
// 使用 Promise.all 并行处理所有账号的计算,避免串行 await 导致验证码生成延迟
|
||||
await Promise.all(list.map(async (acc) => {
|
||||
const period = acc.period || 30
|
||||
const remaining = Math.ceil(period - (now % period))
|
||||
acc.remaining = remaining
|
||||
acc.percentage = (remaining / period) * 100
|
||||
|
||||
// 颜色逻辑
|
||||
if (remaining > 10) acc.color = '#67C23A'
|
||||
else if (remaining > 5) acc.color = '#E6A23C'
|
||||
else acc.color = '#F56C6C'
|
||||
|
||||
// 计算当前验证码(epoch 变化时或首次计算)
|
||||
const currentEpoch = Math.floor(now / period)
|
||||
const algorithm = acc.algorithm
|
||||
? acc.algorithm.replace('SHA', 'SHA-').replace('SHA--', 'SHA-')
|
||||
: 'SHA-1'
|
||||
|
||||
if (acc.lastEpoch !== currentEpoch || !acc.currentCode || acc.currentCode === '------') {
|
||||
acc.currentCode = await generateTOTP(acc.secret, period, acc.digits, algorithm)
|
||||
acc.lastEpoch = currentEpoch
|
||||
}
|
||||
|
||||
// 剩余 5 秒时预计算下一段验证码
|
||||
if (remaining <= 5) {
|
||||
if (!acc.nextCode || acc.lastNextEpoch !== currentEpoch + 1) {
|
||||
acc.nextCode = await generateTOTP(acc.secret, period, acc.digits, algorithm, 1)
|
||||
acc.lastNextEpoch = currentEpoch + 1
|
||||
}
|
||||
} else {
|
||||
acc.nextCode = null
|
||||
}
|
||||
}))
|
||||
|
||||
// shallowRef 需要手动通知,合并所有深层属性变更为一次 UI 重绘
|
||||
// 渲染来源分流 (Trigger Control):
|
||||
// 1. 无 targetList 传参时(由内部 1 秒定时器调用):因为仅原地修改属性,没改变浅引用,需手动 triggerRef 才能重绘 UI。
|
||||
// 2. 有 targetList 传参时(由外部 useVaultList 初筛调用):因为外边马上就要走 `vault.value = merged` 浅引用赋值逻辑,
|
||||
// 所以这里绝对静默,不可调用 triggerRef,否则将打破单次重绘策略并造成 "------" 闪屏重演。
|
||||
if (!Array.isArray(targetList)) {
|
||||
triggerRef(vault)
|
||||
}
|
||||
}
|
||||
|
||||
const startTimer = () => {
|
||||
updateVaultStatus() // 立即计算一次
|
||||
globalTimer = setInterval(() => updateVaultStatus(), 1000)
|
||||
}
|
||||
|
||||
const stopTimer = () => {
|
||||
if (globalTimer) {
|
||||
clearInterval(globalTimer)
|
||||
globalTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(startTimer)
|
||||
onUnmounted(stopTimer)
|
||||
|
||||
return {
|
||||
updateVaultStatus,
|
||||
startTimer,
|
||||
stopTimer
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import QRCode from 'qrcode'
|
||||
import { useVaultStore } from '@/features/vault/store/vaultStore'
|
||||
import { vaultService } from '@/features/vault/service/vaultService'
|
||||
import { copyToClipboard } from '@/shared/utils/common'
|
||||
|
||||
/**
|
||||
* 管理账号的增删改查与二维码展示等弹窗行为
|
||||
* @param {Function} fetchVault - 操作成功后用于刷新列表的回调
|
||||
* @param {import('vue').ShallowRef} vault - 当前账号列表(用于全选)
|
||||
* @returns Composable state and actions
|
||||
*/
|
||||
export function useVaultActions(fetchVault, vault) {
|
||||
const vaultStore = useVaultStore()
|
||||
|
||||
// --- 批量操作 ---
|
||||
const selectedIds = ref([])
|
||||
const isBulkDeleting = ref(false)
|
||||
|
||||
// --- 编辑弹窗 ---
|
||||
const showEditDialog = ref(false)
|
||||
const isEditing = ref(false)
|
||||
const editVaultData = ref({ id: '', service: '', account: '', category: '' })
|
||||
|
||||
// --- 二维码弹窗 ---
|
||||
const showQrDialog = ref(false)
|
||||
const currentQrItem = ref(null)
|
||||
const showSecret = ref(false)
|
||||
const qrCodeUrl = ref('')
|
||||
|
||||
// --- 批量删除 ---
|
||||
const handleBulkDelete = async () => {
|
||||
if (!selectedIds.value.length) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除选中的 ${selectedIds.value.length} 个账号吗?此操作不可恢复。`,
|
||||
'批量删除',
|
||||
{ type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' }
|
||||
)
|
||||
isBulkDeleting.value = true
|
||||
await vaultService.batchDelete(selectedIds.value)
|
||||
ElMessage.success(`已成功删除 ${selectedIds.value.length} 个账号`)
|
||||
selectedIds.value = []
|
||||
vaultStore.markDirty()
|
||||
fetchVault()
|
||||
} catch (e) {
|
||||
if (e !== 'cancel') console.error(e)
|
||||
} finally {
|
||||
isBulkDeleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelection = (id) => {
|
||||
const idx = selectedIds.value.indexOf(id)
|
||||
if (idx > -1) {
|
||||
selectedIds.value.splice(idx, 1)
|
||||
} else {
|
||||
selectedIds.value.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
// Bug Fix: 全选已加载账号(直接访问 vault ref,无需跨 composable 传参)
|
||||
const selectAllLoaded = () => {
|
||||
if (vault?.value) {
|
||||
selectedIds.value = vault.value.map(acc => acc.id)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 复制 TOTP 代码 ---
|
||||
const copyCode = async (vaultItem) => {
|
||||
if (!vaultItem.currentCode || vaultItem.currentCode === '------') {
|
||||
return ElMessage.warning('验证码尚未生成,请稍候')
|
||||
}
|
||||
await copyToClipboard(vaultItem.currentCode, '验证码已复制')
|
||||
}
|
||||
|
||||
// --- 编辑账号 ---
|
||||
const openEditDialog = (vaultItem) => {
|
||||
editVaultData.value = {
|
||||
id: vaultItem.id,
|
||||
service: vaultItem.service,
|
||||
account: vaultItem.account,
|
||||
category: vaultItem.category || ''
|
||||
}
|
||||
showEditDialog.value = true
|
||||
}
|
||||
|
||||
const submitEditVault = async () => {
|
||||
isEditing.value = true
|
||||
try {
|
||||
const { id, ...updateData } = editVaultData.value
|
||||
const res = await vaultService.updateAccount(id, updateData)
|
||||
if (res.success) {
|
||||
ElMessage.success('账号信息已更新')
|
||||
showEditDialog.value = false
|
||||
vaultStore.markDirty()
|
||||
fetchVault()
|
||||
}
|
||||
} catch (e) {
|
||||
// Error is shown by request utility or vaultError handler
|
||||
} finally {
|
||||
isEditing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- 删除单个账号 ---
|
||||
const deleteVault = async (vaultItem) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除 ${vaultItem.service} 吗?`, '删除确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确认删除',
|
||||
cancelButtonText: '取消'
|
||||
})
|
||||
await vaultService.deleteAccount(vaultItem.id)
|
||||
ElMessage.success('账号已删除')
|
||||
vaultStore.markDirty()
|
||||
fetchVault()
|
||||
} catch (e) {
|
||||
if (e !== 'cancel') console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 二维码导出 ---
|
||||
const openQrDialog = async (vaultItem) => {
|
||||
currentQrItem.value = vaultItem
|
||||
showSecret.value = false
|
||||
showQrDialog.value = true
|
||||
|
||||
const algorithm = vaultItem.algorithm
|
||||
? vaultItem.algorithm.replace('SHA', 'SHA-').replace('SHA--', 'SHA-')
|
||||
: 'SHA-1'
|
||||
const uri = `otpauth://totp/${encodeURIComponent(vaultItem.service)}:${encodeURIComponent(vaultItem.account)}?secret=${vaultItem.secret}&issuer=${encodeURIComponent(vaultItem.service)}&algorithm=${algorithm}&digits=${vaultItem.digits || 6}&period=${vaultItem.period || 30}`
|
||||
qrCodeUrl.value = await QRCode.toDataURL(uri, { width: 240, margin: 1 })
|
||||
}
|
||||
|
||||
const copySecret = () => {
|
||||
if (currentQrItem.value) {
|
||||
copyToClipboard(currentQrItem.value.secret)
|
||||
ElMessage.success('密钥已复制到剪贴板')
|
||||
}
|
||||
}
|
||||
|
||||
const copyOtpUrl = () => {
|
||||
if (currentQrItem.value) {
|
||||
const item = currentQrItem.value
|
||||
const algorithm = item.algorithm
|
||||
? item.algorithm.replace('SHA', 'SHA-').replace('SHA--', 'SHA-')
|
||||
: 'SHA-1'
|
||||
const uri = `otpauth://totp/${encodeURIComponent(item.service)}:${encodeURIComponent(item.account)}?secret=${item.secret}&issuer=${encodeURIComponent(item.service)}&algorithm=${algorithm}&digits=${item.digits || 6}&period=${item.period || 30}`
|
||||
copyToClipboard(uri)
|
||||
ElMessage.success('OTP URI 已复制')
|
||||
}
|
||||
}
|
||||
|
||||
const formatSecret = (secret) => {
|
||||
return (secret || '').match(/.{1,4}/g)?.join(' ') || secret
|
||||
}
|
||||
|
||||
// --- 统一命令分发 ---
|
||||
const handleCommand = (cmd, vaultItem) => {
|
||||
if (cmd === 'edit') openEditDialog(vaultItem)
|
||||
else if (cmd === 'qr') openQrDialog(vaultItem)
|
||||
else if (cmd === 'delete') deleteVault(vaultItem)
|
||||
}
|
||||
|
||||
return {
|
||||
selectedIds,
|
||||
isBulkDeleting,
|
||||
showEditDialog,
|
||||
isEditing,
|
||||
editVaultData,
|
||||
showQrDialog,
|
||||
currentQrItem,
|
||||
showSecret,
|
||||
qrCodeUrl,
|
||||
|
||||
toggleSelection,
|
||||
selectAllLoaded,
|
||||
handleBulkDelete,
|
||||
copyCode,
|
||||
openEditDialog,
|
||||
submitEditVault,
|
||||
deleteVault,
|
||||
openQrDialog,
|
||||
copySecret,
|
||||
copyOtpUrl,
|
||||
formatSecret,
|
||||
handleCommand,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { ref, shallowRef, computed, watch, triggerRef } from 'vue'
|
||||
import { useInfiniteQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { useVaultStore } from '@/features/vault/store/vaultStore'
|
||||
import { vaultService } from '@/features/vault/service/vaultService'
|
||||
|
||||
/**
|
||||
* 管理金库账号的分页列表数据获取与搜索
|
||||
*
|
||||
* 架构说明 (Architecture Notes):
|
||||
* 1. 渲染时序防闪烁: 采用「两级渲染策略」。针对已有账号(已包含计算好的 TOTP 验证码)直接复用;
|
||||
* 针对新加载分页的新账号,先触发 `afterLoadRef` 在后台预计算验证码,完成之后再赋值给 `vault.value`。
|
||||
* 这种先计算后一次性赋值的浅引用 (shallowRef) 机制,避免了验证码变为 "------" 然后闪换的情况。
|
||||
* 2. 缓存秒开机制: `staleTime` 设置为 60s。当用户从外部组件切回主页时,若在 60s 内,
|
||||
* 直接利用 Vue Query 的现有缓存(保留已有账号的 TOTP `currentCode` 和动态倒计时 `remaining`),
|
||||
* 消除 Loading 空白期的出现。也不会触发多余的重复分页加载以及昂贵的 PBKDF2 (`vaultStore.saveData`)加解密迭代开销。
|
||||
* 3. 并发解耦: 本 Composable `useVaultList` 独立负责 Vue Query 分页和本地缓存维护(`vaultStore.saveData`)。
|
||||
* 对于后续验证码计算的具体逻辑,委托给外部通过参数 `afterLoadRef` 回调。
|
||||
*
|
||||
* @param {import('vue').Ref<Function|null>} afterLoadRef - 可选的 ref,指向数据合并后的异步回调(如 updateVaultStatus)
|
||||
* @returns Composable state and actions
|
||||
*/
|
||||
export function useVaultList(afterLoadRef = null) {
|
||||
const vaultStore = useVaultStore()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const vault = shallowRef([])
|
||||
const searchQuery = ref('')
|
||||
const pageSize = ref(12)
|
||||
|
||||
// --- Vue Query 分页逻辑 ---
|
||||
const fetchVaultPage = async ({ pageParam = 1 }) => {
|
||||
if (!vaultStore.isUnlocked) return { vault: [], pagination: { totalPages: 0 } }
|
||||
return await vaultService.getVault({
|
||||
page: pageParam,
|
||||
limit: pageSize.value,
|
||||
search: searchQuery.value
|
||||
})
|
||||
}
|
||||
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
refetch
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ['vault', searchQuery],
|
||||
queryFn: fetchVaultPage,
|
||||
getNextPageParam: (lastPage) => {
|
||||
if (!lastPage || !lastPage.pagination) return undefined
|
||||
const { page, totalPages } = lastPage.pagination
|
||||
return page < totalPages ? page + 1 : undefined
|
||||
},
|
||||
enabled: computed(() => vaultStore.isUnlocked),
|
||||
// 60 秒内切换回来直接用缓存,不触发逐页重拉(避免多次 PBKDF2 saveData 开销)
|
||||
// invalidateQueries / resetQueries 调用时会忽略 staleTime,强制立即刷新
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
|
||||
// 监听数据变化,同步到本地 vault 引用
|
||||
watch(data, async (newData) => {
|
||||
if (!newData) return
|
||||
|
||||
// 智能合并:保留已有账号的 TOTP 实时状态,新增账号初始化响应式属性
|
||||
const flatVault = newData.pages.flatMap(p => p.vault || [])
|
||||
const merged = flatVault.map(newAcc => {
|
||||
const existing = vault.value.find(a => a.id === newAcc.id)
|
||||
if (existing) {
|
||||
return { ...existing, ...newAcc } // 保留 currentCode / remaining 等动态字段
|
||||
}
|
||||
return {
|
||||
...newAcc,
|
||||
currentCode: '------',
|
||||
nextCode: null,
|
||||
remaining: 30,
|
||||
percentage: 0,
|
||||
color: ''
|
||||
}
|
||||
})
|
||||
|
||||
// 两级渲染策略,兼顾「无 ------ 闪烁」与「切换回来秒开」:
|
||||
// 1. 找出真正的新增项(已有项 currentCode 有值,直接复用,无需重算)
|
||||
const newItems = merged.filter(acc => !acc.currentCode || acc.currentCode === '------')
|
||||
|
||||
if (newItems.length > 0) {
|
||||
// 2. 只为新增项预计算 TOTP(通常 12 条/页,耗时极短),避免显示 "------"
|
||||
// updateVaultStatus(newItems) 仅修改 newItems 元素属性,不调用 triggerRef
|
||||
const cb = afterLoadRef?.value ?? afterLoadRef
|
||||
if (typeof cb === 'function') {
|
||||
await cb(newItems)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 一次赋值完成渲染:已有项复用原有验证码,新增项验证码已预计算完毕
|
||||
// shallowRef .value = 新引用会自动通知 Vue,无需额外 triggerRef
|
||||
vault.value = merged
|
||||
|
||||
// 无搜索词时保存全量缓存(使用正确的 vaultStore API)
|
||||
if (!searchQuery.value && vaultStore.isUnlocked) {
|
||||
try {
|
||||
await vaultStore.saveData({ vault: merged })
|
||||
vaultStore.clearDirty()
|
||||
} catch (e) {
|
||||
// 缓存写入失败静默处理
|
||||
}
|
||||
}
|
||||
}, { deep: false })
|
||||
|
||||
// 公开的 fetchVault 方法(用于父组件通过 ref 手工刷新)
|
||||
// 使用 invalidateQueries 而非 resetQueries:保留缓存仅标记为过期,
|
||||
// 切换回账号列表时可立即从缓存显示,后台静默刷新,无需重新拉取所有分页。
|
||||
const fetchVault = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['vault'] })
|
||||
}
|
||||
|
||||
// 无限滚动加载
|
||||
const isLoadMoreDisabled = computed(() =>
|
||||
!hasNextPage.value || isFetchingNextPage.value || !vaultStore.isUnlocked || isError.value
|
||||
)
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (!isLoadMoreDisabled.value) {
|
||||
fetchNextPage()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
vault,
|
||||
searchQuery,
|
||||
pageSize,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
isError,
|
||||
isLoadMoreDisabled,
|
||||
fetchVault,
|
||||
handleLoadMore,
|
||||
refetch
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { request } from '@/shared/utils/request'
|
||||
import { vaultError } from '@/shared/utils/errors/vaultError'
|
||||
|
||||
/**
|
||||
* @typedef {Object} VaultAccount
|
||||
* @property {string} [id] - Account ID
|
||||
* @property {string} service - Service name (e.g. "Google")
|
||||
* @property {string} account - Account identifier (e.g. email)
|
||||
* @property {string} secret - Base32-encoded TOTP secret
|
||||
* @property {string} [category] - Optional category label
|
||||
* @property {6|8} [digits] - OTP digit count (default: 6)
|
||||
* @property {30|60} [period] - TOTP period in seconds (default: 30)
|
||||
* @property {'SHA1'|'SHA256'|'SHA512'} [algorithm] - HMAC algorithm (default: 'SHA1')
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} VaultPagination
|
||||
* @property {number} page - Current page number
|
||||
* @property {number} totalPages - Total number of pages
|
||||
* @property {number} total - Total item count
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} VaultListResponse
|
||||
* @property {boolean} success
|
||||
* @property {VaultAccount[]} vault - List of accounts for THIS page
|
||||
* @property {VaultPagination} pagination
|
||||
*/
|
||||
|
||||
export const vaultService = {
|
||||
/**
|
||||
* 分页获取账号列表
|
||||
* @param {{ page?: number, limit?: number, search?: string }} params
|
||||
* @returns {Promise<VaultListResponse>}
|
||||
* @throws {vaultError}
|
||||
*/
|
||||
async getVault(params = {}) {
|
||||
try {
|
||||
const query = new URLSearchParams(params).toString()
|
||||
return await request(`/api/vault?${query}`)
|
||||
} catch (e) {
|
||||
throw new vaultError('Failed to fetch vault list', 'VAULT_FETCH_FAILED', e)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建新账号
|
||||
* @param {VaultAccount} vaultData
|
||||
* @returns {Promise<{success: boolean, id: string}>}
|
||||
* @throws {vaultError}
|
||||
*/
|
||||
async createAccount(vaultData) {
|
||||
try {
|
||||
return await request('/api/vault', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(vaultData)
|
||||
})
|
||||
} catch (e) {
|
||||
throw new vaultError('Failed to create account', 'ACCOUNT_CREATE_FAILED', e)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新账号信息
|
||||
* @param {string} id
|
||||
* @param {Partial<VaultAccount>} updateData
|
||||
* @returns {Promise<{success: boolean}>}
|
||||
* @throws {vaultError}
|
||||
*/
|
||||
async updateAccount(id, updateData) {
|
||||
try {
|
||||
return await request(`/api/vault/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updateData)
|
||||
})
|
||||
} catch (e) {
|
||||
throw new vaultError('Failed to update account', 'ACCOUNT_UPDATE_FAILED', e)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除单个账号
|
||||
* @param {string} id
|
||||
* @returns {Promise<{success: boolean}>}
|
||||
* @throws {vaultError}
|
||||
*/
|
||||
async deleteAccount(id) {
|
||||
try {
|
||||
return await request(`/api/vault/${id}`, { method: 'DELETE' })
|
||||
} catch (e) {
|
||||
throw new vaultError('Failed to delete account', 'ACCOUNT_DELETE_FAILED', e)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 批量删除账号
|
||||
* @param {string[]} ids
|
||||
* @returns {Promise<{success: boolean, deleted: number}>}
|
||||
* @throws {vaultError}
|
||||
*/
|
||||
async batchDelete(ids) {
|
||||
try {
|
||||
return await request('/api/vault/batch-delete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids })
|
||||
})
|
||||
} catch (e) {
|
||||
throw new vaultError('Failed to batch delete accounts', 'ACCOUNTS_BATCH_DELETE_FAILED', e)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 通过 URI 添加账号
|
||||
* @param {string} uri - otpauth://totp/... URI string
|
||||
* @param {string} [category='扫码添加']
|
||||
* @returns {Promise<{success: boolean, id: string}>}
|
||||
* @throws {vaultError}
|
||||
*/
|
||||
async addFromUri(uri, category = '扫码添加') {
|
||||
try {
|
||||
return await request('/api/vault/add-from-uri', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ uri, category })
|
||||
})
|
||||
} catch (e) {
|
||||
throw new vaultError('Failed to add account from URI', 'ACCOUNT_ADD_URI_FAILED', e)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 导入账号数据
|
||||
* @param {VaultAccount[]|string} vault - 账号数组或加密字符串
|
||||
* @param {'raw'|'encrypted'} [type='raw']
|
||||
* @returns {Promise<{success: boolean, count: number}>}
|
||||
* @throws {vaultError}
|
||||
*/
|
||||
async importVault(vault, type = 'raw') {
|
||||
try {
|
||||
return await request('/api/vault/import', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
type,
|
||||
content: typeof vault === 'string' ? vault : JSON.stringify(vault)
|
||||
})
|
||||
})
|
||||
} catch (e) {
|
||||
throw new vaultError('Failed to import vault data', 'VAULT_IMPORT_FAILED', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,8 +54,8 @@ const layoutStore = useLayoutStore()
|
||||
const VaultList = defineAsyncComponent(() => import('@/features/vault/components/vaultList.vue'))
|
||||
const AddVaultScan = defineAsyncComponent(() => import('@/features/vault/components/addVaultScan.vue'))
|
||||
const AddVaultManual= defineAsyncComponent(() => import('@/features/vault/components/addVaultManual.vue'))
|
||||
const DataExport = defineAsyncComponent(() => import('@/features/backup/views/dataExport.vue'))
|
||||
const DataImport = defineAsyncComponent(() => import('@/features/backup/views/dataImport.vue'))
|
||||
const DataExport = defineAsyncComponent(() => import('@/features/migration/views/dataExport.vue'))
|
||||
const DataImport = defineAsyncComponent(() => import('@/features/migration/views/dataImport.vue'))
|
||||
const DataBackup = defineAsyncComponent(() => import('@/features/backup/views/dataBackup.vue'))
|
||||
const UtilityTools = defineAsyncComponent(() => import('@/features/tools/views/utilityTools.vue'))
|
||||
|
||||
|
||||
+20
-3
@@ -24,9 +24,26 @@ themeStore.initTheme()
|
||||
// PWA Service Worker 注册
|
||||
registerSW({
|
||||
onOfflineReady() {
|
||||
ElMessage.success({
|
||||
message: '应用已缓存,支持离线访问',
|
||||
duration: 3000
|
||||
import('element-plus').then(({ ElNotification }) => {
|
||||
ElNotification({
|
||||
title: '更新可用',
|
||||
message: `
|
||||
<div style="line-height: 1.5; font-size: 14px; margin-bottom: 5px;">
|
||||
🎉 Cloudflare Worker 新部署已就绪!
|
||||
</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()"
|
||||
>
|
||||
立即刷新
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
dangerouslyUseHTMLString: true,
|
||||
type: 'success',
|
||||
duration: 0
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<svg viewBox="3 4 40 40" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M25.5591,42.9452l9.9263-8.0881a9.0285,9.0285,0,0,0,3.3255-6.9993V9.7074a2.657,2.657,0,0,0-1.9753-2.568l-7.15-1.8977a22.1657,22.1657,0,0,0-11.3718,0l-7.15,1.8977a2.657,2.657,0,0,0-1.9753,2.568v18.15a9.0285,9.0285,0,0,0,3.3255,6.9993l9.9263,8.0881A2.4682,2.4682,0,0,0,25.5591,42.9452Z"/>
|
||||
<line x1="15.5" y1="15" x2="32.5" y2="15" />
|
||||
<line x1="15.5" y1="22" x2="26" y2="22" />
|
||||
<line x1="15.5" y1="29" x2="20" y2="29" />
|
||||
</svg>
|
||||
</template>
|
||||
<script>
|
||||
export default { name: 'icon2FAS' }
|
||||
</script>
|
||||
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor">
|
||||
<path d="m6.9487 19.8732-.0074.0133a1.483 1.483 0 0 0-.0013 1.469l.3165.5565c.2639.4638.7563.75 1.2897.75h.0163c1.139 0 1.853-1.2301 1.2883-2.219l-.325-.5697c-.5693-.9974-2.0074-.9974-2.577-.0002m3.4905-6.1074a1.483 1.483 0 0 0-.0013 1.4688l3.7964 6.6769c.2639.4638.756.7503 1.2897.7503h.0156c1.139 0 1.853-1.2301 1.2883-2.219l-3.8118-6.677c-.5693-.9974-2.0077-.9974-2.5768 0m.3275-11.692L.1972 20.4648c-.5618.9775.1438 2.1969 1.2713 2.1969a1.467 1.467 0 0 0 1.2734-.7393l7.9513-13.9285c.5632-.9867 1.9861-.9855 2.548.0026l7.9175 13.9239a1.466 1.466 0 0 0 1.2746.7416h.0982c1.1255 0 1.8313-1.2152 1.2736-2.193L13.3116 2.0776c-.5616-.9844-1.98-.9867-2.5448-.0039"/>
|
||||
</svg>
|
||||
</template>
|
||||
<script>
|
||||
export default { name: 'iconAegis' }
|
||||
</script>
|
||||
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor">
|
||||
<path d="M21.722.296A.964.964 0 0 0 21.018 0H2.982a.959.959 0 0 0-.703.296.96.96 0 0 0-.297.702v12c0 .895.174 1.783.523 2.665.349.88.783 1.66 1.3 2.345.517.68 1.132 1.346 1.848 1.993a21.807 21.807 0 0 0 1.98 1.609c.605.427 1.235.83 1.893 1.212.657.381 1.125.638 1.4.772.276.134.5.241.664.311a.916.916 0 0 0 .814 0c.168-.073.389-.177.667-.311.275-.134.743-.394 1.401-.772a25.305 25.305 0 0 0 1.894-1.212A21.891 21.891 0 0 0 18.348 20c.716-.647 1.33-1.31 1.847-1.993s.949-1.463 1.3-2.345c.35-.879.524-1.767.524-2.665V1.001a.95.95 0 0 0-.297-.705zm-2.325 12.815c0 4.344-7.397 8.087-7.397 8.087V2.57h7.397v10.54z"/>
|
||||
</svg>
|
||||
</template>
|
||||
<script>
|
||||
export default { name: 'iconBitwarden' }
|
||||
</script>
|
||||
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 48 48" fill="currentColor">
|
||||
<path d="M36.8 16.6C36.2 11.2 31.7 7 26.2 7c-4.8 0-8.9 3.2-10.2 7.6-5.3.6-9.4 5.1-9.4 10.5 0 5.8 4.7 10.5 10.5 10.5h19.7c4.8 0 8.7-3.9 8.7-8.7 0-4.6-3.6-8.4-8.1-8.7-.2-1.1-.4-2.1-.6-3.1z"/>
|
||||
</svg>
|
||||
</template>
|
||||
<script>
|
||||
export default { name: 'iconCloudflare' }
|
||||
</script>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<svg width="1em" height="1em" viewBox="0 0 90 90" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<circle id="Combined-Shape" fill="#C71D23" cx="44.8544363" cy="44.8544363" r="44.8544363"></circle>
|
||||
<path d="M67.558546,39.8714292 L42.0857966,39.8714292 C40.8627004,39.8720094 39.8710953,40.8633548 39.8701949,42.0864508 L39.8687448,47.623783 C39.867826,48.8471055 40.8592652,49.8390642 42.0825879,49.8393845 C42.0827874,49.8393846 42.0829869,49.8393846 42.0831864,49.8387862 L57.5909484,49.838657 C58.8142711,49.8386283 59.8059783,50.830319 59.8059885,52.0536417 C59.8059885,52.0536479 59.8059885,52.053654 59.8059701,52.0536602 L59.8059701,52.6073539 L59.8059701,52.6073539 L59.8059701,53.161115 C59.8059701,56.8310831 56.8308731,59.80618 53.160905,59.80618 L32.1165505,59.80618 C30.8934034,59.806119 29.9018373,58.8145802 29.9017425,57.5914331 L29.9011625,36.5491188 C29.9008781,32.8791508 32.8758931,29.9039718 36.5458611,29.9038706 C36.5459222,29.9038706 36.5459833,29.9038706 36.5460443,29.9040538 L67.5523638,29.9040538 C68.77515,29.9026795 69.7666266,28.9118177 69.7687593,27.6890325 L69.7721938,22.1516997 C69.774326,20.928378 68.7832423,19.9360642 67.5599198,19.9353054 C67.5594619,19.9353054 67.5590039,19.9353054 67.558546,19.9366784 L36.5479677,19.9366784 C27.3730474,19.9366784 19.935305,27.3744208 19.935305,36.549341 L19.935305,67.558546 C19.935305,68.7818687 20.927004,69.7735676 22.1503267,69.7735676 L54.8224984,69.7735676 C63.079746,69.7735676 69.7735676,63.079746 69.7735676,54.8224984 L69.7735676,42.0864509 C69.7735676,40.8631282 68.7818687,39.8714292 67.558546,39.8714292 Z" id="G" fill="#FFFFFF"></path>
|
||||
</svg>
|
||||
</template>
|
||||
<script>
|
||||
export default { name: 'iconGitee' }
|
||||
</script>
|
||||
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
</template>
|
||||
<script>
|
||||
export default { name: 'iconGithub' }
|
||||
</script>
|
||||
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M21.35 11.1h-9.17v2.73h6.51c-.33 3.81-3.5 5.44-6.5 5.44C8.36 19.27 5 16.25 5 12c0-4.1 3.2-7.27 7.2-7.27c3.09 0 4.9 1.97 4.9 1.97L19 4.72S16.56 2 12.1 2C6.42 2 2.03 6.8 2.03 12c0 5.05 4.13 10 10.22 10c5.35 0 9.25-3.67 9.25-9.09c0-1.15-.15-1.81-.15-1.81Z"/>
|
||||
</svg>
|
||||
</template>
|
||||
<script>
|
||||
export default { name: 'iconGoogle' }
|
||||
</script>
|
||||
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor">
|
||||
<path d="M6.957 1.3379A2.0133 2.0133 0 0 0 6 1.6074c-.967.5583-1.2985 1.7947-.7402 2.7617L8.498 9.9785H2.0215C.9049 9.9785 0 10.8835 0 12c0 1.1166.905 2.0215 2.0215 2.0215H8.498l-3.2382 5.6094c-.5583.967-.2268 2.2034.7402 2.7617.967.5582 2.2034.2267 2.7617-.7403L12 16.045l3.2383 5.6074c.5583.967 1.7947 1.2985 2.7617.7403.967-.5583 1.2985-1.7947.7402-2.7617l-3.2382-5.6094h6.4765C23.0951 14.0215 24 13.1165 24 12c0-1.1166-.905-2.0215-2.0215-2.0215H15.502l3.2382-5.6094c.5583-.967.2268-2.2034-.7402-2.7617-.967-.5582-2.2034-.2267-2.7617.7403L12 7.955 8.7617 2.3477C8.378 1.6829 7.674 1.3193 6.957 1.3379Zm9.959 1.0058c.1932-.0127.3928.0317.5781.1387.4944.2854.6565.8866.3711 1.3809l-4.1132 7.125h8.2265c.5709 0 1.0117.4408 1.0117 1.0117s-.4408 1.0117-1.0117 1.0117H14.92l-1.168-2.0234-1.168-2.0235 3.5294-6.1113c.1783-.3089.4808-.4885.8027-.5098zm-9.9336.004c.3587-.0093.7081.166.9043.5058l3.5293 6.1113-1.168 2.0235-1.168 2.0234H2.0216c-.5709 0-1.0117-.4408-1.0117-1.0117s.4408-1.0117 1.0117-1.0117h8.2265l-4.1132-7.125c-.2854-.4943-.1233-1.0955.371-1.3809a.9891.9891 0 0 1 .4766-.1347ZM9.666 14.0215h4.668l3.5312 6.1152c.2854.4943.1233 1.0955-.371 1.3809-.4942.2852-1.0956.1231-1.381-.3711L12 14.0254l-4.1133 7.121c-.2853.4943-.8867.6564-1.3808.3712-.4944-.2854-.6565-.8866-.3711-1.3809Z"/>
|
||||
</svg>
|
||||
</template>
|
||||
<script>
|
||||
export default { name: 'iconGoogleAuth' }
|
||||
</script>
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg" fill="none" width="1em" height="1em">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="3.36" d="M26.88 17.36h-7.84c-4.942 0 -9.072 4.046 -8.204 8.918C13.3 40.166 21.266 47.6 26.88 47.6m0 -30.24h7.84c4.942 0 9.072 4.046 8.204 8.918C40.46 40.166 32.494 47.6 26.88 47.6"/>
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M19.04 15.68a7.84 7.84 0 1 1 15.498 1.68h3.416a11.2 11.2 0 1 0 -22.148 0h3.416a8.4 8.4 0 0 1 -0.182 -1.68m-0.098 27.706a8.12 8.12 0 0 1 15.666 -0.798l2.52 -2.688a11.48 11.48 0 0 0 -20.468 -0.042l1.68 2.702z" clip-rule="evenodd"/>
|
||||
<path stroke="currentColor" stroke-width="3.36" d="M30.8 26.32a3.92 3.92 0 0 1 -3.92 3.92 3.92 3.92 0 0 1 -3.92 -3.92 3.92 3.92 0 0 1 7.84 0z"/>
|
||||
</svg>
|
||||
</template>
|
||||
<script>
|
||||
export default { name: 'iconMicrosoftAuth' }
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 102 66" fill="none">
|
||||
<path
|
||||
d="M51.504 19.32C51.504 23.224 51.312 28.888 50.928 36.312C50.544 43.608 50.352 49.144 50.352 52.92C50.352 54.008 50.416 55.64 50.544 57.816C50.672 59.992 50.736 61.688 50.736 62.904C50.608 63.288 49.36 63.64 46.992 63.96C44.624 64.28 42.992 64.44 42.096 64.44C41.648 64.44 40.944 63.832 39.984 62.616C39.024 61.4 37.808 59.704 36.336 57.528C33.904 54.072 32.368 52.088 31.728 51.576C31.728 51.576 31.216 50.808 30.192 49.272C29.104 47.544 27.76 45.496 26.16 43.128C24.624 40.76 23.856 39.544 23.856 39.48L15.504 29.592C15.44 29.464 15.28 29.112 15.024 28.536C14.768 27.96 14.416 27.672 13.968 27.672C13.776 27.672 13.584 27.864 13.392 28.248C13.264 28.568 13.2 28.92 13.2 29.304C13.2 36.024 13.264 41.304 13.392 45.144C13.456 46.04 13.488 47.16 13.488 48.504C13.488 48.696 13.456 48.92 13.392 49.176C13.328 49.368 13.264 49.528 13.2 49.656V64.44L12.432 65.208L10.32 65.304L0.528 64.536C0.528 62.744 0.592 60.632 0.72 58.2C0.912 55.768 1.008 54.328 1.008 53.88C1.008 52.728 0.944 51.128 0.816 49.08C0.688 46.776 0.624001 44.792 0.624001 43.128L0.816 39.864C1.008 36.216 1.104 34.2 1.104 33.816C1.104 32.28 1.04 29.976 0.912 26.904C0.784 23.832 0.72 21.56 0.72 20.088C0.72 19.192 0.848 17.912 1.104 16.248C1.36 14.712 1.488 13.592 1.488 12.888C1.488 12.696 1.36 11.736 1.104 10.008C0.848 8.216 0.72 6.68 0.72 5.4L0.336 4.728C0.464 4.088 0.688 3.672 1.008 3.48C1.456 3.416 2.672 3.224 4.656 2.904C6.64 2.52 8.24 2.328 9.456 2.328C10.544 2.328 11.28 2.456 11.664 2.712C12.112 3.096 12.816 3.768 13.776 4.728C14.736 5.688 15.184 6.264 15.12 6.456L20.016 14.808C20.016 14.872 20.912 16.152 22.704 18.648L24.24 20.856C27.44 25.592 30.256 29.656 32.688 33.048C35.12 36.376 36.688 38.04 37.392 38.04C37.84 38.04 38.096 37.88 38.16 37.56C38.16 36.536 38.096 35.128 37.968 33.336L37.872 29.592C37.872 28.44 37.936 26.648 38.064 24.216L38.16 18.84C38.16 17.368 38.096 15.16 37.968 12.216C37.84 9.4 37.776 7.224 37.776 5.688C37.776 4.152 37.872 3.064 38.064 2.424C38.32 1.784 38.8 1.336 39.504 1.08C40.208 0.76 41.36 0.6 42.96 0.6C46.16 0.6 48.144 0.76 48.912 1.08C49.68 1.336 50.096 1.816 50.16 2.52C50.224 3.16 50.416 3.704 50.736 4.152V10.2L51.024 11.064L50.928 14.328C50.928 16.76 51.12 18.424 51.504 19.32Z"
|
||||
fill="url(#nodeloc_grad)"
|
||||
/>
|
||||
<path
|
||||
d="M74.3625 0.6C74.8745 0.6 75.2905 0.76 75.6105 1.08C75.9305 1.336 76.0905 1.656 76.0905 2.04C76.0905 2.744 75.9945 3.576 75.8025 4.536C75.3545 6.904 75.1305 9.144 75.1305 11.256C75.1305 11.32 75.0665 12.44 74.9385 14.616C74.8105 16.728 74.6185 18.584 74.3625 20.184L74.9385 20.664V32.184C75.3225 32.888 75.6425 34.488 75.8985 36.984C76.2185 39.416 76.4105 40.92 76.4745 41.496C76.7945 44.568 77.0825 46.936 77.3385 48.6C77.5945 50.2 77.9145 51.192 78.2985 51.576C78.5545 51.704 78.8745 51.832 79.2585 51.96C79.6425 52.088 79.9625 52.184 80.2185 52.248L83.6745 52.152C86.3625 51.96 90.0745 51.864 94.8105 51.864C95.0665 51.8 95.3865 51.736 95.7705 51.672C96.2185 51.608 96.5705 51.576 96.8265 51.576C98.0425 51.512 98.9385 51.352 99.5145 51.096C100.091 50.84 100.411 50.264 100.475 49.368C100.795 49.368 101.083 49.464 101.339 49.656C101.659 49.784 101.819 50.008 101.819 50.328V60.6C101.819 61.24 101.595 61.784 101.147 62.232C100.699 62.616 100.251 62.808 99.8025 62.808L94.7145 62.616C92.0905 62.616 90.2025 62.712 89.0505 62.904L86.7465 63C86.7465 62.872 85.8185 62.744 83.9625 62.616L78.9705 62.232C77.6265 62.232 76.2825 62.424 74.9385 62.808C73.5945 63.192 72.9225 63.416 72.9225 63.48C71.8345 63.48 70.5865 63.32 69.1785 63C67.5145 62.744 66.2985 62.616 65.5305 62.616C65.5305 61.4 65.4025 59.512 65.1465 56.952C64.8905 54.264 64.7625 52.216 64.7625 50.808V50.424C64.8265 50.168 64.8585 49.528 64.8585 48.504C64.8585 46.968 64.8265 45.528 64.7625 44.184L64.2825 43.704V27.96C63.9625 26.04 63.8025 24.344 63.8025 22.872C63.8025 21.464 63.9625 20.568 64.2825 20.184V11.256C64.0905 11.128 63.9945 10.584 63.9945 9.624V8.184C63.9945 6.2 63.8345 4.536 63.5145 3.192L63.2265 2.808C63.2265 2.168 64.2185 1.656 66.2025 1.272C68.1865 0.824 70.9065 0.6 74.3625 0.6Z"
|
||||
fill="url(#nodeloc_grad)"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id="nodeloc_grad" x1="-6" y1="35" x2="104" y2="35" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#009966"/>
|
||||
<stop offset="0.790909" stop-color="#FF9933"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</template>
|
||||
<script>
|
||||
export default { name: 'iconNodeloc' }
|
||||
</script>
|
||||
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12a12 12 0 0 0 12-12A12 12 0 0 0 11.944 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472c-.18 1.898-.962 6.502-1.36 8.627c-.168.9-.499 1.201-.82 1.23c-.696.065-1.225-.46-1.9-.902c-1.056-.693-1.653-1.124-2.678-1.8c-1.185-.78-.417-1.21.258-1.91c.177-.184 3.247-2.977 3.307-3.23c.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345c-.48.33-.913.49-1.302.48c-.428-.008-1.252-.241-1.865-.44c-.752-.245-1.349-.374-1.297-.789c.027-.216.325-.437.893-.663c3.498-1.524 5.83-2.529 6.998-3.014c3.332-1.386 4.025-1.627 4.476-1.635z"/>
|
||||
</svg>
|
||||
</template>
|
||||
<script>
|
||||
export default { name: 'iconTelegram' }
|
||||
</script>
|
||||
@@ -5,7 +5,7 @@
|
||||
©{{ new Date().getFullYear() }} <a href="https://github.com/nap0o/2fauth-worker" target="_blank" rel="noopener noreferrer">2FAuth Worker</a>
|
||||
<span v-if="!layoutStore.isMobile">
|
||||
<span class="divider">|</span>
|
||||
Serverless 2FA Manager
|
||||
A Secure 2FA Management Tool
|
||||
</span>
|
||||
<span v-if="commitHash && commitHash !== 'unknown'">
|
||||
<span class="divider">|</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<header class="header">
|
||||
<div class="logo">
|
||||
<el-button v-if="layoutStore.isMobile && !!userStore.userInfo?.username" @click="layoutStore.showMobileMenu = true" style="margin-right: 10px; padding: 5px 8px; border: 1px solid var(--el-border-color); height: auto;">
|
||||
<el-button v-if="layoutStore.isMobile && !!authUserStore.userInfo?.username" @click="layoutStore.showMobileMenu = true" style="margin-right: 10px; padding: 5px 8px; border: 1px solid var(--el-border-color); height: auto;">
|
||||
<el-icon :size="14" class="menu-icon" style="color: var(--el-text-color-primary);">
|
||||
<svg viewBox="0 0 16 16" version="1.1" width="100%" height="100%" aria-hidden="true" fill="currentColor">
|
||||
<path d="M1 2.75A.75.75 0 0 1 1.75 2h12.5a.75.75 0 0 1 0 1.5H1.75A.75.75 0 0 1 1 2.75Zm0 5A.75.75 0 0 1 1.75 7h12.5a.75.75 0 0 1 0 1.5H1.75A.75.75 0 0 1 1 7.75ZM1.75 12h12.5a.75.75 0 0 1 0 1.5H1.75a.75.75 0 0 1 0-1.5Z"></path>
|
||||
@@ -15,10 +15,10 @@
|
||||
</div>
|
||||
<div class="user-actions" v-if="route.path !== '/login'">
|
||||
<div class="user-profile" v-if="!layoutStore.isMobile">
|
||||
<el-avatar :size="32" :src="userStore.userInfo?.avatar">
|
||||
{{ userStore.userInfo?.username?.charAt(0)?.toUpperCase() }}
|
||||
<el-avatar :size="32" :src="authUserStore.userInfo?.avatar">
|
||||
{{ authUserStore.userInfo?.username?.charAt(0)?.toUpperCase() }}
|
||||
</el-avatar>
|
||||
<span class="username">{{ userStore.userInfo?.username }}</span>
|
||||
<span class="username">{{ authUserStore.userInfo?.username }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -29,12 +29,12 @@ import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Lock } from '@element-plus/icons-vue'
|
||||
import { useLayoutStore } from '@/shared/stores/layoutStore'
|
||||
import { useUserStore } from '@/features/auth/store/userStore'
|
||||
import { useAuthUserStore } from '@/features/auth/store/authUserStore'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const layoutStore = useLayoutStore()
|
||||
const userStore = useUserStore()
|
||||
const authUserStore = useAuthUserStore()
|
||||
|
||||
const goHome = () => {
|
||||
if (route.path === '/') {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 基础编码转换辅助工具
|
||||
*/
|
||||
|
||||
export function hexToBytes(hex) {
|
||||
const bytes = new Uint8Array(hex.length / 2)
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
export function base64ToBytes(base64) {
|
||||
const binStr = atob(base64)
|
||||
const bytes = new Uint8Array(binStr.length)
|
||||
for (let i = 0; i < binStr.length; i++) {
|
||||
bytes[i] = binStr.charCodeAt(i)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
export function tryParseJSON(str) {
|
||||
try {
|
||||
return JSON.parse(str)
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 认证与授权模块专用的异常类
|
||||
*/
|
||||
export class authError extends Error {
|
||||
/**
|
||||
* @param {string} message - 错误描述
|
||||
* @param {number|string} [code] - 错误代码或 HTTP 状态码
|
||||
*/
|
||||
constructor(message, code = 'AUTH_ERR') {
|
||||
super(message)
|
||||
this.name = 'authError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export class backupError extends Error {
|
||||
constructor(message, code = 'BACKUP_ERROR', details = null) {
|
||||
super(message)
|
||||
this.name = 'backupError'
|
||||
this.code = code
|
||||
this.details = details
|
||||
|
||||
// Error stack trace maintenance for V8 engines (like Node.js or Chrome)
|
||||
if (Error.captureStackTrace) {
|
||||
Error.captureStackTrace(this, backupError)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export class migrationError extends Error {
|
||||
constructor(message, code = 'MIGRATION_ERROR', details = null) {
|
||||
super(message)
|
||||
this.name = 'migrationError'
|
||||
this.code = code
|
||||
this.details = details
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 工具模块专用的异常类
|
||||
*/
|
||||
export class toolsError extends Error {
|
||||
/**
|
||||
* @param {string} message - 错误描述
|
||||
* @param {number|string} [code] - 错误代码或 HTTP 状态码
|
||||
*/
|
||||
constructor(message, code = 'TOOLS_ERR') {
|
||||
super(message)
|
||||
this.name = 'toolsError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export class vaultError extends Error {
|
||||
constructor(message, code = 'VAULT_ERROR', details = null) {
|
||||
super(message)
|
||||
this.name = 'vaultError'
|
||||
this.code = code
|
||||
this.details = details
|
||||
|
||||
if (Error.captureStackTrace) {
|
||||
Error.captureStackTrace(this, vaultError)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ElMessage } from 'element-plus'
|
||||
import router from '@/app/router'
|
||||
import { useUserStore } from '@/features/auth/store/userStore'
|
||||
import { useAuthUserStore } from '@/features/auth/store/authUserStore'
|
||||
|
||||
// 辅助函数:从 document.cookie 中安全地读取指定的 cookie 值
|
||||
export function getCookie(name) {
|
||||
@@ -54,8 +54,8 @@ export async function request(url, options = {}) {
|
||||
|
||||
// 调用 Pinia Action 清空用户信息、缓存、以及重置内存状态,防止路由守卫死循环打回
|
||||
try {
|
||||
const userStore = useUserStore()
|
||||
userStore.clearUserInfo()
|
||||
const authUserStore = useAuthUserStore()
|
||||
authUserStore.clearUserInfo()
|
||||
} catch (e) {
|
||||
// Fallback to manual localstorage clear if store is unavailable
|
||||
localStorage.removeItem('userInfo')
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import * as scrypt from 'scrypt-js'
|
||||
import { hexToBytes, base64ToBytes } from '@/shared/utils/encoding'
|
||||
|
||||
/**
|
||||
* Aegis 备份数据库解密策略 (Decoding Strategy)
|
||||
*/
|
||||
export const aegisStrategy = {
|
||||
/**
|
||||
* @param {Object} jsonObj - 解析后的 Aegis JSON 结构
|
||||
* @param {string} password - 用户的解密密码
|
||||
* @returns {Promise<Object>} 解密后的数据库明文 (JSON 对象)
|
||||
*/
|
||||
async decryptDatabase(jsonObj, password) {
|
||||
const slots = jsonObj.header.slots
|
||||
if (!slots || !slots.length) throw new Error("Aegis: 找不到密钥槽")
|
||||
const dbBase64 = jsonObj.db
|
||||
const dbParams = jsonObj.header.params
|
||||
|
||||
let masterKey = null
|
||||
const passwordBytes = new TextEncoder().encode(password)
|
||||
|
||||
for (const slot of slots) {
|
||||
if (slot.type === 1) {
|
||||
const saltBytes = hexToBytes(slot.salt)
|
||||
const derivedKey = await scrypt.scrypt(passwordBytes, saltBytes, slot.n, slot.r, slot.p, 32)
|
||||
|
||||
const encryptedKey = hexToBytes(slot.key)
|
||||
const nonce = hexToBytes(slot.key_params.nonce)
|
||||
const tag = hexToBytes(slot.key_params.tag)
|
||||
|
||||
const cipherText = new Uint8Array(encryptedKey.length + tag.length)
|
||||
cipherText.set(encryptedKey)
|
||||
cipherText.set(tag, encryptedKey.length)
|
||||
|
||||
try {
|
||||
const cryptoKey = await window.crypto.subtle.importKey(
|
||||
'raw', derivedKey, { name: 'AES-GCM' }, false, ['decrypt']
|
||||
)
|
||||
const decryptedMaster = await window.crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: nonce }, cryptoKey, cipherText
|
||||
)
|
||||
masterKey = new Uint8Array(decryptedMaster)
|
||||
break
|
||||
} catch (e) {
|
||||
continue // Try next slot
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!masterKey) throw new Error("Aegis: 密码错误或不支持的加密格式 (缺少 Scrypt)")
|
||||
|
||||
const dbBytes = base64ToBytes(dbBase64)
|
||||
const dbNonce = hexToBytes(dbParams.nonce)
|
||||
const dbTag = hexToBytes(dbParams.tag)
|
||||
|
||||
const dbCipherText = new Uint8Array(dbBytes.length + dbTag.length)
|
||||
dbCipherText.set(dbBytes)
|
||||
dbCipherText.set(dbTag, dbBytes.length)
|
||||
|
||||
const dbCryptoKey = await window.crypto.subtle.importKey(
|
||||
'raw', masterKey, { name: 'AES-GCM' }, false, ['decrypt']
|
||||
)
|
||||
try {
|
||||
const plainDbBuffer = await window.crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: dbNonce }, dbCryptoKey, dbCipherText
|
||||
)
|
||||
return JSON.parse(new TextDecoder().decode(plainDbBuffer))
|
||||
} catch (e) {
|
||||
throw new Error("Aegis: 数据库载荷解密失败")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { parseOtpUri } from '@/shared/utils/totp'
|
||||
|
||||
/**
|
||||
* CSV 格式迁移解析策略
|
||||
*/
|
||||
export const csvStrategy = {
|
||||
/**
|
||||
* @typedef {Object} VaultItem
|
||||
* @property {string} service
|
||||
* @property {string} account
|
||||
* @property {string} secret
|
||||
* @property {string} algorithm
|
||||
* @property {number} digits
|
||||
* @property {number} period
|
||||
* @property {string} category
|
||||
*/
|
||||
|
||||
/**
|
||||
* Decode a generic or bwauth CSV string.
|
||||
* @param {string} csvText
|
||||
* @returns {VaultItem[]}
|
||||
*/
|
||||
parseCsv(csvText) {
|
||||
const lines = csvText.split('\n').filter(line => line.trim())
|
||||
if (lines.length < 2) return []
|
||||
|
||||
const headers = lines[0].toLowerCase().split(',').map(h => h.trim())
|
||||
const rawVault = []
|
||||
|
||||
const isBwAuth = headers.includes('login_totp')
|
||||
const isGeneric = headers.includes('issuer') || headers.includes('secret') || headers.includes('name')
|
||||
|
||||
if (!isBwAuth && !isGeneric) return []
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const row = lines[i].split(',').map(c => c.trim())
|
||||
const rowData = {}
|
||||
headers.forEach((h, index) => { rowData[h] = row[index] })
|
||||
|
||||
if (isBwAuth) {
|
||||
const totpStr = rowData['login_totp'] || ''
|
||||
if (totpStr.startsWith('otpauth://')) {
|
||||
const accData = parseOtpUri(totpStr)
|
||||
if (accData) {
|
||||
accData.service = rowData['name'] || accData.service
|
||||
accData.account = rowData['login_username'] || accData.account
|
||||
rawVault.push(accData)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const secret = rowData['secret'] || ''
|
||||
if (secret) {
|
||||
rawVault.push({
|
||||
service: rowData['issuer'] || rowData['name'] || 'Unknown',
|
||||
account: rowData['account'] || rowData['name'] || '',
|
||||
secret: secret.replace(/\s/g, '').toUpperCase(),
|
||||
algorithm: (rowData['algorithm'] || 'SHA1').toUpperCase().replace('SHA1', 'SHA-1').replace('SHA256', 'SHA-256').replace('SHA512', 'SHA-512'),
|
||||
digits: parseInt(rowData['digits'] || '6', 10),
|
||||
period: parseInt(rowData['period'] || '30', 10),
|
||||
category: ''
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return rawVault
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Google Authenticator 迁移协议解码策略 (Decoding Strategy)
|
||||
*/
|
||||
|
||||
export const gaMigrationStrategy = {
|
||||
/**
|
||||
* @typedef {Object} VaultItem
|
||||
* @property {string} service
|
||||
* @property {string} account
|
||||
* @property {string} secret
|
||||
* @property {string} algorithm
|
||||
* @property {number} digits
|
||||
* @property {number} period
|
||||
* @property {string} category
|
||||
*/
|
||||
|
||||
/**
|
||||
* Decode the raw bytes from a GA migration QR code into VaultItems.
|
||||
* @param {Uint8Array} dataBytes
|
||||
* @returns {VaultItem[]}
|
||||
*/
|
||||
decodePayload(dataBytes) {
|
||||
const accounts = []
|
||||
let pos = 0
|
||||
|
||||
function readVarint() {
|
||||
let result = 0
|
||||
let shift = 0
|
||||
while (pos < dataBytes.length) {
|
||||
const byte = dataBytes[pos++]
|
||||
result |= (byte & 0x7f) << shift
|
||||
if ((byte & 0x80) === 0) break
|
||||
shift += 7
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
while (pos < dataBytes.length) {
|
||||
const tag = dataBytes[pos++]
|
||||
const fieldNumber = tag >> 3
|
||||
const wireType = tag & 0x07
|
||||
|
||||
if (fieldNumber === 1 && wireType === 2) {
|
||||
const length = readVarint()
|
||||
const endPos = pos + length
|
||||
|
||||
let secret = null
|
||||
let name = ''
|
||||
let issuer = ''
|
||||
let algorithm = 'SHA1'
|
||||
let digits = 6
|
||||
let type = 'TOTP'
|
||||
|
||||
while (pos < endPos) {
|
||||
const pTag = dataBytes[pos++]
|
||||
const pField = pTag >> 3
|
||||
const pWire = pTag & 0x07
|
||||
|
||||
if (pField === 1 && pWire === 2) {
|
||||
const sLen = readVarint()
|
||||
const sBytes = dataBytes.slice(pos, pos + sLen)
|
||||
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
|
||||
let bits = 0, value = 0, output = ''
|
||||
for (let i = 0; i < sBytes.length; i++) {
|
||||
value = (value << 8) | sBytes[i]
|
||||
bits += 8
|
||||
while (bits >= 5) {
|
||||
output += ALPHABET[(value >>> (bits - 5)) & 31]
|
||||
bits -= 5
|
||||
}
|
||||
}
|
||||
if (bits > 0) output += ALPHABET[(value << (5 - bits)) & 31]
|
||||
secret = output
|
||||
pos += sLen
|
||||
} else if (pField === 2 && pWire === 2) {
|
||||
const nLen = readVarint()
|
||||
name = new TextDecoder().decode(dataBytes.slice(pos, pos + nLen))
|
||||
pos += nLen
|
||||
} else if (pField === 3 && pWire === 2) {
|
||||
const iLen = readVarint()
|
||||
issuer = new TextDecoder().decode(dataBytes.slice(pos, pos + iLen))
|
||||
pos += iLen
|
||||
} else if (pField === 4 && pWire === 0) {
|
||||
const aVal = readVarint()
|
||||
if (aVal === 2) algorithm = 'SHA256'
|
||||
else if (aVal === 3) algorithm = 'SHA512'
|
||||
else if (aVal === 4) algorithm = 'MD5'
|
||||
} else if (pField === 5 && pWire === 0) {
|
||||
const dVal = readVarint()
|
||||
if (dVal === 2) digits = 8
|
||||
} else if (pField === 6 && pWire === 0) {
|
||||
const tVal = readVarint()
|
||||
if (tVal === 1) type = 'HOTP'
|
||||
} else {
|
||||
if (pWire === 0) readVarint()
|
||||
else if (pWire === 2) pos += readVarint()
|
||||
else break
|
||||
}
|
||||
}
|
||||
|
||||
if (secret) {
|
||||
let account = name
|
||||
let finalIssuer = issuer
|
||||
if (!finalIssuer && name.includes(':')) {
|
||||
const parts = name.split(':')
|
||||
finalIssuer = parts[0].trim()
|
||||
account = parts[1].trim()
|
||||
}
|
||||
|
||||
accounts.push({
|
||||
service: finalIssuer || 'Unknown',
|
||||
account: account || 'Unknown',
|
||||
secret: secret,
|
||||
algorithm,
|
||||
digits,
|
||||
period: 30,
|
||||
category: ''
|
||||
})
|
||||
}
|
||||
pos = endPos
|
||||
} else {
|
||||
if (wireType === 0) readVarint()
|
||||
else if (wireType === 2) pos += readVarint()
|
||||
else break
|
||||
}
|
||||
}
|
||||
return accounts
|
||||
}
|
||||
}
|
||||
+18
-5
@@ -36,22 +36,35 @@ export default defineConfig({
|
||||
enabled: true
|
||||
},
|
||||
manifest: {
|
||||
name: '2FAuth Worker - Serverless 2FA Manager',
|
||||
short_name: '2FAuth',
|
||||
description: 'A secure, serverless 2FA management tool.',
|
||||
id: '/',
|
||||
name: '2FAuth Worker',
|
||||
short_name: '2FAuth Worker',
|
||||
description: 'A Secure 2FA Management Tool',
|
||||
theme_color: '#ffffff',
|
||||
background_color: '#ffffff',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
categories: ['utilities', 'security'],
|
||||
icons: [
|
||||
{
|
||||
src: 'pwa-192x192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png'
|
||||
type: 'image/png',
|
||||
purpose: 'any'
|
||||
},
|
||||
{
|
||||
src: 'pwa-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png'
|
||||
type: 'image/png',
|
||||
purpose: 'any'
|
||||
},
|
||||
{
|
||||
src: 'pwa-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user