// TOTP 生成器
class TOTP {
static async generate(secret) {
if (!secret) return null;
try {
const base32chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let bits = '';
const cleanSecret = secret.replace(/\s/g, '').toUpperCase();
for (let i = 0; i < cleanSecret.length; i++) {
const val = base32chars.indexOf(cleanSecret[i]);
if (val === -1) continue;
bits += val.toString(2).padStart(5, '0');
}
const bytes = new Uint8Array(Math.floor(bits.length / 8));
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(bits.substr(i * 8, 8), 2);
}
const epoch = Math.floor(Date.now() / 1000);
const timeStep = Math.floor(epoch / 30);
const timeBuffer = new ArrayBuffer(8);
const timeView = new DataView(timeBuffer);
timeView.setUint32(4, timeStep, false);
const key = await crypto.subtle.importKey(
'raw', bytes, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']
);
const signature = await crypto.subtle.sign('HMAC', key, timeBuffer);
const hmac = new Uint8Array(signature);
const offset = hmac[hmac.length - 1] & 0xf;
const binary = ((hmac[offset] & 0x7f) << 24) |
((hmac[offset + 1] & 0xff) << 16) |
((hmac[offset + 2] & 0xff) << 8) |
(hmac[offset + 3] & 0xff);
const otp = binary % 1000000;
return otp.toString().padStart(6, '0');
} catch (e) {
console.error('TOTP generation error:', e);
return null;
}
}
static getTimeRemaining() {
return 30 - (Math.floor(Date.now() / 1000) % 30);
}
}
// 随机名称库
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'
];
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'
];
// 应用状态
let accounts = [];
let vaults = [];
let currentPage = 1;
let totalPages = 1;
const itemsPerPage = 8;
let editingId = null;
let selectedVaultId = null;
let movingAccountId = null;
let totalAccountCount = 0;
// DOM 元素
const accountsList = document.getElementById('accountsList');
const accountForm = document.getElementById('accountForm');
const formTitle = document.getElementById('formTitle');
const searchInput = document.getElementById('searchInput');
const prevBtn = document.getElementById('prevBtn');
const nextBtn = document.getElementById('nextBtn');
const pageInfo = document.getElementById('pageInfo');
const cancelBtn = document.getElementById('cancelBtn');
const toast = document.getElementById('toast');
const vaultList = document.getElementById('vaultList');
const vaultFormSection = document.getElementById('vaultFormSection');
const moveModal = document.getElementById('moveModal');
// 初始化
document.addEventListener('DOMContentLoaded', () => {
loadVaults();
loadAccounts();
generateRandomName();
generateRandomPassword();
setupEventListeners();
setInterval(updateAllTOTP, 1000);
});
function setupEventListeners() {
accountForm.addEventListener('submit', handleFormSubmit);
cancelBtn.addEventListener('click', resetForm);
// 搜索
let searchTimeout;
searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
if (e.target.value.trim()) {
searchAccounts(e.target.value.trim());
} else {
loadAccounts();
}
}, 300);
});
// 分页
prevBtn.addEventListener('click', () => {
if (currentPage > 1) { currentPage--; loadAccounts(); }
});
nextBtn.addEventListener('click', () => {
if (currentPage < totalPages) { currentPage++; loadAccounts(); }
});
// 随机生成器
document.getElementById('rollName').addEventListener('click', generateRandomName);
document.getElementById('rollPassword').addEventListener('click', generateRandomPassword);
document.querySelectorAll('.generator-section .copy-btn').forEach(btn => {
btn.addEventListener('click', () => {
const targetId = btn.dataset.target;
const value = document.getElementById(targetId).value;
copyToClipboard(value);
});
});
document.getElementById('randomFullName').addEventListener('click', function () {
copyToClipboard(this.value);
});
document.getElementById('randomUsername').addEventListener('click', function () {
copyToClipboard(this.value);
});
['pwdUppercase', 'pwdLowercase', 'pwdNumbers', 'pwdSymbols', 'pwdLength'].forEach(id => {
document.getElementById(id).addEventListener('change', generateRandomPassword);
});
// 资料库表单 (保留用于兼容)
document.getElementById('cancelVaultBtn').addEventListener('click', () => {
vaultFormSection.style.display = 'none';
});
document.getElementById('saveVaultBtn').addEventListener('click', saveVault);
// 迁移模态框
document.getElementById('cancelMoveBtn').addEventListener('click', () => {
moveModal.style.display = 'none';
movingAccountId = null;
});
document.getElementById('confirmMoveBtn').addEventListener('click', confirmMoveAccount);
}
// ==================== 资料库操作 ====================
async function loadVaults() {
try {
vaults = await window.api.getVaults();
totalAccountCount = await window.api.getAccountCount(null);
renderVaultList();
} catch (e) {
console.error('Failed to load vaults:', e);
}
}
function renderVaultList() {
// 更新全部账号数量
document.getElementById('allCount').textContent = totalAccountCount;
// 更新全部账号选中状态
const allVaultRow = document.querySelector('.vault-row.all-vault');
if (allVaultRow) {
allVaultRow.classList.toggle('active', selectedVaultId === null);
}
vaultList.innerHTML = vaults.map(v => `
${escapeHtml(v.name)}
${v.account_count || 0}
${v.name !== '默认' ? `
` : ''}
`).join('');
// 绑定事件
vaultList.querySelectorAll('.vault-row').forEach(row => {
const id = parseInt(row.dataset.id);
const vault = vaults.find(v => v.id === id);
if (!vault) return;
let clickTimeout = null;
row.addEventListener('click', (e) => {
if (row.classList.contains('editing')) return;
if (e.target.closest('.vault-delete')) {
e.stopPropagation();
deleteVault(id);
return;
}
// 延迟执行单击,以便检测是否是双击
if (clickTimeout) {
clearTimeout(clickTimeout);
clickTimeout = null;
return;
}
clickTimeout = setTimeout(() => {
clickTimeout = null;
selectVault(id);
}, 200);
});
// 只有非默认资料库可以双击改名
if (vault.name !== '默认') {
row.addEventListener('dblclick', (e) => {
e.stopPropagation();
e.preventDefault();
// 取消单击
if (clickTimeout) {
clearTimeout(clickTimeout);
clickTimeout = null;
}
startEditVault(row, id);
});
}
});
}
function selectVault(id) {
selectedVaultId = id;
currentPage = 1;
loadAccounts();
renderVaultList();
}
// 双击资料库开始编辑
function startEditVault(row, id) {
const vault = vaults.find(v => v.id === id);
if (!vault || vault.name === '默认') return;
row.classList.add('editing');
const nameSpan = row.querySelector('.vault-name');
const originalName = vault.name;
const input = document.createElement('input');
input.type = 'text';
input.className = 'vault-edit';
input.value = originalName;
nameSpan.replaceWith(input);
// 延迟聚焦以确保元素已渲染
setTimeout(() => {
input.focus();
input.select();
}, 10);
let isFinished = false;
async function finishEdit() {
if (isFinished) return;
isFinished = true;
const newName = input.value.trim();
if (newName && newName !== originalName) {
try {
await window.api.updateVault(id, { name: newName });
showToast('资料库已更新', 'success');
} catch (e) {
console.error('Update vault failed:', e);
showToast('更新失败', 'error');
}
}
loadVaults();
}
input.addEventListener('blur', finishEdit);
input.addEventListener('keydown', (e) => {
e.stopPropagation();
if (e.key === 'Enter') {
input.blur();
} else if (e.key === 'Escape') {
input.value = originalName;
input.blur();
}
});
// 阻止输入框的点击事件冒泡
input.addEventListener('click', (e) => {
e.stopPropagation();
});
}
// 点击 + 新增资料库 (行内创建)
async function showAddVaultForm() {
// 检查是否已有输入框
if (vaultList.querySelector('.vault-new-row')) return;
// 创建新增行
const editRow = document.createElement('div');
editRow.className = 'vault-new-row';
editRow.innerHTML = `
`;
// 插入到 vaultList 末尾
vaultList.appendChild(editRow);
const input = editRow.querySelector('input');
// 延迟聚焦
setTimeout(() => {
input.focus();
}, 10);
let isFinished = false;
async function finishCreate() {
if (isFinished) return;
isFinished = true;
const name = input.value.trim();
if (name) {
try {
await window.api.addVault({ name });
showToast('资料库已创建', 'success');
} catch (e) {
console.error('Add vault failed:', e);
showToast('创建失败', 'error');
}
}
loadVaults();
}
input.addEventListener('blur', finishCreate);
input.addEventListener('keydown', (e) => {
e.stopPropagation();
if (e.key === 'Enter') {
input.blur();
} else if (e.key === 'Escape') {
loadVaults();
}
});
// 阻止点击事件冒泡
input.addEventListener('click', (e) => {
e.stopPropagation();
});
}
async function saveVault() {
const id = document.getElementById('vaultId').value;
const name = document.getElementById('vaultName').value.trim();
if (!name) {
showToast('请输入资料库名称', 'error');
return;
}
try {
if (id) {
await window.api.updateVault(parseInt(id), { name });
showToast('资料库已更新', 'success');
} else {
await window.api.addVault({ name });
showToast('资料库已创建', 'success');
}
vaultFormSection.style.display = 'none';
loadVaults();
} catch (e) {
console.error('Save vault failed:', e);
showToast('保存失败', 'error');
}
}
async function deleteVault(id) {
const vault = vaults.find(v => v.id === id);
if (!vault || vault.name === '默认') return;
if (!confirm(`确定要删除资料库「${vault.name}」吗?\n其中的账号将被移到默认资料库。`)) return;
try {
await window.api.deleteVault(id);
showToast('资料库已删除', 'success');
if (selectedVaultId === id) {
selectedVaultId = null;
}
loadVaults();
loadAccounts();
} catch (e) {
console.error('Delete vault failed:', e);
showToast('删除失败', 'error');
}
}
function openMoveDialog(accountId) {
movingAccountId = accountId;
const select = document.getElementById('moveToVault');
select.innerHTML = vaults.map(v =>
``
).join('');
moveModal.style.display = 'flex';
}
async function confirmMoveAccount() {
if (!movingAccountId) return;
const newVaultId = parseInt(document.getElementById('moveToVault').value);
try {
await window.api.moveAccountToVault(movingAccountId, newVaultId);
showToast('账号已迁移', 'success');
moveModal.style.display = 'none';
movingAccountId = null;
loadAccounts();
loadVaults();
} catch (e) {
console.error('Move failed:', e);
showToast('迁移失败', 'error');
}
}
// ==================== 账号操作 ====================
async function loadAccounts() {
try {
const count = await window.api.getAccountCount(selectedVaultId);
totalPages = Math.max(1, Math.ceil(count / itemsPerPage));
if (currentPage > totalPages) {
currentPage = totalPages;
}
accounts = await window.api.getAccounts(currentPage, itemsPerPage, selectedVaultId);
renderAccounts();
updatePagination();
} catch (e) {
console.error('Failed to load accounts:', e);
showToast('加载账号失败', 'error');
}
}
async function searchAccounts(query) {
try {
accounts = await window.api.searchAccounts(query, selectedVaultId);
currentPage = 1;
totalPages = 1;
renderAccounts();
updatePagination();
} catch (e) {
console.error('Search failed:', e);
}
}
function renderAccounts() {
if (accounts.length === 0) {
accountsList.innerHTML = `
`;
return;
}
accountsList.innerHTML = accounts.map(acc => {
const tags = acc.tags ? acc.tags.split(',').map(t => t.trim()).filter(t => t) : [];
const tagsHtml = tags.length > 0
? `${tags.map(t => `${escapeHtml(t)}`).join('')}
`
: '';
return `
${acc.username ? `
账号
${escapeHtml(acc.username)}
` : ''}
${acc.password ? `
密码
••••••••
` : ''}
${acc.email ? `
邮箱
${escapeHtml(acc.email)}
` : ''}
${acc.proxy ? `
代理
${escapeHtml(acc.proxy)}
` : ''}
${acc.totp_secret ? `
` : ''}
`;
}).join('');
// 不再自动更新TOTP,改为点击时才显示
}
// 保存已激活的TOTP账号列表
const activeTOTPs = new Map();
// 显示TOTP
async function showTOTP(id, secret) {
const container = document.getElementById(`totp-container-${id}`);
const visible = document.getElementById(`totp-visible-${id}`);
if (!container || !visible) return;
container.style.display = 'none';
visible.style.display = 'flex';
// 保存到激活列表
activeTOTPs.set(id, secret);
// 立即更新一次
await updateTOTP(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);
}
}
async function updateTOTP(id, secret) {
const codeEl = document.getElementById(`totp-${id}`);
const timerEl = document.getElementById(`timer-${id}`);
if (!codeEl || !timerEl) return;
const code = await TOTP.generate(secret);
if (code) {
codeEl.textContent = code.substring(0, 3) + ' ' + code.substring(3);
}
const remaining = TOTP.getTimeRemaining();
const progress = (remaining / 30) * 88;
const progressCircle = timerEl.querySelector('.progress');
progressCircle.style.strokeDashoffset = 88 - progress;
timerEl.classList.remove('warning', 'danger');
if (remaining <= 5) {
timerEl.classList.add('danger');
} else if (remaining <= 10) {
timerEl.classList.add('warning');
}
}
function updateAllTOTP() {
// 只更新已激活显示的TOTP
activeTOTPs.forEach((secret, id) => {
updateTOTP(id, secret);
});
}
async function copyTOTP(id) {
const acc = accounts.find(a => a.id === id);
if (!acc || !acc.totp_secret) return;
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);
}
}
function updatePagination() {
pageInfo.textContent = `${currentPage} / ${totalPages}`;
prevBtn.disabled = currentPage <= 1;
nextBtn.disabled = currentPage >= totalPages;
}
async function handleFormSubmit(e) {
e.preventDefault();
const username = document.getElementById('username').value.trim();
const tags = document.getElementById('tags').value.trim();
// 使用标签的第一个作为name,如果没有标签则使用用户名
const firstTag = tags ? tags.split(',')[0].trim() : '';
const autoName = firstTag || username || '未命名';
const account = {
vault_id: selectedVaultId,
name: autoName,
username: username,
password: document.getElementById('password').value,
totp_secret: document.getElementById('totpSecret').value.trim(),
tags: tags,
email: document.getElementById('email').value.trim(),
proxy: document.getElementById('proxy').value.trim(),
browser_id: document.getElementById('browserId').value.trim(),
notes: document.getElementById('notes').value.trim()
};
try {
if (editingId) {
const existingAcc = accounts.find(a => a.id === editingId);
if (existingAcc) {
account.vault_id = existingAcc.vault_id;
}
await window.api.updateAccount(editingId, account);
showToast('账号已更新', 'success');
} else {
await window.api.addAccount(account);
showToast('账号已添加', 'success');
}
resetForm();
loadAccounts();
loadVaults();
} catch (e) {
console.error('Save failed:', e);
showToast('保存失败', 'error');
}
}
function editAccount(id) {
const acc = accounts.find(a => a.id === id);
if (!acc) return;
editingId = id;
formTitle.innerHTML = ' 编辑账号';
document.getElementById('accountId').value = id;
document.getElementById('username').value = acc.username || '';
document.getElementById('password').value = acc.password || '';
document.getElementById('totpSecret').value = acc.totp_secret || '';
document.getElementById('email').value = acc.email || '';
document.getElementById('proxy').value = acc.proxy || '';
document.getElementById('browserId').value = acc.browser_id || '';
document.getElementById('tags').value = acc.tags || '';
document.getElementById('notes').value = acc.notes || '';
document.querySelectorAll('.account-card').forEach(card => {
card.classList.toggle('active', card.dataset.id == id);
});
document.getElementById('username').focus();
}
async function deleteAccount(id) {
if (!confirm('确定要删除这个账号吗?')) return;
try {
await window.api.deleteAccount(id);
showToast('账号已删除', 'success');
if (editingId === id) {
resetForm();
}
loadAccounts();
loadVaults();
} catch (e) {
console.error('Delete failed:', e);
showToast('删除失败', 'error');
}
}
function resetForm() {
editingId = null;
formTitle.innerHTML = ' 添加账号';
accountForm.reset();
document.getElementById('accountId').value = '';
document.querySelectorAll('.account-card').forEach(card => {
card.classList.remove('active');
});
}
async function copyToClipboard(text) {
try {
await window.api.copyToClipboard(text);
showToast('已复制', 'success');
} catch (e) {
console.error('Copy failed:', e);
}
}
// 打开 Browser 浏览器环境
async function openBrowser(idOrName) {
try {
const response = await fetch(`http://localhost:12138/api/open/${encodeURIComponent(idOrName)}`);
if (response.ok) {
showToast('正在启动 Browser 环境...', 'success');
} else {
const error = await response.text();
showToast(`启动失败: ${error}`, 'error');
}
} catch (e) {
console.error('Open browser failed:', e);
showToast('无法连接 Browser API,请确保 Browser 已启动并开启 API 服务', 'error');
}
}
// 生成随机名称 (不含符号的长用户名)
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);
document.getElementById('randomFullName').value = `${firstName} ${lastName}`;
// 不含下划线和符号的用户名格式
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()}`
];
const username = usernameFormats[Math.floor(Math.random() * usernameFormats.length)];
document.getElementById('randomUsername').value = username;
}
function generateRandomPassword() {
const length = parseInt(document.getElementById('pwdLength').value) || 16;
const useUppercase = document.getElementById('pwdUppercase').checked;
const useLowercase = document.getElementById('pwdLowercase').checked;
const useNumbers = document.getElementById('pwdNumbers').checked;
const useSymbols = document.getElementById('pwdSymbols').checked;
let chars = '';
if (useUppercase) chars += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
if (useLowercase) chars += 'abcdefghijklmnopqrstuvwxyz';
if (useNumbers) chars += '0123456789';
if (useSymbols) chars += '!@#$%^&*()_+-=[]{}|;:,.<>?';
if (!chars) chars = 'abcdefghijklmnopqrstuvwxyz';
let password = '';
const array = new Uint32Array(length);
crypto.getRandomValues(array);
for (let i = 0; i < length; i++) {
password += chars[array[i] % chars.length];
}
document.getElementById('randomPassword').value = password;
}
function showToast(message, type = 'info') {
toast.textContent = message;
toast.className = 'toast show';
if (type === 'success') toast.classList.add('success');
setTimeout(() => { toast.classList.remove('show'); }, 2000);
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function escapeJs(text) {
if (!text) return '';
return text.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"');
}