chore: configure eslint and update gitignore

This commit is contained in:
TYt50
2026-02-01 15:07:02 +08:00
parent 1cbd819ff6
commit 8908d56518
13 changed files with 1938 additions and 643 deletions

7
.gitignore vendored
View File

@@ -24,3 +24,10 @@ yarn-error.log*
# Production build (if you pack the app later)
dist/
build/
# Linting
lint_output.txt
final_lint.txt
*.log
.eslintcache

22
eslint.config.mjs Normal file
View File

@@ -0,0 +1,22 @@
import js from "@eslint/js";
import globals from "globals";
export default [
js.configs.recommended,
{
languageOptions: {
ecmaVersion: 2022,
sourceType: "module",
globals: {
...globals.browser,
...globals.node,
...globals.es2021
}
},
rules: {
"no-unused-vars": "warn",
"no-console": "off",
"no-undef": "warn"
}
}
];

15
export.bat Normal file
View File

@@ -0,0 +1,15 @@
@echo off
echo ==========================================
echo Exporting Account Data...
echo ==========================================
echo.
node export.js
echo.
echo ==========================================
echo EXPORT COMPLETE!
echo Check file: accounts_export.txt
echo ==========================================
echo.
pause

107
main.js
View File

@@ -1,16 +1,74 @@
const { app, BrowserWindow, ipcMain, clipboard, Menu } = require('electron');
const { app, BrowserWindow, ipcMain, clipboard, Menu, desktopCapturer, screen } = require('electron');
const path = require('path');
const fs = require('fs');
const Database = require('./src/db/database');
// 强制设置用户数据存储位置为项目根目录下的 data 文件夹
// 这样 Electron 的缓存、Localstorage、日志等都不会写到 C 盘
// Force userData path to project root/data folder
// Prevents Electron cache/localStorage/logs from writing to C drive
app.setPath('userData', path.join(process.cwd(), 'data'));
let mainWindow;
let db;
let backupInterval;
// Auto backup feature
function startAutoBackup() {
const backupDir = path.join(process.cwd(), 'DB');
const dbPath = path.join(process.cwd(), 'accounts.db');
// Ensure backup directory exists
if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true });
}
// Backup every 30 minutes
backupInterval = setInterval(() => {
try {
if (fs.existsSync(dbPath)) {
const now = new Date();
const timestamp = now.getFullYear().toString() +
(now.getMonth() + 1).toString().padStart(2, '0') +
now.getDate().toString().padStart(2, '0') + '_' +
now.getHours().toString().padStart(2, '0') +
now.getMinutes().toString().padStart(2, '0') +
now.getSeconds().toString().padStart(2, '0');
const backupPath = path.join(backupDir, `accounts_${timestamp}.db`);
fs.copyFileSync(dbPath, backupPath);
console.log(`[Backup] Auto backup success: ${backupPath}`);
// Clean old backups, keep last 10
cleanOldBackups(backupDir, 10);
}
} catch (e) {
console.error('[Backup] Backup failed:', e);
}
}, 30 * 60 * 1000); // 30 minutes
console.log('[Backup] Auto backup started, interval 30 minutes');
}
// Clean old backups
function cleanOldBackups(backupDir, keepCount) {
try {
const files = fs.readdirSync(backupDir)
.filter(f => f.startsWith('accounts_') && f.endsWith('.db'))
.map(f => ({ name: f, path: path.join(backupDir, f), time: fs.statSync(path.join(backupDir, f)).mtime }))
.sort((a, b) => b.time - a.time);
if (files.length > keepCount) {
files.slice(keepCount).forEach(f => {
fs.unlinkSync(f.path);
console.log(`[Backup] Deleted old backup: ${f.name}`);
});
}
} catch (e) {
console.error('[Backup] Clean old backups failed:', e);
}
}
function createWindow() {
// 移除应用菜单栏
// Remove application menu bar
Menu.setApplicationMenu(null);
mainWindow = new BrowserWindow({
@@ -21,7 +79,8 @@ function createWindow() {
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
nodeIntegration: false,
sandbox: false
},
backgroundColor: '#0a0a0b',
titleBarStyle: 'default',
@@ -34,13 +93,16 @@ function createWindow() {
mainWindow.show();
});
// 开发时打开DevTools
// mainWindow.webContents.openDevTools();
// DevTools: opt-in only (prevents auto-opening on startup)
if (process.env.ELECTRON_DEVTOOLS === '1') {
mainWindow.webContents.openDevTools();
}
}
app.whenReady().then(() => {
db = new Database();
createWindow();
startAutoBackup();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
@@ -50,6 +112,9 @@ app.whenReady().then(() => {
});
app.on('window-all-closed', () => {
if (backupInterval) {
clearInterval(backupInterval);
}
if (process.platform !== 'darwin') {
app.quit();
}
@@ -105,3 +170,31 @@ ipcMain.handle('clipboard:write', async (event, text) => {
clipboard.writeText(text);
return true;
});
// Screen capture feature
ipcMain.handle('screen:capture', async () => {
try {
const primaryDisplay = screen.getPrimaryDisplay();
const { width, height } = primaryDisplay.size;
const scaleFactor = primaryDisplay.scaleFactor;
const sources = await desktopCapturer.getSources({
types: ['screen'],
thumbnailSize: { width: width * scaleFactor, height: height * scaleFactor }
});
if (sources.length > 0) {
const thumbnail = sources[0].thumbnail;
return {
success: true,
image: thumbnail.toDataURL(),
width: thumbnail.getSize().width,
height: thumbnail.getSize().height
};
}
return { success: false, error: 'Failed to capture screen' };
} catch (e) {
console.error('Screen capture failed:', e);
return { success: false, error: e.message };
}
});

1015
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,8 @@
"description": "本地2FA账号密码管理工具",
"main": "main.js",
"scripts": {
"start": "electron ."
"start": "electron .",
"lint": "eslint ."
},
"keywords": [
"2fa",
@@ -14,10 +15,12 @@
"author": "",
"license": "MIT",
"devDependencies": {
"electron": "^28.0.0"
"electron": "^28.0.0",
"eslint": "^9.39.2"
},
"dependencies": {
"crypto-js": "^4.2.0",
"jsqr": "^1.4.0",
"sql.js": "^1.10.0"
}
}

View File

@@ -1,5 +1,17 @@
const { contextBridge, ipcRenderer } = require('electron');
// 加载jsQR库
let jsQRLib = null;
try {
// 直接require jsqr模块
const jsqrModule = require('jsqr');
// 处理ES module的default export
jsQRLib = jsqrModule.default || jsqrModule;
console.log('jsQR loaded successfully:', typeof jsQRLib);
} catch (e) {
console.error('Failed to load jsQR:', e);
}
contextBridge.exposeInMainWorld('api', {
// Vault operations
getVaults: () => ipcRenderer.invoke('db:getVaults'),
@@ -17,5 +29,39 @@ contextBridge.exposeInMainWorld('api', {
searchAccounts: (query, vaultId) => ipcRenderer.invoke('db:searchAccounts', query, vaultId),
// Clipboard
copyToClipboard: (text) => ipcRenderer.invoke('clipboard:write', text)
copyToClipboard: (text) => ipcRenderer.invoke('clipboard:write', text),
// Screenshot
captureScreen: () => ipcRenderer.invoke('screen:capture'),
// QR Code parsing
decodeQR: (imageData, width, height) => {
if (!jsQRLib) {
console.error('jsQR not loaded');
return null;
}
try {
// 确保是Uint8ClampedArray格式
const data = imageData instanceof Uint8ClampedArray
? imageData
: new Uint8ClampedArray(imageData);
console.log('Decoding QR, image size:', width, 'x', height, 'data length:', data.length);
const result = jsQRLib(data, width, height);
if (result) {
console.log('QR code found:', result.data);
return result.data;
} else {
console.log('No QR code found in image');
return null;
}
} catch (e) {
console.error('QR decode error:', e);
return null;
}
},
hasJsQR: () => !!jsQRLib,
});

View File

@@ -1,37 +1,37 @@
const initSqlJs = require('sql.js');
const path = require('path');
const fs = require('fs');
const { app } = require('electron');
// const { app } = require('electron');
const CryptoJS = require('crypto-js');
const ENCRYPTION_KEY = 'your-secret-key-2fa-manager-v1';
class AccountDatabase {
constructor() {
this.db = null;
this.dbPath = null;
this.ready = this.init();
constructor() {
this.db = null;
this.dbPath = null;
this.ready = this.init();
}
async init() {
const SQL = await initSqlJs();
// const userDataPath = app.getPath('userData');
// this.dbPath = path.join(userDataPath, 'accounts.db');
// 便携版:将数据库放在项目根目录下
this.dbPath = path.join(process.cwd(), 'accounts.db');
// 尝试加载现有数据库
if (fs.existsSync(this.dbPath)) {
const buffer = fs.readFileSync(this.dbPath);
this.db = new SQL.Database(buffer);
} else {
this.db = new SQL.Database();
}
async init() {
const SQL = await initSqlJs();
// const userDataPath = app.getPath('userData');
// this.dbPath = path.join(userDataPath, 'accounts.db');
// 便携版:将数据库放在项目根目录下
this.dbPath = path.join(process.cwd(), 'accounts.db');
// 尝试加载现有数据库
if (fs.existsSync(this.dbPath)) {
const buffer = fs.readFileSync(this.dbPath);
this.db = new SQL.Database(buffer);
} else {
this.db = new SQL.Database();
}
// 创建资料库表
this.db.run(`
// 创建资料库表
this.db.run(`
CREATE TABLE IF NOT EXISTS vaults (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
@@ -41,8 +41,8 @@ class AccountDatabase {
)
`);
// 创建账号表
this.db.run(`
// 创建账号表
this.db.run(`
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
vault_id INTEGER DEFAULT NULL,
@@ -60,54 +60,54 @@ class AccountDatabase {
)
`);
// 迁移: 添加 tags 和 vault_id 和 proxy 和 browser_id 列 (如果不存在)
try { this.db.run(`ALTER TABLE accounts ADD COLUMN tags TEXT`); } catch (e) { }
try { this.db.run(`ALTER TABLE accounts ADD COLUMN vault_id INTEGER`); } catch (e) { }
try { this.db.run(`ALTER TABLE accounts ADD COLUMN proxy TEXT`); } catch (e) { }
try { this.db.run(`ALTER TABLE accounts ADD COLUMN browser_id TEXT`); } catch (e) { }
// 迁移: 添加 tags 和 vault_id 和 proxy 和 browser_id 列 (如果不存在)
try { this.db.run(`ALTER TABLE accounts ADD COLUMN tags TEXT`); } catch { /* ignore */ }
try { this.db.run(`ALTER TABLE accounts ADD COLUMN vault_id INTEGER`); } catch { /* ignore */ }
try { this.db.run(`ALTER TABLE accounts ADD COLUMN proxy TEXT`); } catch { /* ignore */ }
try { this.db.run(`ALTER TABLE accounts ADD COLUMN browser_id TEXT`); } catch { /* ignore */ }
// 创建默认资料库 (如果不存在)
const defaultVault = this.db.exec("SELECT id FROM vaults WHERE name = '默认'");
if (!defaultVault.length || !defaultVault[0].values.length) {
this.db.run("INSERT INTO vaults (name, icon) VALUES ('默认', '🏠')");
}
// 创建索引
this.db.run(`CREATE INDEX IF NOT EXISTS idx_accounts_name ON accounts(name)`);
this.db.run(`CREATE INDEX IF NOT EXISTS idx_accounts_username ON accounts(username)`);
this.db.run(`CREATE INDEX IF NOT EXISTS idx_accounts_vault ON accounts(vault_id)`);
this.save();
return true;
// 创建默认资料库 (如果不存在)
const defaultVault = this.db.exec("SELECT id FROM vaults WHERE name = '默认'");
if (!defaultVault.length || !defaultVault[0].values.length) {
this.db.run("INSERT INTO vaults (name, icon) VALUES ('默认', '🏠')");
}
save() {
if (!this.db || !this.dbPath) return;
const data = this.db.export();
const buffer = Buffer.from(data);
fs.writeFileSync(this.dbPath, buffer);
// 创建索引
this.db.run(`CREATE INDEX IF NOT EXISTS idx_accounts_name ON accounts(name)`);
this.db.run(`CREATE INDEX IF NOT EXISTS idx_accounts_username ON accounts(username)`);
this.db.run(`CREATE INDEX IF NOT EXISTS idx_accounts_vault ON accounts(vault_id)`);
this.save();
return true;
}
save() {
if (!this.db || !this.dbPath) return;
const data = this.db.export();
const buffer = Buffer.from(data);
fs.writeFileSync(this.dbPath, buffer);
}
encrypt(text) {
if (!text) return '';
return CryptoJS.AES.encrypt(text, ENCRYPTION_KEY).toString();
}
decrypt(ciphertext) {
if (!ciphertext) return '';
try {
const bytes = CryptoJS.AES.decrypt(ciphertext, ENCRYPTION_KEY);
return bytes.toString(CryptoJS.enc.Utf8);
} catch {
return ciphertext;
}
}
encrypt(text) {
if (!text) return '';
return CryptoJS.AES.encrypt(text, ENCRYPTION_KEY).toString();
}
// ==================== 资料库操作 ====================
decrypt(ciphertext) {
if (!ciphertext) return '';
try {
const bytes = CryptoJS.AES.decrypt(ciphertext, ENCRYPTION_KEY);
return bytes.toString(CryptoJS.enc.Utf8);
} catch (e) {
return ciphertext;
}
}
// ==================== 资料库操作 ====================
async getVaults() {
await this.ready;
const result = this.db.exec(`
async getVaults() {
await this.ready;
const result = this.db.exec(`
SELECT v.*, COUNT(a.id) as account_count
FROM vaults v
LEFT JOIN accounts a ON a.vault_id = v.id
@@ -115,211 +115,211 @@ class AccountDatabase {
ORDER BY v.created_at ASC
`);
if (!result.length) return [];
if (!result.length) return [];
const columns = result[0].columns;
return result[0].values.map(row => {
const obj = {};
columns.forEach((col, i) => obj[col] = row[i]);
return obj;
});
const columns = result[0].columns;
return result[0].values.map(row => {
const obj = {};
columns.forEach((col, i) => obj[col] = row[i]);
return obj;
});
}
async addVault(vault) {
await this.ready;
this.db.run(`INSERT INTO vaults (name, icon, color) VALUES (?, ?, ?)`, [
vault.name,
vault.icon || '📁',
vault.color || '#3b82f6'
]);
this.save();
const result = this.db.exec('SELECT last_insert_rowid() as id');
return { id: result[0]?.values[0][0], ...vault };
}
async updateVault(id, vault) {
await this.ready;
this.db.run(`UPDATE vaults SET name = ?, icon = ?, color = ? WHERE id = ?`, [
vault.name,
vault.icon || '📁',
vault.color || '#3b82f6',
id
]);
this.save();
return { id, ...vault };
}
async deleteVault(id) {
await this.ready;
// 将该资料库中的账号移到默认资料库
const defaultVault = this.db.exec("SELECT id FROM vaults WHERE name = '默认'");
const defaultId = defaultVault[0]?.values[0]?.[0] || null;
this.db.run('UPDATE accounts SET vault_id = ? WHERE vault_id = ?', [defaultId, id]);
this.db.run('DELETE FROM vaults WHERE id = ? AND name != ?', [id, '默认']);
this.save();
return true;
}
// ==================== 账号操作 ====================
async getAccounts(page = 1, limit = 10, vaultId = null) {
await this.ready;
const offset = (page - 1) * limit;
let sql = `SELECT * FROM accounts`;
const params = [];
if (vaultId !== null) {
sql += ` WHERE vault_id = ?`;
params.push(vaultId);
}
async addVault(vault) {
await this.ready;
this.db.run(`INSERT INTO vaults (name, icon, color) VALUES (?, ?, ?)`, [
vault.name,
vault.icon || '📁',
vault.color || '#3b82f6'
]);
this.save();
sql += ` ORDER BY updated_at DESC LIMIT ? OFFSET ?`;
params.push(limit, offset);
const result = this.db.exec('SELECT last_insert_rowid() as id');
return { id: result[0]?.values[0][0], ...vault };
const stmt = this.db.prepare(sql);
stmt.bind(params);
const accounts = [];
while (stmt.step()) {
const row = stmt.getAsObject();
accounts.push({
...row,
password: this.decrypt(row.password),
totp_secret: this.decrypt(row.totp_secret)
});
}
stmt.free();
return accounts;
}
async getAccountCount(vaultId = null) {
await this.ready;
let sql = 'SELECT COUNT(*) as count FROM accounts';
const params = [];
if (vaultId !== null) {
sql += ' WHERE vault_id = ?';
params.push(vaultId);
}
async updateVault(id, vault) {
await this.ready;
this.db.run(`UPDATE vaults SET name = ?, icon = ?, color = ? WHERE id = ?`, [
vault.name,
vault.icon || '📁',
vault.color || '#3b82f6',
id
]);
this.save();
return { id, ...vault };
}
const stmt = this.db.prepare(sql);
stmt.bind(params);
stmt.step();
const result = stmt.getAsObject();
stmt.free();
async deleteVault(id) {
await this.ready;
// 将该资料库中的账号移到默认资料库
const defaultVault = this.db.exec("SELECT id FROM vaults WHERE name = '默认'");
const defaultId = defaultVault[0]?.values[0]?.[0] || null;
return result.count || 0;
}
this.db.run('UPDATE accounts SET vault_id = ? WHERE vault_id = ?', [defaultId, id]);
this.db.run('DELETE FROM vaults WHERE id = ? AND name != ?', [id, '默认']);
this.save();
return true;
}
async addAccount(account) {
await this.ready;
// ==================== 账号操作 ====================
async getAccounts(page = 1, limit = 10, vaultId = null) {
await this.ready;
const offset = (page - 1) * limit;
let sql = `SELECT * FROM accounts`;
const params = [];
if (vaultId !== null) {
sql += ` WHERE vault_id = ?`;
params.push(vaultId);
}
sql += ` ORDER BY updated_at DESC LIMIT ? OFFSET ?`;
params.push(limit, offset);
const stmt = this.db.prepare(sql);
stmt.bind(params);
const accounts = [];
while (stmt.step()) {
const row = stmt.getAsObject();
accounts.push({
...row,
password: this.decrypt(row.password),
totp_secret: this.decrypt(row.totp_secret)
});
}
stmt.free();
return accounts;
}
async getAccountCount(vaultId = null) {
await this.ready;
let sql = 'SELECT COUNT(*) as count FROM accounts';
const params = [];
if (vaultId !== null) {
sql += ' WHERE vault_id = ?';
params.push(vaultId);
}
const stmt = this.db.prepare(sql);
stmt.bind(params);
stmt.step();
const result = stmt.getAsObject();
stmt.free();
return result.count || 0;
}
async addAccount(account) {
await this.ready;
this.db.run(`
this.db.run(`
INSERT INTO accounts (vault_id, name, username, password, totp_secret, tags, email, proxy, notes, browser_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
account.vault_id || null,
account.name,
account.username,
this.encrypt(account.password),
this.encrypt(account.totp_secret),
account.tags || '',
account.email,
account.proxy || '',
account.notes,
account.browser_id || ''
]);
account.vault_id || null,
account.name,
account.username,
this.encrypt(account.password),
this.encrypt(account.totp_secret),
account.tags || '',
account.email,
account.proxy || '',
account.notes,
account.browser_id || ''
]);
this.save();
this.save();
const result = this.db.exec('SELECT last_insert_rowid() as id');
const id = result[0]?.values[0][0];
const result = this.db.exec('SELECT last_insert_rowid() as id');
const id = result[0]?.values[0][0];
return { id, ...account };
}
return { id, ...account };
}
async updateAccount(id, account) {
await this.ready;
async updateAccount(id, account) {
await this.ready;
this.db.run(`
this.db.run(`
UPDATE accounts
SET vault_id = ?, name = ?, username = ?, password = ?, totp_secret = ?,
tags = ?, email = ?, proxy = ?, notes = ?, browser_id = ?, updated_at = datetime('now')
WHERE id = ?
`, [
account.vault_id || null,
account.name,
account.username,
this.encrypt(account.password),
this.encrypt(account.totp_secret),
account.tags || '',
account.email,
account.proxy || '',
account.notes,
account.browser_id || '',
id
]);
account.vault_id || null,
account.name,
account.username,
this.encrypt(account.password),
this.encrypt(account.totp_secret),
account.tags || '',
account.email,
account.proxy || '',
account.notes,
account.browser_id || '',
id
]);
this.save();
return { id, ...account };
this.save();
return { id, ...account };
}
async moveAccountToVault(accountId, vaultId) {
await this.ready;
this.db.run('UPDATE accounts SET vault_id = ?, updated_at = datetime("now") WHERE id = ?',
[vaultId, accountId]);
this.save();
return true;
}
async deleteAccount(id) {
await this.ready;
this.db.run('DELETE FROM accounts WHERE id = ?', [id]);
this.save();
return true;
}
async searchAccounts(query, vaultId = null) {
await this.ready;
const searchTerm = `%${query}%`;
let sql = `SELECT * FROM accounts WHERE (name LIKE ? OR username LIKE ? OR email LIKE ? OR tags LIKE ? OR proxy LIKE ?)`;
const params = [searchTerm, searchTerm, searchTerm, searchTerm, searchTerm];
if (vaultId !== null) {
sql += ` AND vault_id = ?`;
params.push(vaultId);
}
async moveAccountToVault(accountId, vaultId) {
await this.ready;
this.db.run('UPDATE accounts SET vault_id = ?, updated_at = datetime("now") WHERE id = ?',
[vaultId, accountId]);
this.save();
return true;
sql += ` ORDER BY updated_at DESC LIMIT 50`;
const stmt = this.db.prepare(sql);
stmt.bind(params);
const accounts = [];
while (stmt.step()) {
const row = stmt.getAsObject();
accounts.push({
...row,
password: this.decrypt(row.password),
totp_secret: this.decrypt(row.totp_secret)
});
}
stmt.free();
async deleteAccount(id) {
await this.ready;
this.db.run('DELETE FROM accounts WHERE id = ?', [id]);
this.save();
return true;
}
async searchAccounts(query, vaultId = null) {
await this.ready;
const searchTerm = `%${query}%`;
let sql = `SELECT * FROM accounts WHERE (name LIKE ? OR username LIKE ? OR email LIKE ? OR tags LIKE ? OR proxy LIKE ?)`;
const params = [searchTerm, searchTerm, searchTerm, searchTerm, searchTerm];
if (vaultId !== null) {
sql += ` AND vault_id = ?`;
params.push(vaultId);
}
sql += ` ORDER BY updated_at DESC LIMIT 50`;
const stmt = this.db.prepare(sql);
stmt.bind(params);
const accounts = [];
while (stmt.step()) {
const row = stmt.getAsObject();
accounts.push({
...row,
password: this.decrypt(row.password),
totp_secret: this.decrypt(row.totp_secret)
});
}
stmt.free();
return accounts;
}
close() {
if (this.db) {
this.save();
this.db.close();
}
return accounts;
}
close() {
if (this.db) {
this.save();
this.db.close();
}
}
}
module.exports = AccountDatabase;

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; font-src 'self' data: https://cdnjs.cloudflare.com; connect-src 'self' http://localhost:12138 http://127.0.0.1:12138">
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; font-src 'self' data: https://cdnjs.cloudflare.com; img-src 'self' data:; connect-src 'self' http://localhost:12138 http://127.0.0.1:12138">
<title>2FA 账号管理器</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="styles.css">
@@ -16,10 +16,30 @@
<!-- 左侧区域 -->
<div class="list-panel">
<div class="panel-header">
<h1><i class="fa-solid fa-shield-halved"></i> 账号列表</h1>
<div class="search-box">
<i class="fa-solid fa-magnifying-glass search-icon"></i>
<input type="text" id="searchInput" placeholder="搜索账号..." />
<div class="header-main">
<h1><i class="fa-solid fa-shield-halved"></i> 账号列表</h1>
<!-- 4路独立备忘录 -->
<div class="memo-bar">
<div class="memo-group">
<input type="text" id="memo1" placeholder="备忘1" class="memo-field" />
<i class="fa-solid fa-copy memo-copy-btn" onclick="copyMemo(1)" title="复制备忘1"></i>
</div>
<div class="memo-divider"></div>
<div class="memo-group">
<input type="text" id="memo2" placeholder="备忘2" class="memo-field" />
<i class="fa-solid fa-copy memo-copy-btn" onclick="copyMemo(2)" title="复制备忘2"></i>
</div>
<div class="memo-divider"></div>
<div class="memo-group">
<input type="text" id="memo3" placeholder="备忘3" class="memo-field" />
<i class="fa-solid fa-copy memo-copy-btn" onclick="copyMemo(3)" title="复制备忘3"></i>
</div>
<div class="memo-divider"></div>
<div class="memo-group">
<input type="text" id="memo4" placeholder="备忘4" class="memo-field" />
<i class="fa-solid fa-copy memo-copy-btn" onclick="copyMemo(4)" title="复制备忘4"></i>
</div>
</div>
</div>
</div>
@@ -31,6 +51,12 @@
<span>资料库</span>
</div>
<!-- 搜索框移到资料库上方 -->
<div class="vault-search-box">
<i class="fa-solid fa-magnifying-glass search-icon"></i>
<input type="text" id="searchInput" placeholder="搜索..." />
</div>
<div class="vault-items">
<div class="vault-row all-vault active" onclick="selectVault(null)">
<span class="vault-name">全部</span>
@@ -95,7 +121,12 @@
<div class="form-group">
<label for="totpSecret">2FA 密钥</label>
<input type="text" id="totpSecret" placeholder="TOTP Secret Key">
<div class="totp-input-row">
<input type="text" id="totpSecret" placeholder="TOTP Secret Key">
<button type="button" id="scanQrBtn" class="btn-icon-qr" title="截图识别二维码">
<i class="fa-solid fa-qrcode"></i>
</button>
</div>
</div>
<div class="form-group">
@@ -164,7 +195,7 @@
<input type="checkbox" id="pwdNumbers" checked> 0-9
</label>
<label class="checkbox-label">
<input type="checkbox" id="pwdSymbols" checked> !@#
<input type="checkbox" id="pwdSymbols"> !@#
</label>
<label class="checkbox-label">
长度: <input type="number" id="pwdLength" value="16" min="8" max="64" style="width: 50px;">

View File

@@ -52,22 +52,49 @@ class TOTP {
}
}
// 随机名称库
// 随机名称库 - 多国家名字
const firstNames = [
// 英美名字
'James', 'John', 'Robert', 'Michael', 'William', 'David', 'Richard', 'Joseph',
'Thomas', 'Christopher', 'Mary', 'Patricia', 'Jennifer', 'Linda', 'Elizabeth',
'Barbara', 'Susan', 'Jessica', 'Sarah', 'Karen', 'Alex', 'Jordan', 'Taylor',
'Morgan', 'Casey', 'Riley', 'Quinn', 'Avery', 'Peyton', 'Cameron', 'Emma',
'Oliver', 'Liam', 'Noah', 'Sophia', 'Ava', 'Isabella', 'Mia', 'Charlotte',
'Benjamin', 'Alexander', 'Sebastian', 'Theodore', 'Victoria', 'Penelope'
'Benjamin', 'Alexander', 'Sebastian', 'Theodore', 'Victoria', 'Penelope',
// 泰国名字
'Somchai', 'Somsak', 'Sompong', 'Narong', 'Prasert', 'Wichai', 'Suchart',
'Pornthip', 'Siriporn', 'Siriwan', 'Napaporn', 'Kulap', 'Malai', 'Araya',
// 印度名字
'Rahul', 'Vikram', 'Arjun', 'Ravi', 'Amit', 'Suresh', 'Rajesh', 'Vijay',
'Priya', 'Ananya', 'Deepa', 'Kavita', 'Neha', 'Pooja', 'Sunita', 'Lakshmi',
// 希腊名字
'Alexandros', 'Dimitrios', 'Georgios', 'Konstantinos', 'Nikolaos', 'Panagiotis',
'Eleni', 'Maria', 'Aikaterini', 'Sofia', 'Anastasia', 'Theodora', 'Helena',
// 西班牙/拉丁名字
'Carlos', 'Miguel', 'Jose', 'Antonio', 'Luis', 'Fernando', 'Pablo',
'Maria', 'Carmen', 'Rosa', 'Ana', 'Lucia', 'Isabella', 'Elena',
// 意大利名字
'Marco', 'Luca', 'Matteo', 'Francesco', 'Giovanni', 'Alessandro', 'Andrea',
'Giulia', 'Francesca', 'Chiara', 'Sara', 'Valentina', 'Federica', 'Alessia'
];
const lastNames = [
// 英美姓氏
'Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis',
'Rodriguez', 'Martinez', 'Anderson', 'Taylor', 'Thomas', 'Moore', 'Jackson',
'Martin', 'Lee', 'Thompson', 'White', 'Harris', 'Clark', 'Lewis', 'Robinson',
'Walker', 'Young', 'King', 'Wright', 'Scott', 'Green', 'Baker', 'Adams',
'Nelson', 'Mitchell', 'Campbell', 'Roberts', 'Carter', 'Phillips', 'Evans'
'Nelson', 'Mitchell', 'Campbell', 'Roberts', 'Carter', 'Phillips', 'Evans',
// 泰国姓氏
'Srisawat', 'Suksawat', 'Charoenrat', 'Wongsuwan', 'Thongchai', 'Boonmee',
// 印度姓氏
'Sharma', 'Patel', 'Singh', 'Kumar', 'Reddy', 'Verma', 'Gupta', 'Nair', 'Rao',
// 希腊姓氏
'Papadopoulos', 'Konstantinidis', 'Nikolaidis', 'Georgiou', 'Christodoulou',
// 西班牙姓氏
'Gonzalez', 'Rodriguez', 'Martinez', 'Lopez', 'Hernandez', 'Sanchez', 'Ramirez',
// 意大利姓氏
'Rossi', 'Russo', 'Ferrari', 'Esposito', 'Bianchi', 'Romano', 'Colombo'
];
// 应用状态
@@ -102,6 +129,16 @@ document.addEventListener('DOMContentLoaded', () => {
generateRandomName();
generateRandomPassword();
setupEventListeners();
setupMemo();
initQRScanner();
// 检查jsQR是否可用
if (window.api.hasJsQR()) {
console.log('jsQR library loaded successfully');
} else {
console.warn('jsQR not available, QR scanning may not work');
}
setInterval(updateAllTOTP, 1000);
});
@@ -582,14 +619,7 @@ async function showTOTP(id, secret) {
}
async function copyValue(element, value) {
try {
await window.api.copyToClipboard(value);
element.classList.add('copied');
showToast('已复制', 'success');
setTimeout(() => { element.classList.remove('copied'); }, 300);
} catch (e) {
console.error('Copy failed:', e);
}
await copyToClipboard(value, { element });
}
async function updateTOTP(id, secret) {
@@ -630,10 +660,7 @@ async function copyTOTP(id) {
const code = await TOTP.generate(acc.totp_secret);
if (code) {
const codeEl = document.getElementById(`totp-${id}`);
codeEl.classList.add('copied');
await window.api.copyToClipboard(code);
showToast('已复制', 'success');
setTimeout(() => { codeEl.classList.remove('copied'); }, 300);
await copyToClipboard(code, { element: codeEl });
}
}
@@ -742,12 +769,19 @@ function resetForm() {
});
}
async function copyToClipboard(text) {
async function copyToClipboard(text, { element = null, successMessage = '已复制' } = {}) {
if (!text) return false;
try {
await window.api.copyToClipboard(text);
showToast('已复制', 'success');
if (element) {
element.classList.add('copied');
setTimeout(() => { element.classList.remove('copied'); }, 300);
}
showToast(successMessage, 'success');
return true;
} catch (e) {
console.error('Copy failed:', e);
return false;
}
}
@@ -767,24 +801,50 @@ async function openBrowser(idOrName) {
}
}
// 生成随机名称 (不含符号的长用户名)
// 生成随机字母片段
function getRandomLetters(count) {
const letters = 'abcdefghijklmnopqrstuvwxyz';
let result = '';
for (let i = 0; i < count; i++) {
result += letters[Math.floor(Math.random() * letters.length)];
}
return result;
}
// 生成随机数字片段
function getRandomNumbers(count) {
let result = '';
for (let i = 0; i < count; i++) {
result += Math.floor(Math.random() * 10);
}
return result;
}
// 生成随机名称 (基于姓名的用户名)
function generateRandomName() {
const firstName = firstNames[Math.floor(Math.random() * firstNames.length)];
const lastName = lastNames[Math.floor(Math.random() * lastNames.length)];
const suffix = Math.floor(Math.random() * 10000);
const year = 1990 + Math.floor(Math.random() * 30);
const randomLetters = getRandomLetters(Math.floor(Math.random() * 3) + 2); // 2-4个随机字母
const randomNumbers = getRandomNumbers(Math.floor(Math.random() * 3) + 2); // 2-4个随机数字
document.getElementById('randomFullName').value = `${firstName} ${lastName}`;
// 不含下划线和符号的用户名格式
// 改进的用户名格式 - 去掉了real, official等词增加随机元素
const usernameFormats = [
`${firstName.toLowerCase()}${lastName.toLowerCase()}${suffix}`,
`${firstName.toLowerCase()}${lastName.toLowerCase()}${year}`,
`${firstName.toLowerCase()}${lastName.toLowerCase()}`,
`${firstName[0].toLowerCase()}${lastName.toLowerCase()}${suffix}`,
`${lastName.toLowerCase()}${firstName.toLowerCase()}${suffix}`,
`${firstName.toLowerCase()}${lastName.toLowerCase()}official`,
`real${firstName.toLowerCase()}${lastName.toLowerCase()}`
`${firstName.toLowerCase()}${randomNumbers}`,
`${lastName.toLowerCase()}${randomNumbers}`,
`${firstName.toLowerCase()}${randomLetters}${randomNumbers}`,
`${firstName[0].toLowerCase()}${lastName.toLowerCase()}${randomNumbers}`,
`${firstName.toLowerCase()}${lastName[0].toLowerCase()}${suffix}`,
`${randomLetters}${firstName.toLowerCase()}${randomNumbers}`,
`${firstName.toLowerCase()}${year}${randomLetters}`
];
const username = usernameFormats[Math.floor(Math.random() * usernameFormats.length)];
document.getElementById('randomUsername').value = username;
@@ -834,3 +894,165 @@ function escapeJs(text) {
if (!text) return '';
return text.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"');
}
// ==================== 备忘录功能 ====================
function setupMemo() {
const memoIds = ['memo1', 'memo2', 'memo3', 'memo4'];
// 加载
const saved = localStorage.getItem('2fa-memos-v2');
if (saved) {
try {
const data = JSON.parse(saved);
memoIds.forEach(id => {
const el = document.getElementById(id);
if (el) el.value = data[id] || '';
});
} catch { /* ignore */ }
}
// 保存逻辑
const saveAll = () => {
const data = {};
memoIds.forEach(id => {
data[id] = document.getElementById(id).value;
});
localStorage.setItem('2fa-memos-v2', JSON.stringify(data));
};
memoIds.forEach(id => {
const el = document.getElementById(id);
if (el) el.addEventListener('input', saveAll);
});
// 全局复制函数
window.copyMemo = (index) => {
const val = document.getElementById(`memo${index}`).value.trim();
if (val) {
copyToClipboard(val, { successMessage: `已复制备忘录 ${index}` });
} else {
showToast('该备忘录为空', 'info');
}
};
}
// ==================== 截图识别二维码功能 ====================
// Initialize QR scan button
function initQRScanner() {
const scanBtn = document.getElementById('scanQrBtn');
if (scanBtn) {
scanBtn.addEventListener('click', startQRScan);
}
}
// Start QR scan (full screen)
async function startQRScan() {
showToast('Scanning screen for QR code...', 'info');
try {
// Get screen capture
const result = await window.api.captureScreen();
if (!result.success) {
showToast('Screenshot failed: ' + result.error, 'error');
return;
}
// Show scanning indicator
showScanningIndicator();
// Parse QR from screenshot
const secret = await parseQRFromImage(result.image, result.width, result.height);
// Hide indicator
hideScanningIndicator();
if (secret) {
document.getElementById('totpSecret').value = secret;
showToast('2FA key found: ' + secret.substring(0, 8) + '...', 'success');
} else {
showToast('No valid 2FA QR code found on screen', 'error');
}
} catch (e) {
console.error('QR scan error:', e);
hideScanningIndicator();
showToast('Scan failed: ' + e.message, 'error');
}
}
// Parse QR from image
async function parseQRFromImage(dataUrl, width, height) {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height);
const imageData = ctx.getImageData(0, 0, width, height);
const dataArray = Array.from(imageData.data);
console.log('QR: Decoding image', width, 'x', height);
const code = window.api.decodeQR(dataArray, width, height);
console.log('QR: Decode result:', code);
if (code) {
const secret = parseOtpAuthUrl(code);
console.log('QR: Parsed secret:', secret);
resolve(secret);
} else {
resolve(null);
}
};
img.onerror = () => resolve(null);
img.src = dataUrl;
});
}
// 解析otpauth URL提取密钥
function parseOtpAuthUrl(url) {
if (!url) return null;
// 格式: otpauth://totp/Label?secret=XXXX&issuer=XXX
const match = url.match(/otpauth:\/\/totp\/[^?]*\?.*secret=([A-Z2-7]+)/i);
if (match) {
return match[1].toUpperCase();
}
// 也尝试匹配hotp
const hotpMatch = url.match(/otpauth:\/\/hotp\/[^?]*\?.*secret=([A-Z2-7]+)/i);
if (hotpMatch) {
return hotpMatch[1].toUpperCase();
}
// 如果URL本身就是密钥格式
if (/^[A-Z2-7]{16,}$/i.test(url.trim())) {
return url.trim().toUpperCase();
}
return null;
}
// 显示扫描中指示器
function showScanningIndicator() {
const indicator = document.createElement('div');
indicator.className = 'scanning-indicator';
indicator.id = 'scanningIndicator';
indicator.innerHTML = `
<i class="fa-solid fa-qrcode"></i>
<span>正在识别二维码...</span>
`;
document.body.appendChild(indicator);
}
// 隐藏扫描中指示器
function hideScanningIndicator() {
const indicator = document.getElementById('scanningIndicator');
if (indicator) {
indicator.remove();
}
}

View File

@@ -72,40 +72,6 @@ body {
color: var(--accent);
}
.search-box {
position: relative;
}
.search-box .search-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--text-secondary);
font-size: 12px;
}
.search-box input {
width: 100%;
padding: 10px 14px 10px 36px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-size: 14px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.search-box input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.search-box input::placeholder {
color: var(--text-muted);
}
/* 内容区域:资料库 + 账号卡片 */
.content-area {
flex: 1;
@@ -333,151 +299,6 @@ body {
font-size: 10px;
}
.panel-header h1 {
font-size: 16px;
font-weight: 600;
letter-spacing: -0.02em;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.panel-header h1 i {
color: var(--accent);
}
.search-box {
position: relative;
margin-bottom: 12px;
}
.search-box .search-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
font-size: 12px;
}
.search-box input {
width: 100%;
padding: 10px 14px 10px 36px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-size: 14px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.search-box input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.search-box input::placeholder {
color: var(--text-muted);
}
/* 资料库侧边栏 */
.vault-sidebar {
display: flex;
flex-direction: column;
gap: 2px;
}
.vault-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
color: var(--text-secondary);
font-size: 13px;
}
.vault-row:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.vault-row.active {
background: rgba(59, 130, 246, 0.15);
color: var(--accent);
}
.vault-row.active i {
color: var(--accent);
}
.vault-row i {
width: 16px;
text-align: center;
font-size: 12px;
}
.vault-row span:first-of-type {
flex: 1;
}
.vault-count {
font-size: 11px;
padding: 2px 8px;
background: var(--bg-tertiary);
border-radius: 10px;
color: var(--text-muted);
}
.vault-row.add-vault {
border: 1px dashed var(--border-color);
margin-top: 4px;
}
.vault-row.add-vault:hover {
border-color: var(--accent);
color: var(--accent);
}
.vault-row .vault-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.15s;
}
.vault-row:hover .vault-actions {
opacity: 1;
}
.vault-action-btn {
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: 4px;
color: var(--text-secondary);
font-size: 10px;
cursor: pointer;
transition: all 0.15s;
}
.vault-action-btn:hover {
background: var(--bg-primary);
color: var(--text-primary);
}
.vault-action-btn.delete:hover {
color: var(--danger);
}
/* 账号列表 */
.accounts-list {
flex: 1;
@@ -1047,176 +868,6 @@ body {
border-radius: 3px;
}
/* ==================== 资料库区域 ==================== */
.vault-section {
padding: 16px;
border-bottom: 1px solid var(--border-color);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.section-header h3 {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
.btn-small {
padding: 4px 10px;
background: var(--accent);
border: none;
border-radius: 4px;
color: white;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.btn-small:hover {
background: var(--accent-hover);
}
.btn-small-secondary {
padding: 4px 10px;
background: transparent;
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.btn-small-secondary:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.vault-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 150px;
overflow-y: auto;
}
.vault-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: var(--bg-tertiary);
border: 1px solid var(--border-subtle);
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
}
.vault-item:hover {
border-color: var(--border-color);
}
.vault-item.active {
border-color: var(--accent);
background: rgba(59, 130, 246, 0.1);
}
.vault-item-icon {
font-size: 14px;
}
.vault-item-name {
flex: 1;
font-size: 12px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.vault-item-count {
font-size: 10px;
color: var(--text-muted);
padding: 2px 6px;
background: var(--bg-primary);
border-radius: 10px;
}
.vault-item-actions {
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 0.15s;
}
.vault-item:hover .vault-item-actions {
opacity: 1;
}
.vault-action-btn {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: 4px;
color: var(--text-secondary);
font-size: 10px;
cursor: pointer;
transition: all 0.15s;
}
.vault-action-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.vault-action-btn.delete:hover {
color: var(--danger);
}
.vault-form {
margin-top: 10px;
padding: 10px;
background: var(--bg-tertiary);
border-radius: 6px;
}
.vault-form-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.vault-form-row input {
flex: 1;
padding: 6px 10px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-primary);
font-size: 12px;
}
.vault-form-row input:focus {
outline: none;
border-color: var(--accent);
}
.vault-form-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
/* ==================== 迁移按钮 ==================== */
.card-action-btn.move {
font-size: 12px;
@@ -1302,9 +953,203 @@ body {
color: var(--accent);
}
.panel-header h1,
.form-section h2,
.generator-section h3 {
display: flex;
align-items: center;
gap: 8px;
}
.header-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.panel-header h1 {
margin-bottom: 0;
white-space: nowrap;
}
/* 4路紧凑型备忘录 */
.memo-bar {
flex: 1;
display: flex;
align-items: center;
gap: 4px;
max-width: 600px;
}
.memo-group {
flex: 1;
display: flex;
align-items: center;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 0 4px;
transition: all 0.2s;
min-width: 0;
}
.memo-group:focus-within {
border-color: var(--accent);
background: var(--bg-hover);
}
.memo-field {
flex: 1;
background: transparent;
border: none;
color: var(--text-primary);
font-size: 11px;
padding: 5px 4px;
min-width: 0;
outline: none;
text-align: center;
}
.memo-copy-btn {
font-size: 10px;
color: var(--text-muted);
cursor: pointer;
padding: 4px;
transition: all 0.15s;
opacity: 0.6;
flex-shrink: 0;
}
.memo-copy-btn:hover {
color: var(--accent);
opacity: 1;
}
.memo-divider {
width: 1px;
height: 10px;
background: var(--border-color);
flex-shrink: 0;
}
/* ==================== 资料库内搜索框 ==================== */
.vault-search-box {
position: relative;
padding: 8px;
border-bottom: 1px solid var(--border-subtle);
}
.vault-search-box .search-icon {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
font-size: 10px;
}
.vault-search-box input {
width: 100%;
padding: 8px 10px 8px 28px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-primary);
font-size: 12px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.vault-search-box input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
.vault-search-box input::placeholder {
color: var(--text-muted);
}
/* ==================== 2FA输入行 & 截图按钮 ==================== */
.totp-input-row {
display: flex;
gap: 6px;
}
.totp-input-row input {
flex: 1;
min-width: 0;
}
.btn-icon-qr {
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(59, 130, 246, 0.15) 0%, rgba(139, 92, 246, 0.15) 100%);
border: 1px solid rgba(59, 130, 246, 0.3);
border-radius: 6px;
color: var(--accent);
font-size: 15px;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
}
.btn-icon-qr:hover {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.25) 0%, rgba(139, 92, 246, 0.25) 100%);
border-color: var(--accent);
transform: scale(1.05);
}
.btn-icon-qr:active {
transform: scale(0.95);
}
.btn-icon-qr i {
color: var(--accent);
}
/* 扫描动画 */
.scanning-indicator {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 10001;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 24px 32px;
background: rgba(17, 17, 19, 0.95);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.scanning-indicator i {
font-size: 32px;
color: var(--accent);
animation: pulse 1s ease-in-out infinite;
}
.scanning-indicator span {
font-size: 14px;
color: var(--text-secondary);
}
@keyframes pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.6;
transform: scale(1.1);
}
}

View File

@@ -1,24 +1,24 @@
@echo off
echo 正在关闭 2FA Account Tool...
echo Closing 2FA Account Tool...
echo.
:: 终止所有 electron 相关进程
:: Kill all electron processes
taskkill /f /im electron.exe >nul 2>&1
if %errorlevel% equ 0 (
echo [已关闭] electron.exe
echo [CLOSED] electron.exe
) else (
echo [未运行] electron.exe
echo [NOT RUNNING] electron.exe
)
:: 可能有残留的 node 进程
:: Kill any remaining node processes
taskkill /f /im node.exe >nul 2>&1
if %errorlevel% equ 0 (
echo [已关闭] node.exe
echo [CLOSED] node.exe
) else (
echo [未运行] node.exe
echo [NOT RUNNING] node.exe
)
echo.
echo 程序已彻底关闭,现在可以安全删除 node_modules 文件夹了。
echo Program has been closed. You can now safely delete node_modules folder.
echo.
pause