Files

576 řádky
22 KiB
JavaScript

// 全局变量
let token = localStorage.getItem('ftp_token') || '';
let currentPath = '';
let logPage = 1;
// --- API 封装 ---
async function api(method, url, data) {
const opts = {
method,
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
}
};
if (data && method !== 'GET') {
opts.body = JSON.stringify(data);
}
const resp = await fetch(url, opts);
const json = await resp.json();
if (resp.status === 401) {
token = '';
localStorage.removeItem('ftp_token');
showLogin();
throw new Error('登录已过期');
}
if (json.error) {
throw new Error(json.error);
}
return json.data;
}
// --- 工具函数 ---
function showToast(msg, type = 'success') {
const toast = document.getElementById('toast');
toast.textContent = msg;
toast.className = 'toast ' + type;
setTimeout(() => { toast.className = 'toast'; }, 3000);
}
function formatBytes(bytes) {
if (!bytes || bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + units[i];
}
function formatTime(t) {
if (!t) return '-';
return t.replace('T', ' ').substring(0, 19);
}
function showLogin() {
document.getElementById('login-page').style.display = 'flex';
document.getElementById('main-app').style.display = 'none';
}
function showMain() {
document.getElementById('login-page').style.display = 'none';
document.getElementById('main-app').style.display = 'flex';
}
// --- 登录 ---
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('login-username').value;
const password = document.getElementById('login-password').value;
try {
const data = await api('POST', '/api/login', { username, password });
token = data.token;
localStorage.setItem('ftp_token', token);
showMain();
loadDashboard();
} catch (err) {
showToast(err.message, 'error');
}
});
document.getElementById('logout-btn').addEventListener('click', () => {
token = '';
localStorage.removeItem('ftp_token');
showLogin();
});
// --- 导航切换 ---
document.querySelectorAll('.sidebar-menu li').forEach(li => {
li.addEventListener('click', () => {
document.querySelectorAll('.sidebar-menu li').forEach(el => el.classList.remove('active'));
li.classList.add('active');
const page = li.dataset.page;
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.getElementById('page-' + page).classList.add('active');
loadPage(page);
});
});
function loadPage(page) {
switch (page) {
case 'dashboard': loadDashboard(); break;
case 'users': loadUsers(); break;
case 'files': loadFiles(currentPath); break;
case 'logs': loadLogs(); break;
case 'online': loadOnline(); break;
case 'ip-rules': loadIPRules(); break;
case 'settings': loadConfig(); break;
}
}
// --- 仪表盘 ---
async function loadDashboard() {
try {
const stats = await api('GET', '/api/dashboard');
document.getElementById('stat-users').textContent = stats.total_users || 0;
document.getElementById('stat-enabled').textContent = stats.enabled_users || 0;
document.getElementById('stat-online').textContent = stats.online_users || 0;
document.getElementById('stat-today-logins').textContent = stats.today_logins || 0;
document.getElementById('stat-today-uploads').textContent = stats.today_uploads || 0;
document.getElementById('stat-today-downloads').textContent = stats.today_downloads || 0;
document.getElementById('stat-upload-bytes').textContent = formatBytes(stats.total_upload_bytes);
document.getElementById('stat-download-bytes').textContent = formatBytes(stats.total_download_bytes);
} catch (err) {
showToast(err.message, 'error');
}
}
// --- 用户管理 ---
async function loadUsers() {
try {
const users = await api('GET', '/api/users');
const tbody = document.getElementById('users-tbody');
tbody.innerHTML = users.map(u => `
<tr>
<td>${u.id}</td>
<td>${u.username}</td>
<td title="${u.home_dir}">${u.home_dir}</td>
<td>${u.permissions}</td>
<td>${u.quota_size > 0 ? formatBytes(u.quota_size) : '无限制'}</td>
<td><span class="${u.enabled ? 'status-enabled' : 'status-disabled'}">${u.enabled ? '启用' : '禁用'}</span></td>
<td>${formatTime(u.created_at)}</td>
<td class="action-btns">
<button class="btn btn-sm" onclick="editUser('${u.username}')">编辑</button>
<button class="btn btn-sm" onclick="resetPassword('${u.username}')">改密</button>
<button class="btn btn-sm btn-danger" onclick="deleteUser('${u.username}')">删除</button>
</td>
</tr>
`).join('');
} catch (err) {
showToast(err.message, 'error');
}
}
function showAddUser() {
document.getElementById('user-modal-title').textContent = '添加用户';
document.getElementById('user-edit-mode').value = 'add';
document.getElementById('user-form').reset();
document.getElementById('user-username').disabled = false;
document.getElementById('user-password').required = true;
document.getElementById('user-enabled').checked = true;
document.getElementById('user-modal').style.display = 'flex';
}
async function editUser(username) {
try {
const user = await api('GET', '/api/users/' + username);
document.getElementById('user-modal-title').textContent = '编辑用户';
document.getElementById('user-edit-mode').value = 'edit';
document.getElementById('user-username').value = user.username;
document.getElementById('user-username').disabled = true;
document.getElementById('user-password').value = '';
document.getElementById('user-password').required = false;
document.getElementById('user-homedir').value = user.home_dir;
document.getElementById('user-permissions').value = user.permissions;
document.getElementById('user-quota-size').value = Math.round(user.quota_size / 1024 / 1024);
document.getElementById('user-quota-files').value = user.quota_files;
document.getElementById('user-upload-rate').value = user.upload_rate;
document.getElementById('user-download-rate').value = user.download_rate;
document.getElementById('user-enabled').checked = user.enabled;
document.getElementById('user-modal').style.display = 'flex';
} catch (err) {
showToast(err.message, 'error');
}
}
document.getElementById('user-form').addEventListener('submit', async (e) => {
e.preventDefault();
const mode = document.getElementById('user-edit-mode').value;
const username = document.getElementById('user-username').value;
const password = document.getElementById('user-password').value;
const homeDir = document.getElementById('user-homedir').value;
const quotaMB = parseInt(document.getElementById('user-quota-size').value) || 0;
const data = {
username,
password,
home_dir: homeDir,
permissions: document.getElementById('user-permissions').value,
quota_size: quotaMB * 1024 * 1024,
quota_files: parseInt(document.getElementById('user-quota-files').value) || 0,
upload_rate: parseInt(document.getElementById('user-upload-rate').value) || 0,
download_rate: parseInt(document.getElementById('user-download-rate').value) || 0,
enabled: document.getElementById('user-enabled').checked
};
try {
if (mode === 'add') {
await api('POST', '/api/users', data);
showToast('用户添加成功');
} else {
await api('PUT', '/api/users/' + username, data);
if (password) {
await api('PUT', '/api/users/' + username + '/password', { password });
}
showToast('用户更新成功');
}
closeUserModal();
loadUsers();
} catch (err) {
showToast(err.message, 'error');
}
});
function closeUserModal() {
document.getElementById('user-modal').style.display = 'none';
}
async function deleteUser(username) {
if (!confirm('确定删除用户 "' + username + '" 吗?')) return;
try {
await api('DELETE', '/api/users/' + username);
showToast('用户已删除');
loadUsers();
} catch (err) {
showToast(err.message, 'error');
}
}
async function resetPassword(username) {
const password = prompt('请输入新密码:');
if (!password) return;
try {
await api('PUT', '/api/users/' + username + '/password', { password });
showToast('密码已更新');
} catch (err) {
showToast(err.message, 'error');
}
}
// --- 文件管理 ---
async function loadFiles(path) {
try {
const url = '/api/files?path=' + encodeURIComponent(path || '');
const data = await api('GET', url);
currentPath = data.path;
const tbody = document.getElementById('files-tbody');
let html = '';
// 返回上级
if (currentPath) {
html += `<tr>
<td colspan="4"><span class="dir-link" onclick="loadFiles('')">[根目录]</span></td>
</tr>`;
}
data.files.forEach(f => {
if (f.is_dir) {
html += `<tr>
<td><span class="file-icon">&#128193;</span><span class="dir-link" onclick="loadFiles('${f.path.replace(/\\/g, '\\\\')}')">${f.name}</span></td>
<td>-</td>
<td>${formatTime(f.mod_time)}</td>
<td class="action-btns">
<button class="btn btn-sm btn-danger" onclick="deleteFile('${f.path.replace(/\\/g, '\\\\')}', true)">删除</button>
</td>
</tr>`;
} else {
html += `<tr>
<td><span class="file-icon">&#128196;</span>${f.name}</td>
<td>${formatBytes(f.size)}</td>
<td>${formatTime(f.mod_time)}</td>
<td class="action-btns">
<button class="btn btn-sm btn-danger" onclick="deleteFile('${f.path.replace(/\\/g, '\\\\')}', false)">删除</button>
</td>
</tr>`;
}
});
if (!data.files.length && !currentPath) {
html = '<tr><td colspan="4" style="text-align:center;color:#999;padding:40px">目录为空</td></tr>';
}
tbody.innerHTML = html;
// 面包屑
document.getElementById('file-breadcrumb').innerHTML = '<span onclick="loadFiles(\'\')">/</span> ' +
currentPath.replace(/\\/g, '/').split('/').filter(Boolean).map((p, i, arr) => {
const subPath = arr.slice(0, i + 1).join('/');
return '<span onclick="loadFiles(\'' + subPath + '\')">' + p + '</span>';
}).join(' / ');
} catch (err) {
showToast(err.message, 'error');
}
}
async function deleteFile(path, isDir) {
const name = path.split(/[\\/]/).pop();
if (!confirm('确定删除 "' + name + '" 吗?' + (isDir ? '将删除文件夹内所有内容!' : ''))) return;
try {
await api('DELETE', '/api/files?path=' + encodeURIComponent(path));
showToast('删除成功');
loadFiles(currentPath);
} catch (err) {
showToast(err.message, 'error');
}
}
function uploadFile() {
document.getElementById('upload-form').reset();
document.getElementById('upload-modal').style.display = 'flex';
}
function closeUploadModal() {
document.getElementById('upload-modal').style.display = 'none';
}
document.getElementById('upload-form').addEventListener('submit', async (e) => {
e.preventDefault();
const fileInput = document.getElementById('upload-file');
if (!fileInput.files.length) return;
const formData = new FormData();
formData.append('file', fileInput.files[0]);
try {
const resp = await fetch('/api/upload?path=' + encodeURIComponent(currentPath), {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
body: formData
});
const json = await resp.json();
if (json.error) throw new Error(json.error);
showToast('上传成功');
closeUploadModal();
loadFiles(currentPath);
} catch (err) {
showToast(err.message, 'error');
}
});
async function createFolder() {
const name = prompt('请输入文件夹名称:');
if (!name) return;
try {
await api('POST', '/api/files', { path: currentPath, name, type: 'dir' });
showToast('文件夹已创建');
loadFiles(currentPath);
} catch (err) {
showToast(err.message, 'error');
}
}
// --- 日志 ---
async function loadLogs() {
const username = document.getElementById('log-username').value;
const action = document.getElementById('log-action').value;
try {
const data = await api('GET', `/api/logs?username=${encodeURIComponent(username)}&action=${action}&page=${logPage}&page_size=20`);
const tbody = document.getElementById('logs-tbody');
if (!data.logs || !data.logs.length) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#999;padding:40px">暂无日志</td></tr>';
} else {
tbody.innerHTML = data.logs.map(l => {
let statusClass = l.status === 'success' ? 'status-enabled' : 'status-disabled';
return `<tr>
<td>${formatTime(l.created_at)}</td>
<td>${l.username || '-'}</td>
<td>${l.ip || '-'}</td>
<td>${l.action}</td>
<td title="${l.file_path}">${l.file_path || '-'}</td>
<td>${l.file_size > 0 ? formatBytes(l.file_size) : '-'}</td>
<td><span class="${statusClass}">${l.status}</span></td>
</tr>`;
}).join('');
}
// 分页
const totalPages = Math.ceil(data.total / 20);
let pagHtml = `<button ${logPage <= 1 ? 'disabled' : ''} onclick="logPage=${logPage - 1};loadLogs()">上一页</button>`;
pagHtml += `<span style="padding:6px 12px">第 ${logPage} / ${totalPages || 1} 页 (共 ${data.total} 条)</span>`;
pagHtml += `<button ${logPage >= totalPages ? 'disabled' : ''} onclick="logPage=${logPage + 1};loadLogs()">下一页</button>`;
document.getElementById('logs-pagination').innerHTML = pagHtml;
} catch (err) {
showToast(err.message, 'error');
}
}
// --- 在线用户 ---
async function loadOnline() {
try {
const users = await api('GET', '/api/online');
const tbody = document.getElementById('online-tbody');
if (!users || !users.length) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:#999;padding:40px">暂无在线用户</td></tr>';
} else {
tbody.innerHTML = users.map(u => `
<tr>
<td>${u.username || '-'}</td>
<td>${u.ip}</td>
<td>${formatTime(u.login_time)}</td>
<td>${formatTime(u.last_activity)}</td>
<td>${u.current_dir || '-'}</td>
</tr>
`).join('');
}
} catch (err) {
showToast(err.message, 'error');
}
}
// --- 系统设置 ---
async function loadConfig() {
try {
const cfg = await api('GET', '/api/config');
document.getElementById('cfg-ftp-port').value = cfg.ftp.port;
document.getElementById('cfg-ftp-passive-min').value = cfg.ftp.passive_port_min;
document.getElementById('cfg-ftp-passive-max').value = cfg.ftp.passive_port_max;
document.getElementById('cfg-ftp-max-conn').value = cfg.ftp.max_connections;
document.getElementById('cfg-ftp-idle-timeout').value = cfg.ftp.idle_timeout;
document.getElementById('cfg-ftp-anonymous').value = String(cfg.ftp.enable_anonymous);
} catch (err) {
showToast(err.message, 'error');
}
}
async function saveConfig() {
const data = {
ftp: {
port: parseInt(document.getElementById('cfg-ftp-port').value),
passive_port_min: parseInt(document.getElementById('cfg-ftp-passive-min').value),
passive_port_max: parseInt(document.getElementById('cfg-ftp-passive-max').value),
max_connections: parseInt(document.getElementById('cfg-ftp-max-conn').value),
idle_timeout: parseInt(document.getElementById('cfg-ftp-idle-timeout').value),
enable_anonymous: document.getElementById('cfg-ftp-anonymous').value === 'true'
},
admin: {
username: document.getElementById('cfg-admin-username').value,
password: document.getElementById('cfg-admin-password').value
}
};
try {
await api('PUT', '/api/config', data);
showToast('配置已保存,部分设置需要重启生效');
} catch (err) {
showToast(err.message, 'error');
}
}
// --- 初始化 ---
if (token) {
showMain();
loadDashboard();
} else {
showLogin();
}
// --- IP规则管理 ---
async function loadIPRules() {
try {
const filter = document.getElementById('ip-rule-filter').value;
let url = '/api/ip-rules';
if (filter === 'global') url += '?username=__empty__';
else if (filter === 'user') url += '?username=__has__';
const rules = await api('GET', url);
const tbody = document.getElementById('ip-rules-tbody');
if (!rules || !rules.length) {
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:#999;padding:40px">暂无IP规则,所有IP默认允许连接</td></tr>';
return;
}
tbody.innerHTML = rules.map(r => {
const scopeLabel = r.username
? `<span style="color:#e67e22;font-weight:600">用户: ${r.username}</span>`
: '<span style="color:#667eea;font-weight:600">全局</span>';
const typeLabel = r.type === 'whitelist'
? '<span style="color:#667eea;font-weight:600">白名单</span>'
: '<span style="color:#ff4d4f;font-weight:600">黑名单</span>';
const statusLabel = r.enabled
? '<span class="status-enabled">启用</span>'
: '<span class="status-disabled">禁用</span>';
return `<tr>
<td>${r.id}</td>
<td>${scopeLabel}</td>
<td><code style="background:#f5f5f5;padding:2px 6px;border-radius:3px">${r.ip}</code></td>
<td>${typeLabel}</td>
<td>${r.note || '-'}</td>
<td>${statusLabel}</td>
<td>${formatTime(r.created_at)}</td>
<td class="action-btns">
<button class="btn btn-sm" onclick="editIPRule(${r.id}, '${r.username||''}', '${r.ip}', '${r.type}', '${(r.note||'').replace(/'/g, "\\'")}', ${r.enabled})">编辑</button>
<button class="btn btn-sm" onclick="toggleIPRule(${r.id}, '${r.username||''}', '${r.ip}', '${r.type}', '${(r.note||'').replace(/'/g, "\\'")}', ${r.enabled})">${r.enabled ? '禁用' : '启用'}</button>
<button class="btn btn-sm btn-danger" onclick="deleteIPRule(${r.id})">删除</button>
</td>
</tr>`;
}).join('');
} catch (err) {
showToast(err.message, 'error');
}
}
function showAddIPRule() {
document.getElementById('ip-rule-modal-title').textContent = '添加IP规则';
document.getElementById('ip-rule-edit-id').value = '';
document.getElementById('ip-rule-form').reset();
document.getElementById('ip-rule-enabled').checked = true;
document.getElementById('ip-rule-modal').style.display = 'flex';
}
function editIPRule(id, username, ip, type, note, enabled) {
document.getElementById('ip-rule-modal-title').textContent = '编辑IP规则';
document.getElementById('ip-rule-edit-id').value = id;
document.getElementById('ip-rule-username').value = username;
document.getElementById('ip-rule-ip').value = ip;
document.getElementById('ip-rule-type').value = type;
document.getElementById('ip-rule-note').value = note;
document.getElementById('ip-rule-enabled').checked = enabled;
document.getElementById('ip-rule-modal').style.display = 'flex';
}
function closeIPRuleModal() {
document.getElementById('ip-rule-modal').style.display = 'none';
}
document.getElementById('ip-rule-form').addEventListener('submit', async (e) => {
e.preventDefault();
const editId = document.getElementById('ip-rule-edit-id').value;
const data = {
username: document.getElementById('ip-rule-username').value,
ip: document.getElementById('ip-rule-ip').value,
type: document.getElementById('ip-rule-type').value,
note: document.getElementById('ip-rule-note').value,
enabled: document.getElementById('ip-rule-enabled').checked
};
try {
if (editId) {
await api('PUT', '/api/ip-rules/' + editId, data);
showToast('规则已更新');
} else {
await api('POST', '/api/ip-rules', data);
showToast('规则添加成功');
}
closeIPRuleModal();
loadIPRules();
} catch (err) {
showToast(err.message, 'error');
}
});
async function toggleIPRule(id, username, ip, type, note, enabled) {
try {
await api('PUT', '/api/ip-rules/' + id, {
username, ip, type, note, enabled: !enabled
});
showToast(!enabled ? '规则已启用' : '规则已禁用');
loadIPRules();
} catch (err) {
showToast(err.message, 'error');
}
}
async function deleteIPRule(id) {
if (!confirm('确定删除此IP规则吗?')) return;
try {
await api('DELETE', '/api/ip-rules/' + id);
showToast('规则已删除');
loadIPRules();
} catch (err) {
showToast(err.message, 'error');
}
}