refactor: 重构项目
This commit is contained in:
1 parent
d97ff2aa2d
commit
92ceb8ac94
24 files changed
+1783
-1634
No files matched your search
@@ -23,19 +23,19 @@ jobs:
|
||||
frontend/package.json
|
||||
backend/package.json
|
||||
|
||||
# 关键步骤:Wrangler 的 build 命令会调用前端构建,先进入 frontend 目录安装依赖,否则构建会失败。
|
||||
# 安装前端依赖 (Wrangler 构建需要)
|
||||
- name: Install Frontend Dependencies
|
||||
run: |
|
||||
cd frontend
|
||||
npm install
|
||||
|
||||
# 安装后端依赖 (包含 wrangler 等)
|
||||
# 安装后端依赖
|
||||
- name: Install Backend Dependencies
|
||||
run: |
|
||||
cd backend
|
||||
npm install
|
||||
|
||||
# 动态注入 D1 数据库ID 和 数据库名称
|
||||
# 注入 D1 数据库配置
|
||||
- name: Inject D1 Database ID
|
||||
env:
|
||||
CLOUDFLARE_D1_DATABASE_ID: ${{ secrets.CLOUDFLARE_D1_DATABASE_ID }}
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
sed -i "0,/\"database_id\":/s/\"database_id\": \".*\"/\"database_id\": \"$CLOUDFLARE_D1_DATABASE_ID\"/" backend/wrangler.jsonc
|
||||
sed -i "0,/\"database_name\":/s/\"database_name\": \".*\"/\"database_name\": \"$CLOUDFLARE_D1_DATABASE_NAME\"/" backend/wrangler.jsonc
|
||||
|
||||
# 初始化或更新数据库结构 (执行 schema.sql)
|
||||
# 更新数据库结构
|
||||
- name: Update Database Schema
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
@@ -54,14 +54,13 @@ jobs:
|
||||
workingDirectory: 'backend'
|
||||
command: d1 execute ${{ secrets.CLOUDFLARE_D1_DATABASE_NAME || '2fauth-db' }} --remote --file=schema.sql
|
||||
|
||||
# 使用官方 Action 进行部署
|
||||
# 部署到 Cloudflare Workers
|
||||
- name: Deploy to Cloudflare Workers
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
workingDirectory: 'backend'
|
||||
# 保持与 package.json 中一致的参数,开启压缩
|
||||
command: deploy --minify
|
||||
secrets: |
|
||||
OAUTH_CLIENT_ID
|
||||
|
||||
@@ -14,6 +14,12 @@ backend/node_modules
|
||||
backend/package-lock.json
|
||||
|
||||
frontend/.vscode
|
||||
frontend/.github
|
||||
frontend/.vite
|
||||
frontend/.gitignore
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
frontend/tests
|
||||
frontend/playwright-report
|
||||
frontend/playwright.config.ts
|
||||
frontend/package-lock.json
|
||||
@@ -6,6 +6,7 @@
|
||||
"deploy": "wrangler deploy --minify src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"aws4fetch": "^1.0.19",
|
||||
"hono": "^4.11.10",
|
||||
"webdav": "^5.9.0"
|
||||
},
|
||||
|
||||
+10
-6
@@ -13,13 +13,17 @@ CREATE TABLE IF NOT EXISTS accounts (
|
||||
updated_by TEXT
|
||||
);
|
||||
|
||||
-- WebDAV 配置表
|
||||
CREATE TABLE IF NOT EXISTS webdav_configs (
|
||||
-- 备份提供商配置表 (支持多账号/多类型)
|
||||
DROP TABLE IF EXISTS webdav_configs;
|
||||
CREATE TABLE IF NOT EXISTS backup_providers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
url TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT NOT NULL, -- 存储加密后的 JSON 字符串
|
||||
save_dir TEXT,
|
||||
type TEXT NOT NULL, -- 类型: 'webdav', 's3', etc.
|
||||
name TEXT NOT NULL, -- 别名: '我的坚果云'
|
||||
is_enabled BOOLEAN DEFAULT 1,-- 开关
|
||||
config TEXT NOT NULL, -- JSON 字符串 (敏感字段如 password 需加密存储)
|
||||
last_backup_at INTEGER, -- 最后备份时间
|
||||
last_backup_status TEXT, -- 'success' | 'failed'
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
);
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { EnvBindings } from './config';
|
||||
// 稍后我们会在这里引入拆分好的路由模块
|
||||
import authRoutes from './routes/auth';
|
||||
import accountsRoutes from './routes/accounts';
|
||||
import webdavRoutes from './routes/webdav';
|
||||
import backupRoutes from './routes/backups';
|
||||
|
||||
// 扩展 EnvBindings 以包含 ASSETS (Cloudflare Pages/Workers Assets)
|
||||
type Bindings = EnvBindings & { ASSETS: { fetch: (req: Request) => Promise<Response> } };
|
||||
@@ -29,14 +29,14 @@ app.get('/api', (c) => c.text('🔐 2FA Secure Manager API is running!'));
|
||||
// 3. 挂载子路由
|
||||
app.route('/api/oauth', authRoutes);
|
||||
app.route('/api/accounts', accountsRoutes);
|
||||
app.route('/api/webdav', webdavRoutes);
|
||||
app.route('/api/backups', backupRoutes);
|
||||
|
||||
// 4. 全局错误处理
|
||||
app.onError((err, c) => {
|
||||
const statusCode = (err as any).statusCode || (err as any).status || 500;
|
||||
|
||||
// 特殊处理: WebDAV list 接口如果返回 404,说明目录不存在,视为无备份,返回空列表
|
||||
if (c.req.path.includes('/webdav/list') && (Number(statusCode) === 404 || err.message.includes('404'))) {
|
||||
if (c.req.path.includes('/files') && (Number(statusCode) === 404 || err.message.includes('404'))) {
|
||||
return c.json({ success: true, backups: [] });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface BackupFile {
|
||||
filename: string;
|
||||
size: number;
|
||||
lastModified: string;
|
||||
}
|
||||
|
||||
export interface BackupProvider {
|
||||
testConnection(): Promise<boolean>;
|
||||
listBackups(): Promise<BackupFile[]>;
|
||||
uploadBackup(filename: string, data: string): Promise<void>;
|
||||
downloadBackup(filename: string): Promise<string>;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { AwsClient } from 'aws4fetch';
|
||||
import { BackupProvider, BackupFile } from './BackupProvider';
|
||||
|
||||
export class S3Provider implements BackupProvider {
|
||||
private client: AwsClient;
|
||||
private bucket: string;
|
||||
private endpoint: string;
|
||||
private prefix: string;
|
||||
|
||||
constructor(config: any) {
|
||||
if (!config.endpoint || !config.accessKeyId || !config.secretAccessKey || !config.bucket) {
|
||||
throw new Error('S3 configuration incomplete');
|
||||
}
|
||||
|
||||
this.client = new AwsClient({
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
region: config.region || 'auto',
|
||||
service: 's3',
|
||||
});
|
||||
|
||||
this.bucket = config.bucket;
|
||||
// 移除 endpoint 末尾的斜杠
|
||||
this.endpoint = config.endpoint.replace(/\/+$/, '');
|
||||
|
||||
// 处理前缀 (目录),确保不以 / 开头,但以 / 结尾
|
||||
this.prefix = (config.saveDir || '').trim();
|
||||
if (this.prefix.startsWith('/')) this.prefix = this.prefix.slice(1);
|
||||
if (this.prefix.length > 0 && !this.prefix.endsWith('/')) this.prefix += '/';
|
||||
}
|
||||
|
||||
// 构造 Path-Style URL: endpoint/bucket/key
|
||||
// 这种方式兼容性最好 (支持 R2, MinIO 等)
|
||||
private getUrl(key: string = '', params: Record<string, string> = {}): string {
|
||||
const url = new URL(`${this.endpoint}/${this.bucket}/${key}`);
|
||||
Object.entries(params).forEach(([k, v]) => url.searchParams.append(k, v));
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async testConnection(): Promise<boolean> {
|
||||
// 使用 list-type=2 获取少量文件来测试权限和连通性
|
||||
const url = this.getUrl('', { 'list-type': '2', 'max-keys': '1' });
|
||||
const res = await this.client.fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`S3 Error: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async listBackups(): Promise<BackupFile[]> {
|
||||
const url = this.getUrl('', { 'list-type': '2', 'prefix': this.prefix });
|
||||
const res = await this.client.fetch(url);
|
||||
if (!res.ok) throw new Error(`Failed to list S3 objects: ${res.status}`);
|
||||
|
||||
const text = await res.text();
|
||||
|
||||
// 简单的 XML 解析 (避免引入庞大的 XML 解析库)
|
||||
// 提取 <Contents> 块中的 Key, LastModified, Size
|
||||
const backups: BackupFile[] = [];
|
||||
const regex = /<Contents>[\s\S]*?<Key>(.*?)<\/Key>[\s\S]*?<LastModified>(.*?)<\/LastModified>[\s\S]*?<Size>(.*?)<\/Size>[\s\S]*?<\/Contents>/g;
|
||||
|
||||
let match;
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
const fullKey = match[1];
|
||||
const lastModified = match[2];
|
||||
const size = parseInt(match[3], 10);
|
||||
|
||||
// 过滤掉非 JSON 备份文件
|
||||
const filename = fullKey.replace(this.prefix, '');
|
||||
if (filename.startsWith('2fa-backup-') && filename.endsWith('.json')) {
|
||||
backups.push({
|
||||
filename: filename,
|
||||
size: size,
|
||||
lastModified: lastModified
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return backups.sort((a, b) => b.filename.localeCompare(a.filename));
|
||||
}
|
||||
|
||||
async uploadBackup(filename: string, data: string): Promise<void> {
|
||||
const key = this.prefix + filename;
|
||||
const url = this.getUrl(key);
|
||||
|
||||
const res = await this.client.fetch(url, {
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`S3 Upload Failed: ${res.status}`);
|
||||
}
|
||||
|
||||
async downloadBackup(filename: string): Promise<string> {
|
||||
const key = this.prefix + filename;
|
||||
const url = this.getUrl(key);
|
||||
const res = await this.client.fetch(url);
|
||||
if (!res.ok) throw new Error(`S3 Download Failed: ${res.status}`);
|
||||
return await res.text();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createClient, WebDAVClient } from 'webdav';
|
||||
import { BackupProvider, BackupFile } from './BackupProvider';
|
||||
|
||||
export class WebDavProvider implements BackupProvider {
|
||||
private client: WebDAVClient;
|
||||
private saveDir: string;
|
||||
|
||||
constructor(config: any) {
|
||||
if (!config.url || !config.username || !config.password) {
|
||||
throw new Error('WebDAV configuration incomplete');
|
||||
}
|
||||
this.client = createClient(config.url, {
|
||||
username: config.username,
|
||||
password: config.password
|
||||
});
|
||||
|
||||
// 标准化 saveDir: 确保以 / 开头,且不以 / 结尾 (除非是根目录)
|
||||
let dir = (config.saveDir || '/').trim();
|
||||
if (!dir.startsWith('/')) dir = '/' + dir;
|
||||
if (dir.length > 1 && dir.endsWith('/')) dir = dir.slice(0, -1);
|
||||
|
||||
this.saveDir = dir;
|
||||
}
|
||||
|
||||
async testConnection(): Promise<boolean> {
|
||||
await this.client.getDirectoryContents('/');
|
||||
return true;
|
||||
}
|
||||
|
||||
async listBackups(): Promise<BackupFile[]> {
|
||||
const items = await this.client.getDirectoryContents(this.saveDir);
|
||||
return (items as any[])
|
||||
.filter(item => item.type === 'file' && item.basename.startsWith('2fa-backup-') && item.basename.endsWith('.json'))
|
||||
.map(item => {
|
||||
// Try to parse date from filename if lastmod is missing or generic
|
||||
let displayTime = item.lastmod;
|
||||
return {
|
||||
filename: item.basename,
|
||||
path: item.filename, // 返回 WebDAV 服务器提供的绝对路径
|
||||
size: item.size,
|
||||
lastModified: displayTime
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.filename.localeCompare(a.filename));
|
||||
}
|
||||
|
||||
async uploadBackup(filename: string, data: string): Promise<void> {
|
||||
if (this.saveDir !== '/' && await this.client.exists(this.saveDir) === false) {
|
||||
await this.client.createDirectory(this.saveDir);
|
||||
}
|
||||
const fullPath = this.saveDir === '/' ? `/${filename}` : `${this.saveDir}/${filename}`;
|
||||
await this.client.putFileContents(fullPath, data);
|
||||
}
|
||||
|
||||
async downloadBackup(filename: string): Promise<string> {
|
||||
const cleanFilename = filename.trim().replace(/^\/+/, '');
|
||||
const fullPath = this.saveDir === '/' ? `/${cleanFilename}` : `${this.saveDir}/${cleanFilename}`;
|
||||
|
||||
const content = await this.client.getFileContents(fullPath, { format: 'text' });
|
||||
return content as string;
|
||||
}
|
||||
}
|
||||
@@ -2,33 +2,14 @@ import { Hono } from 'hono';
|
||||
import { EnvBindings, AppError, SECURITY_CONFIG } from '../config';
|
||||
import { authMiddleware, sanitizeInput, validateBase32Secret, validateServiceName, validateAccountName, parseOTPAuthURI } from '../utils/helper';
|
||||
import { encryptData, decryptData, generateTOTP } from '../utils/crypto';
|
||||
import { encryptField, decryptField, batchInsertAccounts, batchDeleteAccounts } from '../utils/db';
|
||||
|
||||
// 定义带有环境变量和用户变量的 Hono 实例
|
||||
const accounts = new Hono<{ Bindings: EnvBindings, Variables: { user: any } }>();
|
||||
|
||||
// 🛡️ 为这下面所有的路由挂载鉴权中间件 (必须登录才能访问)
|
||||
// 挂载鉴权中间件
|
||||
accounts.use('*', authMiddleware);
|
||||
|
||||
// 辅助:加密单个字段并序列化为字符串存储
|
||||
async function encryptField(data: any, key: string) {
|
||||
const encrypted = await encryptData(data, key);
|
||||
return JSON.stringify(encrypted);
|
||||
}
|
||||
|
||||
// 辅助:反序列化并解密单个字段
|
||||
async function decryptField(encryptedStr: string, key: string) {
|
||||
try {
|
||||
const encryptedObj = JSON.parse(encryptedStr);
|
||||
return await decryptData(encryptedObj, key);
|
||||
} catch (e) {
|
||||
console.error('Decryption failed', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 1. 获取所有账号
|
||||
// ==========================================
|
||||
// 获取所有账号
|
||||
accounts.get('/', async (c) => {
|
||||
const pageStr = c.req.query('page');
|
||||
const limitStr = c.req.query('limit');
|
||||
@@ -59,13 +40,11 @@ accounts.get('/', async (c) => {
|
||||
const accountsList = await Promise.all(results.map(async (row: any) => {
|
||||
const secret = await decryptField(row.secret, key);
|
||||
|
||||
// 🚀 [自动迁移] 检查是否为旧格式 (含 salt),如果是则异步升级为极速格式
|
||||
// [自动迁移] 旧格式 (含 salt) 异步升级为极速格式
|
||||
try {
|
||||
const raw = JSON.parse(row.secret);
|
||||
if (raw.salt && raw.salt.length > 0) {
|
||||
// 重新加密 (encryptField 现已使用极速模式)
|
||||
const newSecret = await encryptField(secret, key);
|
||||
// 异步更新数据库 (不阻塞响应)
|
||||
c.executionCtx.waitUntil(
|
||||
c.env.DB.prepare("UPDATE accounts SET secret = ? WHERE id = ?")
|
||||
.bind(newSecret, row.id)
|
||||
@@ -98,9 +77,7 @@ accounts.get('/', async (c) => {
|
||||
return c.json(response);
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 2. 添加新账号
|
||||
// ==========================================
|
||||
// 添加新账号
|
||||
accounts.post('/', async (c) => {
|
||||
const { service, category, account, secret, digits = 6, period = 30 } = await c.req.json();
|
||||
const user = c.get('user');
|
||||
@@ -112,7 +89,7 @@ accounts.post('/', async (c) => {
|
||||
throw new AppError('Invalid digits or period', 400);
|
||||
}
|
||||
|
||||
// D1: 查重
|
||||
// 查重
|
||||
const existing = await c.env.DB.prepare(
|
||||
"SELECT id FROM accounts WHERE service = ? AND account = ?"
|
||||
).bind(service, account).first();
|
||||
@@ -123,7 +100,7 @@ accounts.post('/', async (c) => {
|
||||
const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
|
||||
const secretEncrypted = await encryptField(secret.replace(/\s/g, '').toUpperCase(), key);
|
||||
|
||||
// D1: 插入
|
||||
// 插入
|
||||
await c.env.DB.prepare(
|
||||
`INSERT INTO accounts (id, service, account, category, secret, digits, period, created_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
@@ -137,9 +114,7 @@ accounts.post('/', async (c) => {
|
||||
return c.json({ success: true, account: { ...newAccount, secret: '[PROTECTED]' } });
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 3. 修改账号信息
|
||||
// ==========================================
|
||||
// 修改账号信息
|
||||
accounts.put('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const { service, category, account, digits, period } = await c.req.json();
|
||||
@@ -165,9 +140,7 @@ accounts.put('/:id', async (c) => {
|
||||
return c.json({ success: true, message: 'Account updated successfully' });
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 4. 删除指定账号
|
||||
// ==========================================
|
||||
// 删除指定账号
|
||||
accounts.delete('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const result = await c.env.DB.prepare("DELETE FROM accounts WHERE id = ?").bind(id).run();
|
||||
@@ -177,17 +150,22 @@ accounts.delete('/:id', async (c) => {
|
||||
return c.json({ success: true, message: 'Account deleted successfully' });
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 5. 清空所有账号
|
||||
// ==========================================
|
||||
// 批量删除账号
|
||||
accounts.post('/batch-delete', async (c) => {
|
||||
const { ids } = await c.req.json();
|
||||
if (!Array.isArray(ids) || ids.length === 0) throw new AppError('No IDs provided', 400);
|
||||
|
||||
const count = await batchDeleteAccounts(c.env.DB, ids);
|
||||
return c.json({ success: true, count, message: `Successfully deleted ${count} accounts` });
|
||||
});
|
||||
|
||||
// 清空所有账号
|
||||
accounts.delete('/clear-all', async (c) => {
|
||||
await c.env.DB.prepare("DELETE FROM accounts").run();
|
||||
return c.json({ success: true, message: 'All accounts cleared' });
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 6. 核心功能:生成 TOTP 验证码
|
||||
// ==========================================
|
||||
// 生成 TOTP 验证码
|
||||
accounts.post('/generate-totp', async (c) => {
|
||||
const { secret, period = 30, digits = 6 } = await c.req.json();
|
||||
|
||||
@@ -198,9 +176,7 @@ accounts.post('/generate-totp', async (c) => {
|
||||
return c.json({ success: true, code });
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 7. 扫码支持:解析 TOTP URI
|
||||
// ==========================================
|
||||
// 解析 TOTP URI
|
||||
accounts.post('/parse-uri', async (c) => {
|
||||
const { uri } = await c.req.json();
|
||||
if (!uri) throw new AppError('URI is required', 400);
|
||||
@@ -211,9 +187,7 @@ accounts.post('/parse-uri', async (c) => {
|
||||
return c.json({ success: true, account });
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 8. 从扫码结果添加账户 (修复架构师遗漏的接口)
|
||||
// ==========================================
|
||||
// 从 URI 添加账户
|
||||
accounts.post('/add-from-uri', async (c) => {
|
||||
const { uri, category } = await c.req.json();
|
||||
const user = c.get('user');
|
||||
@@ -223,7 +197,7 @@ accounts.post('/add-from-uri', async (c) => {
|
||||
const parsedAccount = parseOTPAuthURI(uri);
|
||||
if (!parsedAccount) throw new AppError('Invalid OTP Auth URI', 400);
|
||||
|
||||
// D1: 查重
|
||||
// 查重
|
||||
const existing = await c.env.DB.prepare(
|
||||
"SELECT id FROM accounts WHERE service = ? AND account = ?"
|
||||
).bind(parsedAccount.issuer, parsedAccount.account).first();
|
||||
@@ -246,9 +220,7 @@ accounts.post('/add-from-uri', async (c) => {
|
||||
return c.json({ success: true, account: { ...newAccount, secret: '[PROTECTED]' } });
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 9. 加密导出金库数据
|
||||
// ==========================================
|
||||
// 加密导出数据
|
||||
accounts.post('/export-secure', async (c) => {
|
||||
const { password } = await c.req.json();
|
||||
if (!password || password.length < SECURITY_CONFIG.MIN_EXPORT_PASSWORD_LENGTH) {
|
||||
@@ -288,9 +260,7 @@ accounts.post('/export-secure', async (c) => {
|
||||
return c.json(exportFile);
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 10. 全能导入通道 (支持 加密/JSON/2FAS/文本)
|
||||
// ==========================================
|
||||
// 导入数据 (支持 加密/JSON/2FAS/文本)
|
||||
accounts.post('/import', async (c) => {
|
||||
const { content, type, password } = await c.req.json();
|
||||
const user = c.get('user');
|
||||
@@ -344,42 +314,35 @@ accounts.post('/import', async (c) => {
|
||||
}
|
||||
|
||||
// 过滤有效账户并去重保存
|
||||
let addedCount = 0;
|
||||
const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
|
||||
|
||||
// D1 批量插入 (使用事务或批量执行)
|
||||
const stmt = c.env.DB.prepare(
|
||||
`INSERT INTO accounts (id, service, account, category, secret, digits, period, created_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
const batch = [];
|
||||
// 1. 预先获取所有已存在的账号标识,避免 N+1 查询
|
||||
const { results: existingRows } = await c.env.DB.prepare("SELECT service, account FROM accounts").all();
|
||||
// 内存中快速查重 (格式: service:account)
|
||||
const existingSet = new Set(existingRows.map((row: any) => `${row.service}:${row.account}`));
|
||||
|
||||
// 2. 内存过滤与并行加密
|
||||
const uniqueAccountsToInsert = [];
|
||||
const seenInBatch = new Set();
|
||||
|
||||
for (const acc of validAccounts) {
|
||||
if (acc.service && acc.account && validateBase32Secret(acc.secret)) {
|
||||
// 简单查重
|
||||
const existing = await c.env.DB.prepare("SELECT id FROM accounts WHERE service = ? AND account = ?")
|
||||
.bind(acc.service, acc.account).first();
|
||||
|
||||
if (!existing) {
|
||||
const secretEncrypted = await encryptField(acc.secret.replace(/\s/g, '').toUpperCase(), key);
|
||||
batch.push(stmt.bind(
|
||||
crypto.randomUUID(), sanitizeInput(acc.service, 50), sanitizeInput(acc.account, 100),
|
||||
acc.category ? sanitizeInput(acc.category, 30) : '', secretEncrypted,
|
||||
acc.digits || 6, acc.period || 30, Date.now(), user.username
|
||||
));
|
||||
addedCount++;
|
||||
const signature = `${acc.service}:${acc.account}`;
|
||||
// 查重:既不在数据库中,也不在当前待插入的批次中
|
||||
if (!existingSet.has(signature) && !seenInBatch.has(signature)) {
|
||||
uniqueAccountsToInsert.push(acc);
|
||||
seenInBatch.add(signature);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (batch.length > 0) await c.env.DB.batch(batch);
|
||||
// 3. 使用封装好的批量插入函数
|
||||
const insertedCount = await batchInsertAccounts(c.env.DB, uniqueAccountsToInsert, key, user.username);
|
||||
|
||||
return c.json({ success: true, count: addedCount, total: validAccounts.length, duplicates: validAccounts.length - addedCount });
|
||||
return c.json({ success: true, count: insertedCount, total: validAccounts.length, duplicates: validAccounts.length - insertedCount });
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 11. 手动触发数据迁移 (旧格式 -> 极速格式)
|
||||
// ==========================================
|
||||
// 手动触发数据迁移 (旧格式 -> 极速格式)
|
||||
accounts.post('/migrate-crypto', async (c) => {
|
||||
const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
|
||||
const limit = 50; // 每次处理 50 条以防超时
|
||||
@@ -393,18 +356,24 @@ accounts.post('/migrate-crypto', async (c) => {
|
||||
return c.json({ success: true, message: '所有数据已是最新格式', migrated: 0, remaining: 0 });
|
||||
}
|
||||
|
||||
const updates = [];
|
||||
for (const row of results) {
|
||||
// 并行处理解密和加密
|
||||
const updates = (await Promise.all(results.map(async (row: any) => {
|
||||
try {
|
||||
const plain = await decryptField(row.secret as string, key);
|
||||
const newSecret = await encryptField(plain, key);
|
||||
updates.push(
|
||||
c.env.DB.prepare("UPDATE accounts SET secret = ? WHERE id = ?").bind(newSecret, row.id)
|
||||
);
|
||||
} catch (e) { console.error(`Failed to migrate account ${row.id}`, e); }
|
||||
}
|
||||
return c.env.DB.prepare("UPDATE accounts SET secret = ? WHERE id = ?").bind(newSecret, row.id);
|
||||
} catch (e) {
|
||||
console.error(`Failed to migrate account ${row.id}`, e);
|
||||
return null;
|
||||
}
|
||||
}))).filter(Boolean);
|
||||
|
||||
if (updates.length > 0) await c.env.DB.batch(updates);
|
||||
// 分批执行更新
|
||||
const BATCH_SIZE = 20;
|
||||
for (let i = 0; i < updates.length; i += BATCH_SIZE) {
|
||||
const chunk = updates.slice(i, i + BATCH_SIZE);
|
||||
if (chunk.length > 0) await c.env.DB.batch(chunk);
|
||||
}
|
||||
|
||||
// 检查剩余数量
|
||||
const remaining = await c.env.DB.prepare(
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { Hono } from 'hono';
|
||||
import { EnvBindings, AppError, SECURITY_CONFIG } from '../config';
|
||||
import { authMiddleware, sanitizeInput } from '../utils/helper';
|
||||
import { encryptData, decryptData } from '../utils/crypto';
|
||||
import { BackupProvider } from '../providers/BackupProvider';
|
||||
import { WebDavProvider } from '../providers/WebDavProvider';
|
||||
import { S3Provider } from '../providers/S3Provider';
|
||||
import { batchInsertAccounts, decryptField } from '../utils/db';
|
||||
|
||||
const backups = new Hono<{ Bindings: EnvBindings, Variables: { user: any } }>();
|
||||
|
||||
backups.use('*', authMiddleware);
|
||||
|
||||
// 工厂函数:根据类型获取 Provider 实例
|
||||
async function getProvider(type: string, config: any): Promise<BackupProvider> {
|
||||
switch (type) {
|
||||
case 'webdav':
|
||||
return new WebDavProvider(config);
|
||||
case 's3':
|
||||
return new S3Provider(config);
|
||||
default:
|
||||
throw new AppError(`Unknown provider type: ${type}`, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数:处理配置存储(加密敏感字段)
|
||||
async function processConfigForStorage(type: string, config: any, key: string) {
|
||||
const processed = { ...config };
|
||||
if (type === 'webdav') {
|
||||
if (processed.password) {
|
||||
processed.password = await encryptData(processed.password, key);
|
||||
}
|
||||
}
|
||||
if (type === 's3') {
|
||||
if (processed.secretAccessKey) {
|
||||
processed.secretAccessKey = await encryptData(processed.secretAccessKey, key);
|
||||
}
|
||||
}
|
||||
return JSON.stringify(processed);
|
||||
}
|
||||
|
||||
// 辅助函数:处理配置读取(解密敏感字段)
|
||||
async function processConfigForUsage(type: string, configStr: string, key: string) {
|
||||
const config = JSON.parse(configStr);
|
||||
if (type === 'webdav') {
|
||||
if (config.password) {
|
||||
config.password = await decryptData(config.password, key);
|
||||
}
|
||||
}
|
||||
if (type === 's3') {
|
||||
if (config.secretAccessKey) {
|
||||
config.secretAccessKey = await decryptData(config.secretAccessKey, key);
|
||||
}
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 1. 获取所有备份源列表
|
||||
// ==========================================
|
||||
backups.get('/providers', async (c) => {
|
||||
const { results } = await c.env.DB.prepare("SELECT * FROM backup_providers ORDER BY created_at DESC").all();
|
||||
|
||||
const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
|
||||
const providers = await Promise.all(results.map(async (row: any) => {
|
||||
// 解密配置返回给前端(以便编辑回显),注意前端展示时要掩码处理
|
||||
const config = await processConfigForUsage(row.type, row.config, key);
|
||||
return {
|
||||
...row,
|
||||
config
|
||||
};
|
||||
}));
|
||||
|
||||
return c.json({ success: true, providers });
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 2. 添加备份源
|
||||
// ==========================================
|
||||
backups.post('/providers', async (c) => {
|
||||
const { type, name, config } = await c.req.json();
|
||||
|
||||
if (!type || !name || !config) throw new AppError('Missing required fields', 400);
|
||||
|
||||
const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
|
||||
const encryptedConfig = await processConfigForStorage(type, config, key);
|
||||
|
||||
const res = await c.env.DB.prepare(
|
||||
"INSERT INTO backup_providers (type, name, config, created_at, updated_at) VALUES (?, ?, ?, ?, ?)"
|
||||
).bind(type, name, encryptedConfig, Date.now(), Date.now()).run();
|
||||
|
||||
return c.json({ success: true, id: res.meta.last_row_id });
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 3. 更新备份源
|
||||
// ==========================================
|
||||
backups.put('/providers/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const { name, config, type } = await c.req.json();
|
||||
|
||||
const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
|
||||
const encryptedConfig = await processConfigForStorage(type, config, key);
|
||||
|
||||
await c.env.DB.prepare(
|
||||
"UPDATE backup_providers SET name = ?, config = ?, updated_at = ? WHERE id = ?"
|
||||
).bind(name, encryptedConfig, Date.now(), id).run();
|
||||
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 4. 删除备份源
|
||||
// ==========================================
|
||||
backups.delete('/providers/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
await c.env.DB.prepare("DELETE FROM backup_providers WHERE id = ?").bind(id).run();
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 5. 测试连接
|
||||
// ==========================================
|
||||
backups.post('/providers/test', async (c) => {
|
||||
const { type, config } = await c.req.json();
|
||||
try {
|
||||
const provider = await getProvider(type, config);
|
||||
await provider.testConnection();
|
||||
return c.json({ success: true, message: 'Connection successful' });
|
||||
} catch (e: any) {
|
||||
throw new AppError(`Connection failed: ${e.message}`, 400);
|
||||
}
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 6. 执行备份 (导出)
|
||||
// ==========================================
|
||||
backups.post('/providers/:id/backup', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const { password } = await c.req.json(); // 用户输入的加密密码
|
||||
|
||||
if (!password || password.length < SECURITY_CONFIG.MIN_EXPORT_PASSWORD_LENGTH) {
|
||||
throw new AppError(`Export password must be at least ${SECURITY_CONFIG.MIN_EXPORT_PASSWORD_LENGTH} chars`, 400);
|
||||
}
|
||||
|
||||
// 1. 获取 Provider 配置
|
||||
const providerRow: any = await c.env.DB.prepare("SELECT * FROM backup_providers WHERE id = ?").bind(id).first();
|
||||
if (!providerRow) throw new AppError('Provider not found', 404);
|
||||
|
||||
const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
|
||||
const config = await processConfigForUsage(providerRow.type, providerRow.config, key);
|
||||
const provider = await getProvider(providerRow.type, config);
|
||||
|
||||
// 2. 准备数据 (从数据库读取并解密)
|
||||
const { results: accountResults } = await c.env.DB.prepare("SELECT * FROM accounts").all();
|
||||
|
||||
// 优化:并行解密,并过滤掉解密失败的数据,防止因单条数据损坏导致整个备份失败
|
||||
const accounts = (await Promise.all(accountResults.map(async (row: any) => {
|
||||
const secret = await decryptField(row.secret, key);
|
||||
if (!secret) return null; // 如果某条数据解密失败(如密钥轮换遗留的旧数据),则跳过,不影响整体备份
|
||||
|
||||
return {
|
||||
service: row.service,
|
||||
account: row.account,
|
||||
category: row.category,
|
||||
secret: secret,
|
||||
digits: row.digits,
|
||||
period: row.period
|
||||
};
|
||||
}))).filter(Boolean);
|
||||
|
||||
const exportPayload = {
|
||||
version: "2.0",
|
||||
app: "2fa-secure-manager",
|
||||
encrypted: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
accounts
|
||||
};
|
||||
|
||||
// 3. 使用用户密码加密 Payload
|
||||
const userEncrypted = await encryptData(exportPayload, password);
|
||||
const fileContent = JSON.stringify({ ...exportPayload, data: userEncrypted, accounts: undefined });
|
||||
|
||||
// 4. 上传
|
||||
const filename = `2fa-backup-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
|
||||
try {
|
||||
await provider.uploadBackup(filename, fileContent);
|
||||
|
||||
// 更新状态
|
||||
await c.env.DB.prepare("UPDATE backup_providers SET last_backup_at = ?, last_backup_status = 'success' WHERE id = ?")
|
||||
.bind(Date.now(), id).run();
|
||||
|
||||
return c.json({ success: true, message: 'Backup successful' });
|
||||
} catch (e: any) {
|
||||
await c.env.DB.prepare("UPDATE backup_providers SET last_backup_status = 'failed' WHERE id = ?")
|
||||
.bind(id).run();
|
||||
throw new AppError(`Backup failed: ${e.message}`, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 7. 获取文件列表 & 恢复备份
|
||||
// ==========================================
|
||||
backups.get('/providers/:id/files', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const providerRow: any = await c.env.DB.prepare("SELECT * FROM backup_providers WHERE id = ?").bind(id).first();
|
||||
if (!providerRow) throw new AppError('Provider not found', 404);
|
||||
|
||||
const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
|
||||
const config = await processConfigForUsage(providerRow.type, providerRow.config, key);
|
||||
const provider = await getProvider(providerRow.type, config);
|
||||
|
||||
const files = await provider.listBackups();
|
||||
return c.json({ success: true, files });
|
||||
});
|
||||
|
||||
backups.post('/providers/:id/restore', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const { filename, password } = await c.req.json();
|
||||
|
||||
const providerRow: any = await c.env.DB.prepare("SELECT * FROM backup_providers WHERE id = ?").bind(id).first();
|
||||
if (!providerRow) throw new AppError('Provider not found', 404);
|
||||
|
||||
const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
|
||||
const config = await processConfigForUsage(providerRow.type, providerRow.config, key);
|
||||
const provider = await getProvider(providerRow.type, config);
|
||||
|
||||
// 1. 下载文件
|
||||
let content: string;
|
||||
try {
|
||||
content = await provider.downloadBackup(filename);
|
||||
} catch (e: any) {
|
||||
throw new AppError(`Restore download failed: ${e.message}`, 500);
|
||||
}
|
||||
|
||||
// 2. 解析并解密
|
||||
let accounts = [];
|
||||
try {
|
||||
const backupFile = JSON.parse(content);
|
||||
if (!backupFile.data) throw new Error('Legacy format or invalid file');
|
||||
|
||||
const decrypted = await decryptData(backupFile.data, password); // 使用用户输入的密码解密
|
||||
accounts = decrypted.accounts || [];
|
||||
} catch (e: any) {
|
||||
throw new AppError('解密失败:密码错误或文件格式不兼容', 400);
|
||||
}
|
||||
|
||||
// 3. 覆盖写入数据库 (先清空,再分批插入)
|
||||
await c.env.DB.prepare("DELETE FROM accounts").run();
|
||||
|
||||
// 4. 使用封装好的批量插入函数
|
||||
const insertedCount = await batchInsertAccounts(c.env.DB, accounts, key, 'restore');
|
||||
|
||||
return c.json({ success: true, message: 'Restore successful', count: insertedCount });
|
||||
});
|
||||
|
||||
export default backups;
|
||||
@@ -1,310 +0,0 @@
|
||||
import { Hono } from 'hono';
|
||||
import { createClient } from 'webdav';
|
||||
import { EnvBindings, AppError, SECURITY_CONFIG } from '../config';
|
||||
import { authMiddleware, sanitizeInput } from '../utils/helper';
|
||||
import { encryptData, decryptData } from '../utils/crypto';
|
||||
|
||||
const webdav = new Hono<{ Bindings: EnvBindings, Variables: { user: any } }>();
|
||||
|
||||
// 🛡️ 挂载鉴权中间件
|
||||
webdav.use('*', authMiddleware);
|
||||
|
||||
// 辅助:加密单个字段并序列化为字符串存储
|
||||
async function encryptField(data: any, key: string) {
|
||||
const encrypted = await encryptData(data, key);
|
||||
return JSON.stringify(encrypted);
|
||||
}
|
||||
|
||||
// 辅助:反序列化并解密单个字段
|
||||
async function decryptField(encryptedStr: string, key: string) {
|
||||
try {
|
||||
const encryptedObj = JSON.parse(encryptedStr);
|
||||
return await decryptData(encryptedObj, key);
|
||||
} catch (e) {
|
||||
console.error('Decryption failed', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 内部辅助:创建 WebDAV 客户端
|
||||
function getWebDavClient(config: any) {
|
||||
if (!config || !config.url || !config.username || !config.password) {
|
||||
throw new AppError('WebDAV configuration is incomplete', 400);
|
||||
}
|
||||
return createClient(config.url, {
|
||||
username: config.username,
|
||||
password: config.password
|
||||
});
|
||||
}
|
||||
|
||||
// 内部辅助:生成备份文件名
|
||||
function generateBackupFilename(dir: string) {
|
||||
const date = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const cleanDir = dir.endsWith('/') ? dir.slice(0, -1) : dir;
|
||||
return `${cleanDir}/2fa-backup-${date}.json`;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 1. 获取 WebDAV 配置 (解密后返回给前端)
|
||||
// ==========================================
|
||||
webdav.get('/configs', async (c) => {
|
||||
const { results } = await c.env.DB.prepare("SELECT * FROM webdav_configs LIMIT 1").all();
|
||||
if (results.length === 0) return c.json({ success: true, configs: [] });
|
||||
|
||||
const row: any = results[0];
|
||||
const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
|
||||
const password = await decryptField(row.password, key);
|
||||
|
||||
// 🚀 [自动迁移] 升级 WebDAV 密码加密格式
|
||||
try {
|
||||
const raw = JSON.parse(row.password);
|
||||
if (raw.salt && raw.salt.length > 0) {
|
||||
const newPass = await encryptField(password, key);
|
||||
c.executionCtx.waitUntil(
|
||||
c.env.DB.prepare("UPDATE webdav_configs SET password = ? WHERE id = ?").bind(newPass, row.id).run()
|
||||
);
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
const configs = [{
|
||||
url: row.url,
|
||||
username: row.username,
|
||||
password: password,
|
||||
saveDir: row.save_dir
|
||||
}];
|
||||
return c.json({ success: true, configs });
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 2. 保存 WebDAV 配置 (加密后存入 KV)
|
||||
// ==========================================
|
||||
webdav.post('/configs', async (c) => {
|
||||
const { configs } = await c.req.json();
|
||||
|
||||
if (!Array.isArray(configs)) {
|
||||
throw new AppError('Invalid format: configs must be an array', 400);
|
||||
}
|
||||
|
||||
const config = configs[0];
|
||||
const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
|
||||
const passwordEncrypted = await encryptField(config.password, key);
|
||||
|
||||
await c.env.DB.batch([
|
||||
c.env.DB.prepare("DELETE FROM webdav_configs"),
|
||||
c.env.DB.prepare("INSERT INTO webdav_configs (url, username, password, save_dir, updated_at) VALUES (?, ?, ?, ?, ?)").bind(config.url, config.username, passwordEncrypted, config.saveDir, Date.now())
|
||||
]);
|
||||
|
||||
return c.json({ success: true, message: 'Configuration saved securely' });
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 3. 测试连接 (示例桩代码)
|
||||
// ==========================================
|
||||
webdav.post('/test', async (c) => {
|
||||
// 前端通常会把当前填写的配置传过来测试,所以这里直接用 body 里的
|
||||
const config = await c.req.json();
|
||||
|
||||
try {
|
||||
const client = getWebDavClient(config);
|
||||
// 尝试读取根目录来验证凭证
|
||||
await client.getDirectoryContents('/');
|
||||
return c.json({ success: true, message: 'Connection successful' });
|
||||
} catch (error: any) {
|
||||
console.error('WebDAV Test Error:', error);
|
||||
throw new AppError(`Connection failed: ${error.message}`, 400);
|
||||
}
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 4. 获取备份文件列表
|
||||
// ==========================================
|
||||
webdav.post('/list', async (c) => {
|
||||
const { results } = await c.env.DB.prepare("SELECT * FROM webdav_configs LIMIT 1").all();
|
||||
if (results.length === 0) throw new AppError('No WebDAV config found', 404);
|
||||
|
||||
const row: any = results[0];
|
||||
const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
|
||||
const password = await decryptField(row.password, key);
|
||||
|
||||
// 🚀 [自动迁移] 升级 WebDAV 密码加密格式 (在 list 接口也做检查)
|
||||
try {
|
||||
const raw = JSON.parse(row.password);
|
||||
if (raw.salt && raw.salt.length > 0) {
|
||||
const newPass = await encryptField(password, key);
|
||||
c.executionCtx.waitUntil(
|
||||
c.env.DB.prepare("UPDATE webdav_configs SET password = ? WHERE id = ?").bind(newPass, row.id).run()
|
||||
);
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
const configs = [{
|
||||
url: row.url,
|
||||
username: row.username,
|
||||
password: password,
|
||||
saveDir: row.save_dir || '/'
|
||||
}];
|
||||
|
||||
if (configs.length === 0) throw new AppError('No WebDAV config found', 404);
|
||||
|
||||
// 默认使用第一个配置
|
||||
const config = configs[0];
|
||||
const client = getWebDavClient(config);
|
||||
|
||||
try {
|
||||
const items = await client.getDirectoryContents(config.saveDir);
|
||||
// 过滤出我们的备份文件
|
||||
const backups = (items as any[])
|
||||
.filter(item => item.type === 'file' && item.basename.startsWith('2fa-backup-') && item.basename.endsWith('.json'))
|
||||
.map(item => {
|
||||
// 尝试获取时间:优先用服务器返回的 lastmod,如果没有则尝试从文件名解析 (文件名包含 ISO 时间戳)
|
||||
let displayTime = item.lastmod;
|
||||
if (!displayTime) {
|
||||
try {
|
||||
// 文件名格式: 2fa-backup-2024-03-20T10-00-00-000Z.json
|
||||
// 简单提取中间的时间部分用于显示
|
||||
const match = item.basename.match(/2fa-backup-(.+)\.json/);
|
||||
if (match) displayTime = match[1].replace(/-/g, ':').replace('T', ' ');
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
return {
|
||||
name: item.basename,
|
||||
filename: item.basename, // 兼容前端可能使用的 filename 字段
|
||||
size: item.size,
|
||||
lastMod: displayTime,
|
||||
lastModified: displayTime // 兼容前端可能使用的 lastModified 字段
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.name.localeCompare(a.name)); // 按时间倒序
|
||||
|
||||
return c.json({ success: true, backups });
|
||||
} catch (error: any) {
|
||||
throw new AppError(`Failed to list backups: ${error.message}`, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 5. 导出当前金库到 WebDAV
|
||||
// ==========================================
|
||||
webdav.post('/export', async (c) => {
|
||||
const { password } = await c.req.json();
|
||||
if (!password || password.length < SECURITY_CONFIG.MIN_EXPORT_PASSWORD_LENGTH) {
|
||||
throw new AppError(`导出密码至少需要 ${SECURITY_CONFIG.MIN_EXPORT_PASSWORD_LENGTH} 个字符`, 400);
|
||||
}
|
||||
|
||||
// 获取 WebDAV 配置
|
||||
const { results: configResults } = await c.env.DB.prepare("SELECT * FROM webdav_configs LIMIT 1").all();
|
||||
if (configResults.length === 0) throw new AppError('No WebDAV config found', 404);
|
||||
const configRow: any = configResults[0];
|
||||
const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
|
||||
const webdavPassword = await decryptField(configRow.password, key);
|
||||
const webdavConfig = { url: configRow.url, username: configRow.username, password: webdavPassword, saveDir: configRow.save_dir || '/' };
|
||||
|
||||
// 1. 从 D1 获取并解密当前数据
|
||||
const { results: accountResults } = await c.env.DB.prepare("SELECT * FROM accounts").all();
|
||||
|
||||
const accounts = await Promise.all(accountResults.map(async (row: any) => ({
|
||||
service: row.service,
|
||||
account: row.account,
|
||||
category: row.category,
|
||||
secret: await decryptField(row.secret, key),
|
||||
digits: row.digits,
|
||||
period: row.period
|
||||
})));
|
||||
|
||||
// 2. 准备导出数据结构 (清洗数据,仅保留必要字段)
|
||||
const exportPayload = {
|
||||
version: "2.0",
|
||||
app: "2fa-secure-manager",
|
||||
encrypted: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
accounts: accounts.map((acc: any) => ({
|
||||
service: acc.service, category: acc.category, account: acc.account,
|
||||
secret: acc.secret, digits: acc.digits, period: acc.period
|
||||
}))
|
||||
};
|
||||
|
||||
// 3. 使用用户密码重新加密
|
||||
const userEncrypted = await encryptData(exportPayload, password);
|
||||
const fileContent = JSON.stringify({ ...exportPayload, data: userEncrypted, accounts: undefined });
|
||||
|
||||
// 4. 上传到 WebDAV
|
||||
const client = getWebDavClient(webdavConfig);
|
||||
const filename = generateBackupFilename(webdavConfig.saveDir);
|
||||
|
||||
// 尝试创建目录(如果不存在)
|
||||
if (webdavConfig.saveDir !== '/' && await client.exists(webdavConfig.saveDir) === false) {
|
||||
await client.createDirectory(webdavConfig.saveDir);
|
||||
}
|
||||
|
||||
await client.putFileContents(filename, fileContent);
|
||||
|
||||
return c.json({ success: true, message: 'Backup uploaded successfully', filename });
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 6. 从 WebDAV 恢复备份
|
||||
// ==========================================
|
||||
webdav.post('/restore', async (c) => {
|
||||
let body;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch (e) {
|
||||
throw new AppError('Invalid JSON body', 400);
|
||||
}
|
||||
|
||||
// 规范化参数
|
||||
const filename = body.filename || body.name;
|
||||
const password = body.password;
|
||||
|
||||
if (!filename) throw new AppError('Filename is required', 400);
|
||||
if (!password) throw new AppError('Decryption password is required', 400);
|
||||
|
||||
const { results: configResults } = await c.env.DB.prepare("SELECT * FROM webdav_configs LIMIT 1").all();
|
||||
if (configResults.length === 0) throw new AppError('No WebDAV config found', 404);
|
||||
const configRow: any = configResults[0];
|
||||
const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
|
||||
const webdavPassword = await decryptField(configRow.password, key);
|
||||
const saveDir = configRow.save_dir || '/';
|
||||
|
||||
const client = getWebDavClient({ url: configRow.url, username: configRow.username, password: webdavPassword });
|
||||
|
||||
// 1. 下载文件
|
||||
// 拼接完整路径,因为 filename 只是文件名
|
||||
const fullPath = saveDir.endsWith('/') ? `${saveDir}${filename}` : `${saveDir}/${filename}`;
|
||||
const content = await client.getFileContents(fullPath, { format: 'text' });
|
||||
|
||||
// 2. 使用用户密码解密
|
||||
let accounts = [];
|
||||
try {
|
||||
const backupFile = JSON.parse(content as string);
|
||||
// 兼容性检查:如果是旧版直接上传的 KV 数据(没有 data 字段),则无法用密码解密,直接报错
|
||||
if (!backupFile.data) throw new Error('Legacy format');
|
||||
|
||||
const decrypted = await decryptData(backupFile.data, password);
|
||||
accounts = decrypted.accounts || [];
|
||||
} catch (e) {
|
||||
throw new AppError('解密失败:密码错误或文件格式不兼容', 400);
|
||||
}
|
||||
|
||||
// 3. 覆盖写入 D1 (先清空,再批量插入)
|
||||
await c.env.DB.prepare("DELETE FROM accounts").run();
|
||||
|
||||
const stmt = c.env.DB.prepare(
|
||||
`INSERT INTO accounts (id, service, account, category, secret, digits, period, created_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
const batch = [];
|
||||
for (const acc of accounts) {
|
||||
const secretEncrypted = await encryptField(acc.secret, key);
|
||||
batch.push(stmt.bind(
|
||||
crypto.randomUUID(), sanitizeInput(acc.service, 50), sanitizeInput(acc.account, 100),
|
||||
acc.category || '', secretEncrypted, acc.digits || 6, acc.period || 30, Date.now(), 'restore'
|
||||
));
|
||||
}
|
||||
if (batch.length > 0) await c.env.DB.batch(batch);
|
||||
|
||||
return c.json({ success: true, message: 'Restore successful' });
|
||||
});
|
||||
|
||||
export default webdav;
|
||||
@@ -0,0 +1,109 @@
|
||||
import { D1Database } from '@cloudflare/workers-types';
|
||||
import { encryptData, decryptData } from './crypto';
|
||||
import { sanitizeInput } from './helper';
|
||||
|
||||
// 加密并序列化字段
|
||||
export async function encryptField(data: any, key: string) {
|
||||
const encrypted = await encryptData(data, key);
|
||||
return JSON.stringify(encrypted);
|
||||
}
|
||||
|
||||
// 反序列化并解密字段
|
||||
export async function decryptField(encryptedStr: string, key: string) {
|
||||
try {
|
||||
const encryptedObj = JSON.parse(encryptedStr);
|
||||
return await decryptData(encryptedObj, key);
|
||||
} catch (e) {
|
||||
console.error('Decryption failed', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量插入账号 (数据清洗 + 并行加密 + 分批写入)
|
||||
* @param db D1 数据库实例
|
||||
* @param accounts 待插入的原始账号列表
|
||||
* @param key 加密密钥
|
||||
* @param createdBy 创建者标识 (username 或 'restore')
|
||||
* @returns 成功插入的数量
|
||||
*/
|
||||
export async function batchInsertAccounts(
|
||||
db: D1Database,
|
||||
accounts: any[],
|
||||
key: string,
|
||||
createdBy: string
|
||||
): Promise<number> {
|
||||
// 1. 准备数据 (规范化、加密、生成ID)
|
||||
const preparedItems = await Promise.all(accounts.map(async (acc) => {
|
||||
// 规范化密钥 (去除空格,转大写)
|
||||
const normalizedSecret = (acc.secret || '').replace(/\s/g, '').toUpperCase();
|
||||
const secretEncrypted = await encryptField(normalizedSecret, key);
|
||||
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
service: sanitizeInput(acc.service, 50),
|
||||
account: sanitizeInput(acc.account, 100),
|
||||
category: acc.category ? sanitizeInput(acc.category, 30) : '',
|
||||
secretEncrypted,
|
||||
digits: acc.digits || 6,
|
||||
period: acc.period || 30,
|
||||
created_at: Date.now(),
|
||||
created_by: createdBy
|
||||
};
|
||||
}));
|
||||
|
||||
// 2. 构造插入语句
|
||||
const stmt = db.prepare(
|
||||
`INSERT INTO accounts (id, service, account, category, secret, digits, period, created_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
|
||||
// 3. 分批写入 (每批 100 条)
|
||||
const BATCH_SIZE = 100;
|
||||
for (let i = 0; i < preparedItems.length; i += BATCH_SIZE) {
|
||||
const chunk = preparedItems.slice(i, i + BATCH_SIZE);
|
||||
const batch = chunk.map(item => stmt.bind(
|
||||
item.id, item.service, item.account, item.category, item.secretEncrypted,
|
||||
item.digits, item.period, item.created_at, item.created_by
|
||||
));
|
||||
|
||||
if (batch.length > 0) await db.batch(batch);
|
||||
}
|
||||
|
||||
return preparedItems.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除账号 (分批 + 并行)
|
||||
* @param db D1 数据库实例
|
||||
* @param ids 待删除的账号 ID 列表
|
||||
* @returns 成功删除的数量
|
||||
*/
|
||||
export async function batchDeleteAccounts(
|
||||
db: D1Database,
|
||||
ids: string[]
|
||||
): Promise<number> {
|
||||
if (!ids || ids.length === 0) return 0;
|
||||
|
||||
// 优化:使用 IN 子句 + db.batch() 并行执行,减少 SQL 解析与网络 RTT
|
||||
const CHUNK_SIZE = 50;
|
||||
let deletedCount = 0;
|
||||
const statements = [];
|
||||
|
||||
for (let i = 0; i < ids.length; i += CHUNK_SIZE) {
|
||||
const chunk = ids.slice(i, i + CHUNK_SIZE);
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
const query = `DELETE FROM accounts WHERE id IN (${placeholders})`;
|
||||
statements.push(db.prepare(query).bind(...chunk));
|
||||
}
|
||||
|
||||
// D1 batch 限制每次最多 128 条语句
|
||||
const BATCH_LIMIT = 100;
|
||||
for (let i = 0; i < statements.length; i += BATCH_LIMIT) {
|
||||
const batchChunk = statements.slice(i, i + BATCH_LIMIT);
|
||||
const results = await db.batch(batchChunk);
|
||||
results.forEach(res => deletedCount += (res.meta.changes || 0));
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
@@ -16,6 +16,8 @@
|
||||
"vue-router": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@types/node": "^25.3.0",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
<template>
|
||||
<div class="account-list-wrapper" v-loading="loading" element-loading-text="数据加载中..." style="min-height: 400px;">
|
||||
<div v-if="!loading && accounts.length === 0 && !searchQuery" class="empty-state">
|
||||
<el-empty description="空空如也,快去添加你的第一个 2FA 账号吧!">
|
||||
<el-button type="primary" @click="$emit('switch-tab', 'add-account')">去添加账号</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="toolbar" style="margin-bottom: 20px; display: flex; gap: 15px; align-items: center; justify-content: space-between; flex-wrap: wrap;">
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="🔍 搜索服务名称、账号或分类..."
|
||||
clearable
|
||||
style="max-width: 400px; flex: 1;"
|
||||
/>
|
||||
|
||||
<div class="batch-actions" v-if="selectedIds.length > 0" style="display: flex; align-items: center; gap: 10px;">
|
||||
<span style="color: #606266; font-size: 14px;">已选择 {{ selectedIds.length }} 项</span>
|
||||
<el-button type="danger" plain @click="handleBulkDelete" :loading="isBulkDeleting">
|
||||
<el-icon><Delete /></el-icon> 批量删除
|
||||
</el-button>
|
||||
<el-button @click="selectedIds = []" plain>取消选择</el-button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<el-button @click="selectAllVisible" plain>全选本页</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="20" v-if="accounts.length > 0">
|
||||
<el-col :xs="24" :sm="12" :md="8" :lg="6" v-for="account in accounts" :key="account.id" style="margin-bottom: 20px;">
|
||||
<el-card class="account-card" :class="{ 'is-selected': selectedIds.includes(account.id) }" shadow="hover">
|
||||
<div class="card-header">
|
||||
<div class="service-info" style="display: flex; align-items: center; gap: 10px;">
|
||||
<el-checkbox :model-value="selectedIds.includes(account.id)" @change="toggleSelection(account.id)" @click.stop />
|
||||
<h3 class="service-name">{{ account.service }}</h3>
|
||||
<el-tag size="small" v-if="account.category" effect="light">{{ account.category }}</el-tag>
|
||||
</div>
|
||||
|
||||
<el-dropdown trigger="click" @command="(cmd) => handleCommand(cmd, account)">
|
||||
<el-icon class="more-icon"><MoreFilled /></el-icon>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="edit">
|
||||
<el-icon><Edit /></el-icon> 编辑账号
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="delete" style="color: #F56C6C;">
|
||||
<el-icon><Delete /></el-icon> 删除账号
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
|
||||
<p class="account-name" style="margin-left: 24px;">{{ account.account }}</p>
|
||||
|
||||
<div class="card-actions">
|
||||
<el-button type="primary" class="code-btn" @click="showTotpCode(account)">
|
||||
<el-icon><Key /></el-icon> 获取验证码
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<div class="pagination-wrapper" v-if="total > 0" style="margin-top: 10px; display: flex; justify-content: center;">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[12, 24, 48, 96]"
|
||||
background
|
||||
:layout="layoutState.isMobile ? 'prev, pager, next' : 'total, sizes, prev, pager, next, jumper'"
|
||||
:small="layoutState.isMobile"
|
||||
:total="total"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!loading && accounts.length === 0 && searchQuery" description="没有找到匹配的账号" />
|
||||
</div>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<el-dialog v-model="showEditDialog" title="✏️ 编辑账号" :width="layoutState.isMobile ? '90%' : '400px'" destroy-on-close>
|
||||
<el-form :model="editAccountData" label-position="top">
|
||||
<el-form-item label="服务名称 (如 Google, GitHub)">
|
||||
<el-input v-model="editAccountData.service" />
|
||||
</el-form-item>
|
||||
<el-form-item label="账号标识 (如 邮箱地址)">
|
||||
<el-input v-model="editAccountData.account" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分类 (可选)">
|
||||
<el-input v-model="editAccountData.category" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="showEditDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="isEditing" @click="submitEditAccount">保存修改</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- TOTP 弹窗 -->
|
||||
<el-dialog v-model="showTotpDialog" :title="currentTotpAccount?.account" width="300px" center align-center @closed="stopTotpTimer" destroy-on-close>
|
||||
<div class="totp-container">
|
||||
<div class="totp-service">{{ currentTotpAccount?.service }}</div>
|
||||
<div class="totp-code" @click="copyCode">{{ currentTotpCode }}</div>
|
||||
<div class="totp-timer">
|
||||
<el-progress type="circle" :percentage="totpPercentage" :width="80" :stroke-width="6" :color="totpColor">
|
||||
<template #default>
|
||||
<span class="timer-text">{{ totpRemaining }}s</span>
|
||||
</template>
|
||||
</el-progress>
|
||||
</div>
|
||||
<div class="totp-tip">点击验证码复制</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Key, MoreFilled, Edit, Delete } from '@element-plus/icons-vue'
|
||||
import { request } from '../utils/request'
|
||||
import { layoutState } from '../states/layout'
|
||||
|
||||
const emit = defineEmits(['switch-tab'])
|
||||
|
||||
const accounts = ref([])
|
||||
const loading = ref(true)
|
||||
const total = ref(0)
|
||||
const searchQuery = ref('')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(12)
|
||||
|
||||
// 批量操作状态
|
||||
const selectedIds = ref([])
|
||||
const isBulkDeleting = ref(false)
|
||||
|
||||
// 编辑弹窗
|
||||
const showEditDialog = ref(false)
|
||||
const isEditing = ref(false)
|
||||
const editAccountData = ref({ id: '', service: '', account: '', category: '' })
|
||||
|
||||
// TOTP 弹窗
|
||||
const showTotpDialog = ref(false)
|
||||
const currentTotpAccount = ref(null)
|
||||
const currentTotpCode = ref('------')
|
||||
const totpRemaining = ref(30)
|
||||
let totpInterval = null
|
||||
let lastTotpEpoch = -1
|
||||
let searchTimer = null
|
||||
|
||||
// 获取列表
|
||||
const fetchAccounts = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const query = new URLSearchParams({
|
||||
page: currentPage.value,
|
||||
limit: pageSize.value,
|
||||
search: searchQuery.value
|
||||
}).toString()
|
||||
|
||||
const data = await request(`/api/accounts?${query}`)
|
||||
if (data.success) {
|
||||
accounts.value = data.accounts || []
|
||||
if (data.pagination) total.value = data.pagination.total
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch accounts', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ fetchAccounts })
|
||||
|
||||
// 分页与搜索逻辑
|
||||
const handleSizeChange = (val) => {
|
||||
pageSize.value = val
|
||||
currentPage.value = 1
|
||||
fetchAccounts()
|
||||
}
|
||||
|
||||
const handleCurrentChange = (val) => {
|
||||
currentPage.value = val
|
||||
fetchAccounts()
|
||||
}
|
||||
|
||||
watch(searchQuery, () => {
|
||||
currentPage.value = 1
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => fetchAccounts(), 300)
|
||||
})
|
||||
|
||||
// 批量选择逻辑
|
||||
const toggleSelection = (id) => {
|
||||
const index = selectedIds.value.indexOf(id)
|
||||
if (index > -1) selectedIds.value.splice(index, 1)
|
||||
else selectedIds.value.push(id)
|
||||
}
|
||||
|
||||
const selectAllVisible = () => {
|
||||
accounts.value.forEach(acc => {
|
||||
if (!selectedIds.value.includes(acc.id)) selectedIds.value.push(acc.id)
|
||||
})
|
||||
}
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除选中的 ${selectedIds.value.length} 个账号吗?`, '警告', { type: 'error' })
|
||||
isBulkDeleting.value = true
|
||||
const data = await request('/api/accounts/batch-delete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids: selectedIds.value })
|
||||
})
|
||||
if (data.success) {
|
||||
ElMessage.success(`成功删除了 ${data.count} 个账号`)
|
||||
selectedIds.value = []
|
||||
fetchAccounts()
|
||||
}
|
||||
} catch (e) {} finally { isBulkDeleting.value = false }
|
||||
}
|
||||
|
||||
// 单个账号操作
|
||||
const handleCommand = async (command, account) => {
|
||||
if (command === 'delete') {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除 [${account.service}] 吗?`, '警告', { type: 'error' })
|
||||
const data = await request(`/api/accounts/${account.id}`, { method: 'DELETE' })
|
||||
if (data.success) {
|
||||
ElMessage.success('账号已删除')
|
||||
fetchAccounts()
|
||||
}
|
||||
} catch (e) {}
|
||||
} else if (command === 'edit') {
|
||||
editAccountData.value = { ...account, category: account.category || '' }
|
||||
showEditDialog.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const submitEditAccount = async () => {
|
||||
if (!editAccountData.value.service || !editAccountData.value.account) return ElMessage.warning('必填项不能为空')
|
||||
isEditing.value = true
|
||||
try {
|
||||
const data = await request(`/api/accounts/${editAccountData.value.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
service: editAccountData.value.service,
|
||||
account: editAccountData.value.account,
|
||||
category: editAccountData.value.category
|
||||
})
|
||||
})
|
||||
if (data.success) {
|
||||
ElMessage.success('账号修改成功')
|
||||
showEditDialog.value = false
|
||||
fetchAccounts()
|
||||
}
|
||||
} catch (e) {} finally { isEditing.value = false }
|
||||
}
|
||||
|
||||
// TOTP 计算逻辑
|
||||
const totpPercentage = computed(() => {
|
||||
const period = currentTotpAccount.value?.period || 30
|
||||
return (totpRemaining.value / period) * 100
|
||||
})
|
||||
|
||||
const totpColor = computed(() => {
|
||||
if (totpRemaining.value > 10) return '#67C23A'
|
||||
if (totpRemaining.value > 5) return '#E6A23C'
|
||||
return '#F56C6C'
|
||||
})
|
||||
|
||||
const showTotpCode = (account) => {
|
||||
currentTotpAccount.value = account
|
||||
currentTotpCode.value = '------'
|
||||
lastTotpEpoch = -1
|
||||
showTotpDialog.value = true
|
||||
updateTotpLogic()
|
||||
if (totpInterval) clearInterval(totpInterval)
|
||||
totpInterval = setInterval(updateTotpLogic, 1000)
|
||||
}
|
||||
|
||||
const updateTotpLogic = () => {
|
||||
if (!currentTotpAccount.value) return
|
||||
const period = currentTotpAccount.value.period || 30
|
||||
const now = Date.now() / 1000
|
||||
const epoch = Math.floor(now / period)
|
||||
totpRemaining.value = Math.ceil(period - (now % period))
|
||||
if (epoch > lastTotpEpoch) {
|
||||
fetchTotpCode()
|
||||
lastTotpEpoch = epoch
|
||||
}
|
||||
}
|
||||
|
||||
const fetchTotpCode = async () => {
|
||||
try {
|
||||
const data = await request('/api/accounts/generate-totp', {
|
||||
method: 'POST', body: JSON.stringify({ secret: currentTotpAccount.value.secret })
|
||||
})
|
||||
if (data.success) currentTotpCode.value = data.code
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const stopTotpTimer = () => {
|
||||
if (totpInterval) clearInterval(totpInterval)
|
||||
totpInterval = null
|
||||
}
|
||||
|
||||
const copyCode = async () => {
|
||||
if (currentTotpCode.value === '------') return
|
||||
try {
|
||||
await navigator.clipboard.writeText(currentTotpCode.value)
|
||||
ElMessage.success('验证码已复制')
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
onMounted(fetchAccounts)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.account-card { border-radius: 12px; transition: all 0.3s ease; border: none; }
|
||||
.account-card:hover { transform: translateY(-5px); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1) !important; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px; }
|
||||
.service-name { margin: 0 0 5px 0; font-size: 18px; color: #303133; }
|
||||
.account-name { color: #909399; font-size: 14px; margin: 0 0 20px 0; word-break: break-all; }
|
||||
.more-icon { cursor: pointer; color: #909399; padding: 5px; }
|
||||
.more-icon:hover { color: #409EFF; }
|
||||
.card-actions { display: flex; justify-content: flex-end; }
|
||||
.code-btn { width: 100%; border-radius: 8px; }
|
||||
.account-card.is-selected { border: 1px solid #409EFF; background-color: #f4f9ff; }
|
||||
.totp-container { display: flex; flex-direction: column; align-items: center; padding: 10px 0; }
|
||||
.totp-service { font-size: 14px; color: #909399; margin-bottom: 5px; }
|
||||
.totp-code { font-size: 36px; font-weight: bold; color: #409EFF; letter-spacing: 6px; margin: 15px 0; cursor: pointer; transition: transform 0.1s; }
|
||||
.totp-code:active { transform: scale(0.95); }
|
||||
.totp-timer { margin: 10px 0; }
|
||||
.timer-text { font-size: 16px; font-weight: bold; color: #606266; }
|
||||
.totp-tip { font-size: 12px; color: #909399; margin-top: 10px; }
|
||||
</style>
|
||||
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div class="add-account-wrapper">
|
||||
<div class="tab-card-wrapper">
|
||||
<h2 style="text-align: center; margin-bottom: 20px;">⌨️ 手动输入</h2>
|
||||
<div style="max-width: 600px; margin: 0 auto;">
|
||||
<el-form :model="newAccount" label-position="top" :rules="rules" ref="addFormRef" style="padding: 10px 0;">
|
||||
<el-form-item label="服务名称 (如 Google, GitHub)" prop="service">
|
||||
<el-input v-model="newAccount.service" placeholder="请输入服务名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="账号标识 (如 邮箱地址)" prop="account">
|
||||
<el-input v-model="newAccount.account" placeholder="请输入账号或邮箱" />
|
||||
</el-form-item>
|
||||
<el-form-item label="安全密钥 (Base32格式)" prop="secret">
|
||||
<el-input v-model="newAccount.secret" placeholder="请输入 16 位以上的安全密钥" />
|
||||
</el-form-item>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="代码位数" prop="digits">
|
||||
<el-select v-model="newAccount.digits" style="width: 100%">
|
||||
<el-option label="6 位" :value="6" />
|
||||
<el-option label="8 位" :value="8" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="更新周期" prop="period">
|
||||
<el-select v-model="newAccount.period" style="width: 100%">
|
||||
<el-option label="30 秒" :value="30" />
|
||||
<el-option label="60 秒" :value="60" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="分类 (可选)" prop="category">
|
||||
<el-input v-model="newAccount.category" placeholder="如 工作, 个人" />
|
||||
</el-form-item>
|
||||
<el-form-item style="margin-top: 30px;">
|
||||
<el-button type="primary" :loading="submitting" @click="submitAddAccount" style="width: 100%;" size="large">确认添加</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { request } from '../utils/request'
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
|
||||
const submitting = ref(false)
|
||||
const addFormRef = ref(null)
|
||||
const newAccount = ref({
|
||||
service: '', account: '', secret: '', category: '', digits: 6, period: 30
|
||||
})
|
||||
|
||||
const rules = {
|
||||
service: [{ required: true, message: '请输入服务名称', trigger: 'blur' }],
|
||||
account: [{ required: true, message: '请输入账号标识', trigger: 'blur' }],
|
||||
secret: [
|
||||
{ required: true, message: '请输入安全密钥', trigger: 'blur' },
|
||||
{ min: 16, message: '密钥长度通常不少于 16 位', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
const submitAddAccount = async () => {
|
||||
if (!addFormRef.value) return
|
||||
await addFormRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
submitting.value = true
|
||||
try {
|
||||
const data = await request('/api/accounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(newAccount.value)
|
||||
})
|
||||
if (data.success) {
|
||||
ElMessage.success('账号添加成功!')
|
||||
newAccount.value = { service: '', account: '', secret: '', category: '', digits: 6, period: 30 }
|
||||
emit('success')
|
||||
}
|
||||
} catch (error) {
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tab-card-wrapper {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||
min-height: 400px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div class="add-account-wrapper">
|
||||
<div class="tab-card-wrapper">
|
||||
<h2 style="text-align: center; margin-bottom: 20px;">📷 扫码添加</h2>
|
||||
<div style="max-width: 600px; margin: 0 auto;">
|
||||
<div style="text-align: center; margin-bottom: 20px; margin-top: 10px;">
|
||||
<p style="color: #909399;">请允许浏览器使用摄像头,或直接上传微信/系统截图。</p>
|
||||
</div>
|
||||
<QrScanner @scan-success="handleScanSuccess" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { h } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { request } from '../utils/request'
|
||||
import QrScanner from './QrScanner.vue'
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
|
||||
const handleScanSuccess = async (uri) => {
|
||||
try {
|
||||
const data = await request('/api/accounts/parse-uri', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ uri })
|
||||
})
|
||||
|
||||
if (data.success && data.account) {
|
||||
const acc = data.account
|
||||
await ElMessageBox.confirm(
|
||||
h('div', { style: 'text-align:left; background:#f5f7fa; padding:15px; border-radius:8px;' }, [
|
||||
h('p', [h('strong', '服务方:'), acc.issuer || '未知']),
|
||||
h('p', [h('strong', '账号:'), acc.account || '未知']),
|
||||
h('p', [h('strong', '算法:'), `${acc.algorithm} (${acc.digits}位 / ${acc.period}秒)`])
|
||||
]),
|
||||
'✅ 二维码解析成功,确认添加?',
|
||||
{
|
||||
confirmButtonText: '确认添加',
|
||||
cancelButtonText: '取消',
|
||||
type: 'success'
|
||||
}
|
||||
)
|
||||
|
||||
const addData = await request('/api/accounts/add-from-uri', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ uri, category: '手机扫码' })
|
||||
})
|
||||
|
||||
if (addData.success) {
|
||||
ElMessage.success('扫码账号添加成功!')
|
||||
emit('success')
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err !== 'cancel') console.error(err)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tab-card-wrapper {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||
min-height: 400px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,335 @@
|
||||
<template>
|
||||
<div class="backup-container">
|
||||
<!-- 顶部操作 -->
|
||||
<div class="header-actions">
|
||||
<h3>备份源管理</h3>
|
||||
<el-button type="primary" @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon> 添加备份源
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 列表区域 -->
|
||||
<el-row :gutter="20" v-loading="isLoading">
|
||||
<el-col :xs="24" :sm="12" :md="8" v-for="provider in providers" :key="provider.id" style="margin-bottom: 20px;">
|
||||
<el-card shadow="hover" class="provider-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="provider-title">
|
||||
<el-tag size="small" effect="dark" :type="getProviderTypeTag(provider.type)">{{ provider.type.toUpperCase() }}</el-tag>
|
||||
<span class="provider-name">{{ provider.name }}</span>
|
||||
</div>
|
||||
<div class="provider-actions">
|
||||
<el-button link type="primary" @click="editProvider(provider)"><el-icon><Edit /></el-icon></el-button>
|
||||
<el-button link type="danger" @click="deleteProvider(provider)"><el-icon><Delete /></el-icon></el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="card-content">
|
||||
<p class="status-text">
|
||||
上次备份:
|
||||
<span v-if="provider.last_backup_at">{{ new Date(provider.last_backup_at).toLocaleString() }}</span>
|
||||
<span v-else>从未</span>
|
||||
<el-tag v-if="provider.last_backup_status" size="small" :type="provider.last_backup_status === 'success' ? 'success' : 'danger'" style="margin-left: 5px;">
|
||||
{{ provider.last_backup_status }}
|
||||
</el-tag>
|
||||
</p>
|
||||
|
||||
<div class="action-buttons">
|
||||
<el-button type="success" plain size="small" @click="openBackupDialog(provider)">立即备份</el-button>
|
||||
<el-button type="warning" plain size="small" @click="openRestoreDialog(provider)">恢复数据</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="24" v-if="providers.length === 0">
|
||||
<el-empty description="暂无备份源,请点击右上角添加" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<el-dialog v-model="showConfigDialog" :title="isEditing ? '编辑备份源' : '添加备份源'" width="500px" destroy-on-close>
|
||||
<el-form :model="form" label-position="top" ref="formRef">
|
||||
<el-form-item label="类型">
|
||||
<el-select v-model="form.type" :disabled="isEditing">
|
||||
<el-option label="WebDAV" value="webdav" />
|
||||
<el-option label="S3 对象存储" value="s3" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="名称 (别名)">
|
||||
<el-input v-model="form.name" placeholder="例如: 我的坚果云" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- WebDAV 配置 -->
|
||||
<template v-if="form.type === 'webdav'">
|
||||
<el-form-item label="WebDAV 地址">
|
||||
<el-input v-model="form.config.url" placeholder="https://dav.jianguoyun.com/dav/" />
|
||||
</el-form-item>
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="form.config.username" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input v-model="form.config.password" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="保存目录">
|
||||
<el-input v-model="form.config.saveDir" placeholder="/2fauth-backups" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<!-- S3 配置 -->
|
||||
<template v-if="form.type === 's3'">
|
||||
<el-form-item label="Endpoint (API 地址)">
|
||||
<el-input v-model="form.config.endpoint" placeholder="https://<account>.r2.cloudflarestorage.com" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Bucket (存储桶名称)">
|
||||
<el-input v-model="form.config.bucket" placeholder="my-backup-bucket" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Region (区域)">
|
||||
<el-input v-model="form.config.region" placeholder="auto 或 us-east-1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Access Key ID">
|
||||
<el-input v-model="form.config.accessKeyId" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Secret Access Key">
|
||||
<el-input v-model="form.config.secretAccessKey" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="存储路径前缀 (可选)">
|
||||
<el-input v-model="form.config.saveDir" placeholder="backups/" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="testConnection" :loading="isTesting">测试连接</el-button>
|
||||
<el-button type="primary" @click="saveProvider" :loading="isSaving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 备份弹窗 -->
|
||||
<el-dialog v-model="showBackupDialog" title="加密备份" width="400px">
|
||||
<el-alert title="数据安全" type="info" description="请输入加密密码。此密码用于加密备份文件内容,请务必牢记!" show-icon :closable="false" style="margin-bottom: 15px;" />
|
||||
<el-input v-model="backupPassword" type="password" show-password placeholder="输入加密密码" />
|
||||
<template #footer>
|
||||
<el-button @click="showBackupDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleBackup" :loading="isBackingUp">开始备份</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 恢复列表弹窗 -->
|
||||
<el-dialog v-model="showRestoreListDialog" title="选择备份文件恢复" width="600px">
|
||||
<el-table :data="backupFiles" v-loading="isLoadingFiles" height="300px" style="width: 100%">
|
||||
<el-table-column prop="filename" label="文件名" show-overflow-tooltip />
|
||||
<el-table-column label="大小" width="100">
|
||||
<template #default="scope">
|
||||
{{ formatSize(scope.row.size) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="lastModified" label="时间" width="180" />
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="scope">
|
||||
<el-button link type="primary" @click="selectRestoreFile(scope.row)">恢复</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 恢复确认弹窗 -->
|
||||
<el-dialog v-model="showRestoreConfirmDialog" title="解密恢复" width="400px">
|
||||
<el-alert title="警告" type="warning" description="恢复操作将覆盖当前所有数据!" show-icon :closable="false" style="margin-bottom: 15px;" />
|
||||
<el-input v-model="restorePassword" type="password" show-password placeholder="输入备份时的加密密码" />
|
||||
<template #footer>
|
||||
<el-button @click="showRestoreConfirmDialog = false">取消</el-button>
|
||||
<el-button type="danger" @click="handleRestore" :loading="isRestoring">确认覆盖恢复</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete } from '@element-plus/icons-vue'
|
||||
import { request } from '../utils/request'
|
||||
|
||||
const emit = defineEmits(['restore-success'])
|
||||
|
||||
const providers = ref([])
|
||||
const isLoading = ref(false)
|
||||
|
||||
// 配置弹窗状态
|
||||
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: '' } })
|
||||
const currentProviderId = ref(null)
|
||||
|
||||
// 备份/恢复状态
|
||||
const showBackupDialog = ref(false)
|
||||
const backupPassword = ref('')
|
||||
const isBackingUp = 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)
|
||||
|
||||
const fetchProviders = async () => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await request('/api/backups/providers')
|
||||
if (res.success) providers.value = res.providers
|
||||
} finally { isLoading.value = false }
|
||||
}
|
||||
|
||||
const getProviderTypeTag = (type) => type === 'webdav' ? 'primary' : (type === 's3' ? 'warning' : 'info')
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
if (!bytes) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const openAddDialog = () => {
|
||||
isEditing.value = false
|
||||
form.value = { type: 'webdav', name: '', config: { url: '', username: '', password: '', saveDir: '/', endpoint: '', bucket: '', region: 'auto', accessKeyId: '', secretAccessKey: '' } }
|
||||
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
|
||||
}))
|
||||
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'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
const saveProvider = async () => {
|
||||
const error = validateForm()
|
||||
if (error) return ElMessage.warning(error)
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
const deleteProvider = async (provider) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除该备份源吗?', '警告', { type: 'warning' })
|
||||
await request(`/api/backups/providers/${provider.id}`, { method: 'DELETE' })
|
||||
fetchProviders()
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const openBackupDialog = (provider) => {
|
||||
currentActionProvider.value = provider
|
||||
backupPassword.value = ''
|
||||
showBackupDialog.value = true
|
||||
}
|
||||
|
||||
const handleBackup = async () => {
|
||||
if (backupPassword.value.length < 12) return ElMessage.warning('密码至少12位')
|
||||
isBackingUp.value = true
|
||||
try {
|
||||
const res = await request(`/api/backups/providers/${currentActionProvider.value.id}/backup`, {
|
||||
method: 'POST', body: JSON.stringify({ password: backupPassword.value })
|
||||
})
|
||||
if (res.success) {
|
||||
ElMessage.success('备份成功')
|
||||
showBackupDialog.value = false
|
||||
fetchProviders() // Refresh status
|
||||
}
|
||||
} catch (e) {} finally { isBackingUp.value = false }
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
const selectRestoreFile = (file) => {
|
||||
selectedFile.value = file
|
||||
restorePassword.value = ''
|
||||
showRestoreConfirmDialog.value = true
|
||||
}
|
||||
|
||||
const handleRestore = async () => {
|
||||
isRestoring.value = true
|
||||
try {
|
||||
const res = await request(`/api/backups/providers/${currentActionProvider.value.id}/restore`, {
|
||||
method: 'POST', body: JSON.stringify({
|
||||
filename: selectedFile.value.filename,
|
||||
password: restorePassword.value
|
||||
})
|
||||
})
|
||||
if (res.success) {
|
||||
ElMessage.success('恢复成功')
|
||||
showRestoreConfirmDialog.value = false
|
||||
showRestoreListDialog.value = false
|
||||
emit('restore-success')
|
||||
}
|
||||
} catch (e) {} finally { isRestoring.value = false }
|
||||
}
|
||||
|
||||
onMounted(fetchProviders)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header-actions { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.provider-card { height: 100%; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.provider-title { display: flex; align-items: center; gap: 10px; font-weight: bold; }
|
||||
.status-text { font-size: 13px; color: #666; margin-bottom: 15px; }
|
||||
.action-buttons { display: flex; gap: 10px; }
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div class="data-backup-wrapper">
|
||||
<div class="tab-card-wrapper">
|
||||
<div style="text-align: center; margin-bottom: 30px;">
|
||||
<h2>多源备份中心</h2>
|
||||
<p style="color: #909399;">配置多个备份源(WebDAV, S3等),确保数据万无一失。</p>
|
||||
</div>
|
||||
<BackupSettings @restore-success="$emit('success')" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import BackupSettings from './BackupSettings.vue'
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tab-card-wrapper {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||
min-height: 400px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div class="data-export-wrapper">
|
||||
<div class="tab-card-wrapper">
|
||||
<div style="text-align: center; margin-bottom: 30px;">
|
||||
<h2>数据导出</h2>
|
||||
<p style="color: #909399;">将当前金库中的所有账号打包,并使用您设置的密码进行 AES-GCM 强加密。</p>
|
||||
</div>
|
||||
|
||||
<div style="max-width: 500px; margin: 0 auto;">
|
||||
<el-alert title="🛡️ 极密保护" type="warning" description="为保护您的资产,所有导出数据必须强制加密!" show-icon :closable="false" style="margin-bottom: 20px;" />
|
||||
|
||||
<div style="text-align: center; margin-top: 40px;">
|
||||
<el-button type="warning" size="large" style="width: 100%;" @click="showExportDialog = true">
|
||||
<el-icon><Lock /></el-icon> 设置密码并导出
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="showExportDialog" 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="showExportDialog = false">取消</el-button>
|
||||
<el-button type="warning" :loading="isExporting" @click="handleExport">开始加密并下载</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Lock } from '@element-plus/icons-vue'
|
||||
import { request } from '../utils/request'
|
||||
|
||||
const showExportDialog = ref(false)
|
||||
const isExporting = ref(false)
|
||||
const exportForm = ref({ password: '', confirm: '' })
|
||||
const loadingText = ref('')
|
||||
|
||||
const handleExport = async () => {
|
||||
if (exportForm.value.password !== exportForm.value.confirm) {
|
||||
return ElMessage.error('两次输入的密码不一致!')
|
||||
}
|
||||
if (exportForm.value.password.length < 12) {
|
||||
return ElMessage.error('密码太弱!至少需要 12 个字符。')
|
||||
}
|
||||
|
||||
loadingText.value = '正在进行高强度 AES-GCM 加密...'
|
||||
isExporting.value = true
|
||||
try {
|
||||
const data = await request('/api/accounts/export-secure', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password: exportForm.value.password })
|
||||
})
|
||||
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `2fa-backup-encrypted-${new Date().toISOString().split('T')[0]}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
ElMessage.success('🎉 加密备份导出成功!请妥善保管好您的密码!')
|
||||
showExportDialog.value = false
|
||||
exportForm.value = { password: '', confirm: '' }
|
||||
} catch (error) {} finally { isExporting.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tab-card-wrapper {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||
min-height: 400px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div class="data-import-wrapper">
|
||||
<div class="tab-card-wrapper">
|
||||
<div style="text-align: center; margin-bottom: 30px;">
|
||||
<h2>数据导入</h2>
|
||||
<p style="color: #909399;">支持从各类 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')">📝 纯文本 URI (.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 { request } from '../utils/request'
|
||||
|
||||
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('请输入解密密码')
|
||||
}
|
||||
loadingText.value = currentImportType.value === 'encrypted' ? '正在解密并导入数据...' : '正在导入数据...'
|
||||
isImporting.value = true
|
||||
try {
|
||||
const data = await request('/api/accounts/import', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
type: currentImportType.value,
|
||||
content: currentFileContent.value,
|
||||
password: importPassword.value
|
||||
})
|
||||
})
|
||||
if (data.success) {
|
||||
let msg = `✅ 成功导入 ${data.count} 个账户!`
|
||||
if (data.duplicates > 0) msg += ` (自动跳过了 ${data.duplicates} 个重复账户)`
|
||||
ElMessage.success({ message: msg, duration: 5000 })
|
||||
showDecryptDialog.value = false
|
||||
emit('success')
|
||||
}
|
||||
} catch (error) {} finally { isImporting.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tab-card-wrapper {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||
min-height: 400px;
|
||||
}
|
||||
.import-options { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-top: 20px; }
|
||||
.import-options .el-button { margin: 0; justify-content: flex-start; padding-left: 20px; height: auto; padding-top: 15px; padding-bottom: 15px; }
|
||||
@media (max-width: 768px) { .import-options { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -1,259 +0,0 @@
|
||||
<template>
|
||||
<div class="migration-container">
|
||||
<el-row :gutter="30">
|
||||
|
||||
<el-col :xs="24" :md="10" style="margin-bottom: 20px;">
|
||||
<el-card class="action-card export-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header"><el-icon><Upload /></el-icon> <span>数据导出</span></div>
|
||||
</template>
|
||||
<div class="card-body">
|
||||
<el-alert title="🛡️ 极密保护" type="warning" description="为保护您的资产,所有导出数据必须强制加密!" show-icon :closable="false" style="margin-bottom: 20px;" />
|
||||
<p class="desc">将当前金库中的所有账号打包,并使用您设置的密码进行 AES-GCM 强加密。</p>
|
||||
<el-button type="warning" size="large" class="full-btn" @click="showExportDialog = true">
|
||||
<el-icon><Lock /></el-icon> 设置密码并导出
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="24" :md="14">
|
||||
<el-card class="action-card import-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header"><el-icon><Download /></el-icon> <span>数据导入</span></div>
|
||||
</template>
|
||||
<div class="card-body" v-loading="isImporting && !showDecryptDialog" :element-loading-text="loadingText">
|
||||
<p class="desc">支持从各类 2FA 软件或本系统的备份文件中恢复数据。导入时会自动跳过已存在的重复账号。</p>
|
||||
<div class="import-options">
|
||||
<el-button plain @click="triggerImport('encrypted', '.json')">🔒 本系统加密备份 (.json)</el-button>
|
||||
<el-button plain @click="triggerImport('json', '.json')">📄 标准 JSON / 2FAuth (.json)</el-button>
|
||||
<el-button plain @click="triggerImport('2fas', '.2fas,.json')">📱 2FAS 备份 (.2fas)</el-button>
|
||||
<el-button plain @click="triggerImport('text', '.txt')">📝 纯文本 URI (.txt)</el-button>
|
||||
</div>
|
||||
<input type="file" ref="fileInputRef" :accept="acceptType" style="display: none" @change="handleFileUpload" />
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
</el-row>
|
||||
|
||||
<el-dialog v-model="showExportDialog" 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="showExportDialog = false">取消</el-button>
|
||||
<el-button type="warning" :loading="isExporting" @click="handleExport">开始加密并下载</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<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 { Upload, Download, Lock } from '@element-plus/icons-vue'
|
||||
import { request } from '../utils/request'
|
||||
|
||||
// 告诉老爸组件 (Home.vue) 导入成功了,赶紧刷新列表
|
||||
const emit = defineEmits(['import-success'])
|
||||
|
||||
// 导出相关状态
|
||||
const showExportDialog = ref(false)
|
||||
const isExporting = ref(false)
|
||||
const exportForm = ref({ password: '', confirm: '' })
|
||||
const loadingText = ref('')
|
||||
|
||||
// 导入相关状态
|
||||
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)
|
||||
|
||||
// ==========================================
|
||||
// 1. 导出逻辑
|
||||
// ==========================================
|
||||
const handleExport = async () => {
|
||||
if (exportForm.value.password !== exportForm.value.confirm) {
|
||||
return ElMessage.error('两次输入的密码不一致!')
|
||||
}
|
||||
if (exportForm.value.password.length < 12) {
|
||||
return ElMessage.error('密码太弱!至少需要 12 个字符。')
|
||||
}
|
||||
|
||||
loadingText.value = '正在进行高强度 AES-GCM 加密...'
|
||||
isExporting.value = true
|
||||
try {
|
||||
const data = await request('/api/accounts/export-secure', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password: exportForm.value.password })
|
||||
})
|
||||
|
||||
// 纯前端触发文件下载
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `2fa-backup-encrypted-${new Date().toISOString().split('T')[0]}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
ElMessage.success('🎉 加密备份导出成功!请妥善保管好您的密码!')
|
||||
showExportDialog.value = false
|
||||
exportForm.value = { password: '', confirm: '' }
|
||||
} catch (error) {
|
||||
// 错误已被 request 拦截器处理
|
||||
} finally {
|
||||
isExporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 2. 导入逻辑
|
||||
// ==========================================
|
||||
const triggerImport = (type, accept) => {
|
||||
currentImportType.value = type
|
||||
acceptType.value = accept
|
||||
// 必须用 setTimeout 等待 DOM 更新 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('请输入解密密码')
|
||||
}
|
||||
|
||||
loadingText.value = currentImportType.value === 'encrypted' ? '正在解密并导入数据...' : '正在导入数据...'
|
||||
isImporting.value = true
|
||||
try {
|
||||
const data = await request('/api/accounts/import', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
type: currentImportType.value,
|
||||
content: currentFileContent.value,
|
||||
password: importPassword.value
|
||||
})
|
||||
})
|
||||
|
||||
if (data.success) {
|
||||
let msg = `✅ 成功导入 ${data.count} 个账户!`
|
||||
if (data.duplicates > 0) msg += ` (自动跳过了 ${data.duplicates} 个重复账户)`
|
||||
ElMessage.success({ message: msg, duration: 5000 })
|
||||
|
||||
showDecryptDialog.value = false
|
||||
emit('import-success') // 通知 Home.vue 刷新列表并切回 Tab
|
||||
}
|
||||
} catch (error) {
|
||||
} finally {
|
||||
isImporting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.migration-container {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
height: 100%;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.desc {
|
||||
color: #606266;
|
||||
margin-bottom: 25px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.full-btn {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.import-options {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 15px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.import-options .el-button {
|
||||
margin: 0;
|
||||
justify-content: flex-start;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.import-options {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,303 +0,0 @@
|
||||
<template>
|
||||
<div class="webdav-container">
|
||||
<el-row :gutter="30">
|
||||
|
||||
<el-col :xs="24" :md="10" style="margin-bottom: 20px;">
|
||||
<el-card class="config-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header" @click="hasConfig ? showConfig = !showConfig : null" :style="{ cursor: hasConfig ? 'pointer' : 'default', justifyContent: 'space-between' }">
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<el-icon><Setting /></el-icon> <span>WebDAV 配置</span>
|
||||
</div>
|
||||
<el-icon v-if="hasConfig">
|
||||
<ArrowUp v-if="showConfig" />
|
||||
<ArrowDown v-else />
|
||||
</el-icon>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-show="showConfig">
|
||||
<el-alert title="数据主权" type="info" description="配置您的私有云盘。所有备份上传前均会进行高强度 AES-GCM 加密,即使网盘被攻破,数据也绝对安全。" show-icon :closable="false" style="margin-bottom: 20px;" />
|
||||
|
||||
<el-form :model="config" label-position="top" v-loading="isTesting || isSaving" :element-loading-text="loadingText">
|
||||
<el-form-item label="WebDAV 地址">
|
||||
<el-input v-model="config.url" placeholder="https://your-server.com/remote.php/webdav/" clearable />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="config.username" placeholder="输入网盘账号" clearable />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="密码或应用授权码">
|
||||
<el-input v-model="config.password" type="password" show-password placeholder="强烈建议使用应用专用密码" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="网盘保存目录">
|
||||
<el-input v-model="config.saveDir" placeholder="/2fauth-backups" />
|
||||
<div class="form-tip">系统会自动在此目录下创建按日期分类的文件夹</div>
|
||||
</el-form-item>
|
||||
|
||||
<div class="action-buttons">
|
||||
<el-button type="info" plain :loading="isTesting" @click="testConnection">
|
||||
<el-icon><Link /></el-icon> 测试连接
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="isSaving" @click="saveConfig">
|
||||
<el-icon><Select /></el-icon> 保存配置
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="24" :md="14">
|
||||
<el-card class="backup-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header" style="justify-content: space-between;">
|
||||
<div><el-icon><Cloudy /></el-icon> <span>云端备份库</span></div>
|
||||
<el-button type="success" :loading="isExporting" @click="showExportDialog = true" :disabled="!hasConfig">
|
||||
<el-icon><Upload /></el-icon> 立即备份到云端
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="!hasConfig" class="empty-tip">
|
||||
<el-empty description="请先在左侧保存 WebDAV 配置" :image-size="100" />
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div style="margin-bottom: 15px; display: flex; justify-content: space-between;">
|
||||
<span style="font-weight: 600; color: #606266;">云端文件列表</span>
|
||||
<el-button size="small" circle @click="loadBackups" :loading="isLoadingList" title="刷新列表">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="backupList" v-loading="isLoadingList" style="width: 100%" height="300" empty-text="没有备份文件存在,请先备份.">
|
||||
<el-table-column prop="filename" label="文件名" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="lastModified" label="修改时间" width="160">
|
||||
<template #default="scope">
|
||||
{{ scope.row.lastModified ? new Date(scope.row.lastModified).toLocaleString() : '未知' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="size" label="大小" width="100">
|
||||
<template #default="scope">
|
||||
{{ formatSize(scope.row.size) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140" fixed="right" align="center">
|
||||
<template #default="scope">
|
||||
<el-button type="primary" link @click="triggerRestore(scope.row)">恢复</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
</el-row>
|
||||
|
||||
<el-dialog v-model="showExportDialog" title="设置云端加密密码" width="400px" destroy-on-close>
|
||||
<el-alert title="极其重要!" type="error" description="系统不会保存此密码!一旦遗忘,云端的备份将变成一堆无法解密的乱码,神仙难救!" show-icon :closable="false" style="margin-bottom: 20px;" />
|
||||
<el-form label-position="top" v-loading="isExporting" :element-loading-text="loadingText">
|
||||
<el-form-item label="加密密码 (至少 12 位)">
|
||||
<el-input v-model="exportPassword" type="password" show-password placeholder="请输入高强度密码" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showExportDialog = false">取消</el-button>
|
||||
<el-button type="success" :loading="isExporting" @click="handleExportToWebDAV">加密并上传</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="showRestoreDialog" title="🔓 从云端恢复" width="400px" destroy-on-close>
|
||||
<el-alert title="高危操作" type="warning" description="恢复成功后,将清空覆盖当前金库里的所有数据!" show-icon :closable="false" style="margin-bottom: 15px;" />
|
||||
<el-form label-position="top" v-loading="isRestoring" :element-loading-text="loadingText">
|
||||
<el-form-item label="请输入该备份的解密密码:">
|
||||
<el-input v-model="restorePassword" type="password" show-password placeholder="输入备份时设置的密码" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showRestoreDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="isRestoring" @click="handleRestore">确认解密覆盖</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, onBeforeUnmount } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Setting, Cloudy, Link, Select, Upload, Refresh, ArrowDown, ArrowUp } from '@element-plus/icons-vue'
|
||||
import { request } from '../utils/request'
|
||||
|
||||
const emit = defineEmits(['restore-success'])
|
||||
|
||||
// 表单与状态
|
||||
const config = ref({ url: '', username: '', password: '', saveDir: '/2fauth-backups' })
|
||||
const backupList = ref([])
|
||||
const hasConfig = computed(() => !!config.value.url && !!config.value.username)
|
||||
|
||||
// Loading 状态
|
||||
const isTesting = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const isLoadingList = ref(false)
|
||||
const isExporting = ref(false)
|
||||
const isRestoring = ref(false)
|
||||
|
||||
// 弹窗状态
|
||||
const showExportDialog = ref(false)
|
||||
const exportPassword = ref('')
|
||||
const showRestoreDialog = ref(false)
|
||||
const restorePassword = ref('')
|
||||
const selectedBackupPath = ref('')
|
||||
const loadingText = ref('')
|
||||
|
||||
// 移动端适配与折叠逻辑
|
||||
const isMobile = ref(window.innerWidth < 768)
|
||||
const showConfig = ref(true)
|
||||
const checkMobile = () => { isMobile.value = window.innerWidth < 768 }
|
||||
|
||||
// ==========================================
|
||||
// 1. 初始化拉取配置
|
||||
// ==========================================
|
||||
onMounted(async () => {
|
||||
window.addEventListener('resize', checkMobile)
|
||||
try {
|
||||
const data = await request('/api/webdav/configs')
|
||||
if (data.success && data.configs && data.configs.length > 0) {
|
||||
// 我们目前先只取第一个配置
|
||||
config.value = { ...config.value, ...data.configs[0] }
|
||||
loadBackups()
|
||||
|
||||
// 移动端且有配置时,自动收起,方便用户直接看到下方的备份列表
|
||||
if (isMobile.value && hasConfig.value) {
|
||||
showConfig.value = false
|
||||
}
|
||||
}
|
||||
} catch (e) { console.error('Load config failed', e) }
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', checkMobile)
|
||||
})
|
||||
|
||||
// ==========================================
|
||||
// 2. 测试与保存配置
|
||||
// ==========================================
|
||||
const testConnection = async () => {
|
||||
if (!config.value.url || !config.value.username || !config.value.password) {
|
||||
return ElMessage.warning('请填写完整的连接信息')
|
||||
}
|
||||
loadingText.value = '正在连接 WebDAV 服务器...'
|
||||
isTesting.value = true
|
||||
try {
|
||||
const data = await request('/api/webdav/test', {
|
||||
method: 'POST', body: JSON.stringify(config.value)
|
||||
})
|
||||
if (data.success) ElMessage.success('🚀 测试成功!您的 WebDAV 服务器已连通!')
|
||||
} catch (e) {
|
||||
} finally { isTesting.value = false }
|
||||
}
|
||||
|
||||
const saveConfig = async () => {
|
||||
if (!config.value.url) return ElMessage.warning('配置不能为空')
|
||||
loadingText.value = '正在保存配置...'
|
||||
isSaving.value = true
|
||||
try {
|
||||
const data = await request('/api/webdav/configs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ configs: [config.value] })
|
||||
})
|
||||
if (data.success) {
|
||||
ElMessage.success('✅ 配置已保存')
|
||||
loadBackups()
|
||||
}
|
||||
} catch (e) {
|
||||
} finally { isSaving.value = false }
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 3. 获取云端列表
|
||||
// ==========================================
|
||||
const loadBackups = async () => {
|
||||
if (!hasConfig.value) return
|
||||
isLoadingList.value = true
|
||||
try {
|
||||
const data = await request('/api/webdav/list', {
|
||||
method: 'POST', body: JSON.stringify(config.value)
|
||||
})
|
||||
if (data.success) {
|
||||
backupList.value = data.backups || []
|
||||
}
|
||||
} catch (e) {
|
||||
} finally { isLoadingList.value = false }
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 4. 一键加密上传
|
||||
// ==========================================
|
||||
const handleExportToWebDAV = async () => {
|
||||
if (exportPassword.value.length < 12) return ElMessage.warning('密码太弱!至少需要 12 位。')
|
||||
loadingText.value = '正在加密并上传至云端...'
|
||||
isExporting.value = true
|
||||
try {
|
||||
const data = await request('/api/webdav/export', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password: exportPassword.value, webdavConfig: config.value })
|
||||
})
|
||||
if (data.success) {
|
||||
ElMessage.success('🎉 备份已加密并安全抵达云端!')
|
||||
showExportDialog.value = false
|
||||
exportPassword.value = ''
|
||||
loadBackups() // 刷新列表
|
||||
}
|
||||
} catch (e) {
|
||||
} finally { isExporting.value = false }
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 5. 恢复数据
|
||||
// ==========================================
|
||||
const triggerRestore = (row) => {
|
||||
selectedBackupPath.value = row.filename
|
||||
restorePassword.value = ''
|
||||
showRestoreDialog.value = true
|
||||
}
|
||||
|
||||
const handleRestore = async () => {
|
||||
if (!restorePassword.value) return ElMessage.warning('请输入解密密码')
|
||||
loadingText.value = '正在下载并解密恢复...'
|
||||
isRestoring.value = true
|
||||
try {
|
||||
const data = await request('/api/webdav/restore', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ filename: selectedBackupPath.value, password: restorePassword.value, webdavConfig: config.value })
|
||||
})
|
||||
if (data.success) {
|
||||
ElMessage.success(`✅ 云端同步完成!数据已恢复。`)
|
||||
showRestoreDialog.value = false
|
||||
emit('restore-success') // 通知父组件刷新金库
|
||||
}
|
||||
} catch (e) {
|
||||
} finally { isRestoring.value = false }
|
||||
}
|
||||
|
||||
// 工具函数:格式化文件大小
|
||||
const formatSize = (bytes) => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024, sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.webdav-container { padding: 10px 0; }
|
||||
.config-card, .backup-card { height: 100%; border-radius: 12px; border: none; }
|
||||
.card-header { font-size: 18px; font-weight: 600; display: flex; align-items: center; gap: 10px; }
|
||||
.form-tip { font-size: 12px; color: #909399; margin-top: 4px; line-height: 1.4; }
|
||||
.action-buttons { display: flex; gap: 15px; margin-top: 25px; }
|
||||
.empty-tip { padding: 40px 0; }
|
||||
:deep(.el-table__row) { cursor: pointer; }
|
||||
</style>
|
||||
+76
-657
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<el-container class="home-container">
|
||||
<el-container class="main-body">
|
||||
<!-- 侧边栏 (Desktop) -->
|
||||
<el-aside width="240px" class="left-aside" v-if="!layoutState.isMobile">
|
||||
<el-menu
|
||||
:default-active="activeTab"
|
||||
@@ -15,18 +16,33 @@
|
||||
<span>我的账户</span>
|
||||
</el-menu-item>
|
||||
|
||||
<el-sub-menu index="tools">
|
||||
<el-sub-menu index="add-account">
|
||||
<template #title>
|
||||
<el-icon><Tools /></el-icon><span>实用工具</span>
|
||||
<el-icon><Plus /></el-icon><span>添加账号</span>
|
||||
</template>
|
||||
<el-menu-item index="add-account">添加账号</el-menu-item>
|
||||
<el-menu-item index="migration">数据迁移</el-menu-item>
|
||||
<el-menu-item index="webdav">数据备份</el-menu-item>
|
||||
<el-menu-item index="add-account-scan">
|
||||
<el-icon><Camera /></el-icon><span>扫码添加</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="add-account-manual">
|
||||
<el-icon><Edit /></el-icon><span>手动输入</span>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<el-sub-menu index="migration">
|
||||
<template #title>
|
||||
<el-icon><Sort /></el-icon><span>数据迁移</span>
|
||||
</template>
|
||||
<el-menu-item index="migration-export"><el-icon><Upload /></el-icon><span>数据导出</span></el-menu-item>
|
||||
<el-menu-item index="migration-import"><el-icon><Download /></el-icon><span>数据导入</span></el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<el-menu-item index="backups">
|
||||
<el-icon><Cloudy /></el-icon><span>多源备份</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<!-- 移动端抽屉菜单 -->
|
||||
<!-- 抽屉菜单 (Mobile) -->
|
||||
<el-drawer
|
||||
v-model="layoutState.showMobileMenu"
|
||||
direction="ltr"
|
||||
@@ -43,284 +59,80 @@
|
||||
<el-icon><Iphone /></el-icon>
|
||||
<span>我的账户</span>
|
||||
</el-menu-item>
|
||||
<el-sub-menu index="tools">
|
||||
<el-sub-menu index="add-account">
|
||||
<template #title>
|
||||
<el-icon><Tools /></el-icon><span>实用工具</span>
|
||||
<el-icon><Plus /></el-icon><span>添加账号</span>
|
||||
</template>
|
||||
<el-menu-item index="add-account">添加账号</el-menu-item>
|
||||
<el-menu-item index="migration">数据迁移</el-menu-item>
|
||||
<el-menu-item index="webdav">数据备份</el-menu-item>
|
||||
<el-menu-item index="add-account-scan">
|
||||
<el-icon><Camera /></el-icon><span>扫码添加</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="add-account-manual">
|
||||
<el-icon><Edit /></el-icon><span>手动输入</span>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
<el-sub-menu index="migration">
|
||||
<template #title>
|
||||
<el-icon><Sort /></el-icon><span>数据迁移</span>
|
||||
</template>
|
||||
<el-menu-item index="migration-export"><el-icon><Upload /></el-icon><span>数据导出</span></el-menu-item>
|
||||
<el-menu-item index="migration-import"><el-icon><Download /></el-icon><span>数据导入</span></el-menu-item>
|
||||
</el-sub-menu>
|
||||
<el-menu-item index="backups">
|
||||
<el-icon><Cloudy /></el-icon><span>多源备份</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-drawer>
|
||||
|
||||
<el-main class="main-content">
|
||||
<!-- 1. 我的账户视图 -->
|
||||
<!-- 视图:我的账户 -->
|
||||
<div v-if="activeTab === 'accounts'" class="view-container">
|
||||
<div class="account-list-wrapper" v-loading="loading" element-loading-text="数据加载中..." style="min-height: 400px;">
|
||||
<div v-if="!loading && accounts.length === 0 && !searchQuery" class="empty-state">
|
||||
<el-empty description="空空如也,快去添加你的第一个 2FA 账号吧!">
|
||||
<el-button type="primary" @click="activeTab = 'add-account'">去添加账号</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="toolbar" style="margin-bottom: 20px; display: flex; gap: 15px; align-items: center; justify-content: space-between; flex-wrap: wrap;">
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="🔍 搜索服务名称、账号或分类..."
|
||||
clearable
|
||||
style="max-width: 400px; flex: 1;"
|
||||
/>
|
||||
|
||||
<div class="batch-actions" v-if="selectedIds.length > 0" style="display: flex; align-items: center; gap: 10px;">
|
||||
<span style="color: #606266; font-size: 14px;">已选择 {{ selectedIds.length }} 项</span>
|
||||
<el-button type="danger" plain @click="handleBulkDelete" :loading="isBulkDeleting">
|
||||
<el-icon><Delete /></el-icon> 批量删除
|
||||
</el-button>
|
||||
<el-button @click="selectedIds = []" plain>取消选择</el-button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<el-button @click="selectAllVisible" plain>全选本页</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="20" v-if="accounts.length > 0">
|
||||
<el-col :xs="24" :sm="12" :md="8" :lg="6" v-for="account in accounts" :key="account.id" style="margin-bottom: 20px;">
|
||||
<el-card class="account-card" :class="{ 'is-selected': selectedIds.includes(account.id) }" shadow="hover">
|
||||
<div class="card-header">
|
||||
<div class="service-info" style="display: flex; align-items: center; gap: 10px;">
|
||||
<el-checkbox :model-value="selectedIds.includes(account.id)" @change="toggleSelection(account.id)" @click.stop />
|
||||
<h3 class="service-name">{{ account.service }}</h3>
|
||||
<el-tag size="small" v-if="account.category" effect="light">{{ account.category }}</el-tag>
|
||||
</div>
|
||||
|
||||
<el-dropdown trigger="click" @command="(cmd) => handleCommand(cmd, account)">
|
||||
<el-icon class="more-icon"><MoreFilled /></el-icon>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="edit">
|
||||
<el-icon><Edit /></el-icon> 编辑账号
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="delete" style="color: #F56C6C;">
|
||||
<el-icon><Delete /></el-icon> 删除账号
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
|
||||
<p class="account-name" style="margin-left: 24px;">{{ account.account }}</p>
|
||||
|
||||
<div class="card-actions">
|
||||
<el-button type="primary" class="code-btn" @click="showTotpCode(account)">
|
||||
<el-icon><Key /></el-icon> 获取验证码
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<div class="pagination-wrapper" v-if="total > 0" style="margin-top: 10px; display: flex; justify-content: center;">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[12, 24, 48, 96]"
|
||||
background
|
||||
:layout="layoutState.isMobile ? 'prev, pager, next' : 'total, sizes, prev, pager, next, jumper'"
|
||||
:small="layoutState.isMobile"
|
||||
:total="total"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!loading && accounts.length === 0 && searchQuery" description="没有找到匹配的账号" />
|
||||
</div>
|
||||
</div>
|
||||
<AccountList ref="accountListRef" @switch-tab="activeTab = $event" />
|
||||
</div>
|
||||
|
||||
<!-- 2. 添加账号视图 (整合扫码与手动) -->
|
||||
<div v-if="activeTab === 'add-account'" class="view-container">
|
||||
<div class="tab-card-wrapper">
|
||||
<h2 style="text-align: center; margin-bottom: 20px;">添加账号</h2>
|
||||
<el-tabs type="border-card" style="max-width: 600px; margin: 0 auto;">
|
||||
<el-tab-pane label="📷 扫码添加">
|
||||
<div style="text-align: center; margin-bottom: 20px; margin-top: 10px;">
|
||||
<p style="color: #909399;">请允许浏览器使用摄像头,或直接上传微信/系统截图。</p>
|
||||
</div>
|
||||
<QrScanner @scan-success="handleScanSuccess" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="⌨️ 手动输入">
|
||||
<el-form :model="newAccount" label-position="top" :rules="rules" ref="addFormRef" style="padding: 10px 0;">
|
||||
<el-form-item label="服务名称 (如 Google, GitHub)" prop="service">
|
||||
<el-input v-model="newAccount.service" placeholder="请输入服务名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="账号标识 (如 邮箱地址)" prop="account">
|
||||
<el-input v-model="newAccount.account" placeholder="请输入账号或邮箱" />
|
||||
</el-form-item>
|
||||
<el-form-item label="安全密钥 (Base32格式)" prop="secret">
|
||||
<el-input v-model="newAccount.secret" placeholder="请输入 16 位以上的安全密钥" />
|
||||
</el-form-item>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="代码位数" prop="digits">
|
||||
<el-select v-model="newAccount.digits" style="width: 100%">
|
||||
<el-option label="6 位" :value="6" />
|
||||
<el-option label="8 位" :value="8" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="更新周期" prop="period">
|
||||
<el-select v-model="newAccount.period" style="width: 100%">
|
||||
<el-option label="30 秒" :value="30" />
|
||||
<el-option label="60 秒" :value="60" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="分类 (可选)" prop="category">
|
||||
<el-input v-model="newAccount.category" placeholder="如 工作, 个人" />
|
||||
</el-form-item>
|
||||
<el-form-item style="margin-top: 30px;">
|
||||
<el-button type="primary" :loading="submitting" @click="submitAddAccount" style="width: 100%;" size="large">确认添加</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
<!-- 视图:添加账号 -->
|
||||
<div v-if="activeTab === 'add-account-scan'" class="view-container">
|
||||
<AddAccountScan @success="handleSuccess" />
|
||||
</div>
|
||||
<div v-if="activeTab === 'add-account-manual'" class="view-container">
|
||||
<AddAccountManual @success="handleSuccess" />
|
||||
</div>
|
||||
|
||||
<!-- 3. 数据迁移视图 -->
|
||||
<div v-if="activeTab === 'migration'" class="view-container">
|
||||
<div class="tab-card-wrapper">
|
||||
<div style="text-align: center; margin-bottom: 30px;">
|
||||
<h2>数据迁移中心</h2>
|
||||
<p style="color: #909399;">安全地备份您的数据,或从其他软件无缝迁移数据。</p>
|
||||
</div>
|
||||
<DataMigration @import-success="handleImportSuccess" />
|
||||
</div>
|
||||
<!-- 视图:数据导出 -->
|
||||
<div v-if="activeTab === 'migration-export'" class="view-container">
|
||||
<DataExport />
|
||||
</div>
|
||||
|
||||
<!-- 4. WebDAV 备份视图 -->
|
||||
<div v-if="activeTab === 'webdav'" class="view-container">
|
||||
<div class="tab-card-wrapper">
|
||||
<div style="text-align: center; margin-bottom: 30px;">
|
||||
<h2>私有云自动备份中心</h2>
|
||||
<p style="color: #909399;">将加密数据安全托管至您的个人网盘,永远不用担心手机丢失。</p>
|
||||
</div>
|
||||
<WebDavConfig @restore-success="handleImportSuccess" />
|
||||
</div>
|
||||
<!-- 视图:数据导入 -->
|
||||
<div v-if="activeTab === 'migration-import'" class="view-container">
|
||||
<DataImport @success="handleSuccess" />
|
||||
</div>
|
||||
|
||||
<!-- 视图:多源备份 -->
|
||||
<div v-if="activeTab === 'backups'" class="view-container">
|
||||
<DataBackup @success="handleSuccess" />
|
||||
</div>
|
||||
|
||||
</el-main>
|
||||
</el-container>
|
||||
|
||||
<el-dialog v-model="showEditDialog" title="✏️ 编辑账号" :width="layoutState.isMobile ? '90%' : '400px'" destroy-on-close>
|
||||
<el-form :model="editAccountData" label-position="top">
|
||||
<el-form-item label="服务名称 (如 Google, GitHub)">
|
||||
<el-input v-model="editAccountData.service" />
|
||||
</el-form-item>
|
||||
<el-form-item label="账号标识 (如 邮箱地址)">
|
||||
<el-input v-model="editAccountData.account" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分类 (可选)">
|
||||
<el-input v-model="editAccountData.category" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="showEditDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="isEditing" @click="submitEditAccount">保存修改</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- TOTP 验证码弹窗 (新增) -->
|
||||
<el-dialog v-model="showTotpDialog" :title="currentTotpAccount?.account" width="300px" center align-center @closed="stopTotpTimer" destroy-on-close>
|
||||
<div class="totp-container">
|
||||
<div class="totp-service">{{ currentTotpAccount?.service }}</div>
|
||||
<div class="totp-code" @click="copyCode">{{ currentTotpCode }}</div>
|
||||
<div class="totp-timer">
|
||||
<el-progress type="circle" :percentage="totpPercentage" :width="80" :stroke-width="6" :color="totpColor">
|
||||
<template #default>
|
||||
<span class="timer-text">{{ totpRemaining }}s</span>
|
||||
</template>
|
||||
</el-progress>
|
||||
</div>
|
||||
<div class="totp-tip">点击验证码复制</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch, h, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Key, MoreFilled, Edit, Delete, Tools, Setting, Iphone } from '@element-plus/icons-vue'
|
||||
import { request } from '../utils/request'
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { Iphone, Plus, Camera, Edit, Sort, Upload, Download, Cloudy } from '@element-plus/icons-vue'
|
||||
import { layoutState } from '../states/layout'
|
||||
|
||||
import QrScanner from '../components/QrScanner.vue'
|
||||
import DataMigration from '../components/DataMigration.vue'
|
||||
import WebDavConfig from '../components/WebDavConfig.vue'
|
||||
import AccountList from '../components/AccountList.vue'
|
||||
import AddAccountScan from '../components/AddAccountScan.vue'
|
||||
import AddAccountManual from '../components/AddAccountManual.vue'
|
||||
import DataExport from '../components/DataExport.vue'
|
||||
import DataImport from '../components/DataImport.vue'
|
||||
import DataBackup from '../components/DataBackup.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const accounts = ref([])
|
||||
const loading = ref(true)
|
||||
const activeTab = ref('accounts') // 默认选中的标签页
|
||||
const total = ref(0) // 新增:存储服务端返回的总数
|
||||
const activeTab = ref('accounts')
|
||||
const accountListRef = ref(null)
|
||||
|
||||
// ====== 搜索与分页状态 ======
|
||||
const searchQuery = ref('')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(12) // 默认每页显示 12 个(正好排满 3 行)
|
||||
|
||||
// 2. 移除客户端分页切片逻辑 (paginatedAccounts),因为 accounts 现在已经是当前页的数据了
|
||||
// const paginatedAccounts = computed(() => { ... })
|
||||
|
||||
// 3. 分页事件处理
|
||||
const handleSizeChange = (val) => {
|
||||
pageSize.value = val
|
||||
currentPage.value = 1 // 切换每页条数时,重置回第一页,防止数据为空
|
||||
fetchAccounts()
|
||||
}
|
||||
|
||||
const handleCurrentChange = (val) => {
|
||||
currentPage.value = val
|
||||
fetchAccounts()
|
||||
}
|
||||
|
||||
// 4. 监听搜索词变化:只要用户一打字,就把页码重置回第 1 页
|
||||
let searchTimer = null
|
||||
watch(searchQuery, () => {
|
||||
currentPage.value = 1
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => {
|
||||
fetchAccounts()
|
||||
}, 300)
|
||||
})
|
||||
|
||||
// 添加表单相关状态
|
||||
const submitting = ref(false)
|
||||
const addFormRef = ref(null)
|
||||
const newAccount = ref({
|
||||
service: '', account: '', secret: '', category: '', digits: 6, period: 30
|
||||
})
|
||||
|
||||
const rules = {
|
||||
service: [{ required: true, message: '请输入服务名称', trigger: 'blur' }],
|
||||
account: [{ required: true, message: '请输入账号标识', trigger: 'blur' }],
|
||||
secret: [
|
||||
{ required: true, message: '请输入安全密钥', trigger: 'blur' },
|
||||
{ min: 16, message: '密钥长度通常不少于 16 位', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// ====== 移动端适配逻辑 ======
|
||||
// 移动端菜单处理
|
||||
const handleMenuSelect = (index) => {
|
||||
activeTab.value = index
|
||||
if (layoutState.isMobile) {
|
||||
@@ -328,280 +140,15 @@ const handleMenuSelect = (index) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 获取账号列表
|
||||
const fetchAccounts = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
// 构造分页参数
|
||||
const query = new URLSearchParams({
|
||||
page: currentPage.value,
|
||||
limit: pageSize.value,
|
||||
search: searchQuery.value
|
||||
}).toString()
|
||||
|
||||
const data = await request(`/api/accounts?${query}`)
|
||||
if (data.success) {
|
||||
accounts.value = data.accounts || []
|
||||
// 更新总数
|
||||
if (data.pagination) {
|
||||
total.value = data.pagination.total
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch accounts', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 提交手动添加账号
|
||||
const submitAddAccount = async () => {
|
||||
if (!addFormRef.value) return
|
||||
await addFormRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
submitting.value = true
|
||||
try {
|
||||
const data = await request('/api/accounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(newAccount.value)
|
||||
})
|
||||
if (data.success) {
|
||||
ElMessage.success('账号添加成功!')
|
||||
newAccount.value = { service: '', account: '', secret: '', category: '', digits: 6, period: 30 }
|
||||
fetchAccounts()
|
||||
activeTab.value = 'accounts' // 自动切回列表
|
||||
}
|
||||
} catch (error) {
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
// 操作成功回调 (刷新列表)
|
||||
const handleSuccess = () => {
|
||||
activeTab.value = 'accounts'
|
||||
nextTick(() => {
|
||||
if (accountListRef.value) {
|
||||
accountListRef.value.fetchAccounts()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 3. 点击获取验证码 (重构:支持倒计时与自动刷新)
|
||||
const showTotpDialog = ref(false)
|
||||
const currentTotpAccount = ref(null)
|
||||
const currentTotpCode = ref('------')
|
||||
const totpRemaining = ref(30)
|
||||
let totpInterval = null
|
||||
let lastTotpEpoch = -1
|
||||
|
||||
const totpPercentage = computed(() => {
|
||||
const period = currentTotpAccount.value?.period || 30
|
||||
return (totpRemaining.value / period) * 100
|
||||
})
|
||||
|
||||
const totpColor = computed(() => {
|
||||
if (totpRemaining.value > 10) return '#67C23A'
|
||||
if (totpRemaining.value > 5) return '#E6A23C'
|
||||
return '#F56C6C'
|
||||
})
|
||||
|
||||
const showTotpCode = (account) => {
|
||||
currentTotpAccount.value = account
|
||||
currentTotpCode.value = '------'
|
||||
lastTotpEpoch = -1
|
||||
showTotpDialog.value = true
|
||||
|
||||
updateTotpLogic()
|
||||
if (totpInterval) clearInterval(totpInterval)
|
||||
totpInterval = setInterval(updateTotpLogic, 1000)
|
||||
}
|
||||
|
||||
const updateTotpLogic = () => {
|
||||
if (!currentTotpAccount.value) return
|
||||
|
||||
const period = currentTotpAccount.value.period || 30
|
||||
const now = Date.now() / 1000
|
||||
const epoch = Math.floor(now / period)
|
||||
const remaining = Math.ceil(period - (now % period))
|
||||
|
||||
totpRemaining.value = remaining
|
||||
|
||||
// 如果进入了新的周期(epoch 变化),或者当前没有验证码,则刷新
|
||||
if (epoch > lastTotpEpoch) {
|
||||
fetchTotpCode()
|
||||
lastTotpEpoch = epoch
|
||||
}
|
||||
}
|
||||
|
||||
const fetchTotpCode = async () => {
|
||||
try {
|
||||
const data = await request('/api/accounts/generate-totp', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ secret: currentTotpAccount.value.secret })
|
||||
})
|
||||
if (data.success) {
|
||||
currentTotpCode.value = data.code
|
||||
}
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
const stopTotpTimer = () => {
|
||||
if (totpInterval) clearInterval(totpInterval)
|
||||
totpInterval = null
|
||||
}
|
||||
|
||||
const copyCode = async () => {
|
||||
if (currentTotpCode.value === '------') return
|
||||
try {
|
||||
await navigator.clipboard.writeText(currentTotpCode.value)
|
||||
ElMessage.success('验证码已复制')
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// 4. 删除账号
|
||||
// ====== 批量选择与删除逻辑 ======
|
||||
const selectedIds = ref([])
|
||||
const isBulkDeleting = ref(false)
|
||||
|
||||
const toggleSelection = (id) => {
|
||||
const index = selectedIds.value.indexOf(id)
|
||||
if (index > -1) selectedIds.value.splice(index, 1)
|
||||
else selectedIds.value.push(id)
|
||||
}
|
||||
|
||||
const selectAllVisible = () => {
|
||||
accounts.value.forEach(acc => {
|
||||
if (!selectedIds.value.includes(acc.id)) selectedIds.value.push(acc.id)
|
||||
})
|
||||
}
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除选中的 ${selectedIds.value.length} 个账号吗?删除后无法恢复!`,
|
||||
'高危操作警告',
|
||||
{ confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'error' }
|
||||
)
|
||||
|
||||
isBulkDeleting.value = true
|
||||
// ⚠️ 极其关键:必须使用 for...of 串行发送请求,防止 Cloudflare KV 数据库并发写入冲突导致数据丢失!
|
||||
for (const id of selectedIds.value) {
|
||||
await request(`/api/accounts/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
ElMessage.success(`成功删除了 ${selectedIds.value.length} 个账号`)
|
||||
selectedIds.value = []
|
||||
fetchAccounts() // 刷新列表
|
||||
} catch (e) {
|
||||
} finally {
|
||||
isBulkDeleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ====== 编辑账号逻辑 ======
|
||||
const showEditDialog = ref(false)
|
||||
const isEditing = ref(false)
|
||||
const editAccountData = ref({ id: '', service: '', account: '', category: '' })
|
||||
|
||||
// 处理卡片右上角的下拉菜单
|
||||
const handleCommand = async (command, account) => {
|
||||
if (command === 'delete') {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除 [${account.service}] 吗?`, '警告', { type: 'error' })
|
||||
const data = await request(`/api/accounts/${account.id}`, { method: 'DELETE' })
|
||||
if (data.success) {
|
||||
ElMessage.success('账号已删除')
|
||||
fetchAccounts()
|
||||
}
|
||||
} catch (e) {}
|
||||
} else if (command === 'edit') {
|
||||
// 弹出编辑框并回显数据
|
||||
editAccountData.value = {
|
||||
id: account.id,
|
||||
service: account.service,
|
||||
account: account.account,
|
||||
category: account.category || ''
|
||||
}
|
||||
showEditDialog.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 提交编辑保存
|
||||
const submitEditAccount = async () => {
|
||||
if (!editAccountData.value.service || !editAccountData.value.account) {
|
||||
return ElMessage.warning('服务名称和账号不能为空')
|
||||
}
|
||||
isEditing.value = true
|
||||
try {
|
||||
const data = await request(`/api/accounts/${editAccountData.value.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
service: editAccountData.value.service,
|
||||
account: editAccountData.value.account,
|
||||
category: editAccountData.value.category
|
||||
})
|
||||
})
|
||||
if (data.success) {
|
||||
ElMessage.success('账号修改成功')
|
||||
showEditDialog.value = false
|
||||
fetchAccounts()
|
||||
}
|
||||
} catch (e) {
|
||||
} finally {
|
||||
isEditing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 扫码成功后的处理逻辑
|
||||
const handleScanSuccess = async (uri) => {
|
||||
try {
|
||||
// 调用后端解析 URI
|
||||
const data = await request('/api/accounts/parse-uri', {
|
||||
// ⚠️ 注意:这里也由于路由重构,改成了 /api/accounts/parse-uri
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ uri })
|
||||
})
|
||||
|
||||
if (data.success && data.account) {
|
||||
const acc = data.account
|
||||
// 弹窗确认
|
||||
await ElMessageBox.confirm(
|
||||
h('div', { style: 'text-align:left; background:#f5f7fa; padding:15px; border-radius:8px;' }, [
|
||||
h('p', [h('strong', '服务方:'), acc.issuer || '未知']),
|
||||
h('p', [h('strong', '账号:'), acc.account || '未知']),
|
||||
h('p', [h('strong', '算法:'), `${acc.algorithm} (${acc.digits}位 / ${acc.period}秒)`])
|
||||
]),
|
||||
'✅ 二维码解析成功,确认添加?',
|
||||
{
|
||||
confirmButtonText: '确认添加',
|
||||
cancelButtonText: '取消',
|
||||
type: 'success'
|
||||
}
|
||||
)
|
||||
|
||||
// 确认后调用添加接口
|
||||
const addData = await request('/api/accounts/add-from-uri', {
|
||||
// ⚠️ 同理,改成了 /api/accounts/add-from-uri
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ uri, category: '手机扫码' })
|
||||
})
|
||||
|
||||
if (addData.success) {
|
||||
ElMessage.success('扫码账号添加成功!')
|
||||
fetchAccounts() // 刷新列表
|
||||
activeTab.value = 'accounts' // 切回列表页
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err !== 'cancel') console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 数据导入成功后的联动处理
|
||||
// ==========================================
|
||||
const handleImportSuccess = () => {
|
||||
fetchAccounts() // 重新拉取最新的账号列表
|
||||
activeTab.value = 'accounts' // 自动把标签页切回“我的账户”
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchAccounts()
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -610,20 +157,6 @@ onMounted(() => {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.mobile-profile {
|
||||
padding: 4px !important;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
justify-content: center;
|
||||
border-radius: 50% !important;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-weight: 600;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.main-body {
|
||||
height: 100%; /* 自动填充 */
|
||||
display: flex;
|
||||
@@ -646,118 +179,4 @@ onMounted(() => {
|
||||
background-color: #f5f7fa;
|
||||
overflow-y: auto; /* 内容区独立滚动 */
|
||||
}
|
||||
|
||||
.tab-card-wrapper {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.account-card {
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.account-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.service-name {
|
||||
margin: 0 0 5px 0;
|
||||
font-size: 18px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.account-name {
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
margin: 0 0 20px 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.more-icon {
|
||||
cursor: pointer;
|
||||
color: #909399;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.more-icon:hover {
|
||||
color: #409EFF;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.code-btn {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.loading-state, .empty-state {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.account-card.is-selected {
|
||||
border: 1px solid #409EFF;
|
||||
background-color: #f4f9ff;
|
||||
}
|
||||
|
||||
.totp-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.totp-service {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.totp-code {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
color: #409EFF;
|
||||
letter-spacing: 6px;
|
||||
margin: 15px 0;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
.totp-code:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.totp-timer {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.timer-text {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.totp-tip {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -45,14 +45,14 @@ const loading = ref(false)
|
||||
const handleGitHubLogin = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
// 调用后端的授权接口
|
||||
// 获取 GitHub 授权链接
|
||||
const response = await fetch('/api/oauth/authorize')
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success && data.authUrl) {
|
||||
// 记录 state 防御 CSRF(可选,后端也有校验机制)
|
||||
// 记录 state 防御 CSRF
|
||||
localStorage.setItem('oauth_state', data.state)
|
||||
// 直接把浏览器跳转到 GitHub 授权页
|
||||
// 跳转至 GitHub
|
||||
window.location.href = data.authUrl
|
||||
} else {
|
||||
ElMessage.error(data.error || '获取授权链接失败')
|
||||
|
||||
Reference in new issue
Block a user