refactor: 重构项目

This commit is contained in:
nap0o committed 2026-02-24 01:45:45 +08:00
1 parent d97ff2aa2d
commit 92ceb8ac94
24 files changed
+1783 -1634

No files matched your search

+5 -6
View File
@@ -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
+6
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
);
+3 -3
View File
@@ -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: [] });
}
+12
View File
@@ -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>;
}
+102
View File
@@ -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();
}
}
+62
View File
@@ -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;
}
}
+56 -87
View File
@@ -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(
+257
View File
@@ -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;
-310
View File
@@ -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;
+109
View File
@@ -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;
}
+2
View File
@@ -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"
}
+341
View File
@@ -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>
+335
View File
@@ -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>
+27
View File
@@ -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>
+89
View File
@@ -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>
+117
View File
@@ -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>
-259
View File
@@ -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>
-303
View File
@@ -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
View File
@@ -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>
+3 -3
View File
@@ -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 || '获取授权链接失败')