566 lines
22 KiB
JavaScript
566 lines
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">📁</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">📄</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 rules = await api('GET', '/api/ip-rules');
|
|
const tbody = document.getElementById('ip-rules-tbody');
|
|
if (!rules || !rules.length) {
|
|
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#999;padding:40px">暂无IP规则,所有IP默认允许连接</td></tr>';
|
|
return;
|
|
}
|
|
tbody.innerHTML = rules.map(r => {
|
|
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><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.ip}', '${r.type}', '${(r.note||'').replace(/'/g, "\\'")}', ${r.enabled})">编辑</button>
|
|
<button class="btn btn-sm" onclick="toggleIPRule(${r.id}, '${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, ip, type, note, enabled) {
|
|
document.getElementById('ip-rule-modal-title').textContent = '编辑IP规则';
|
|
document.getElementById('ip-rule-edit-id').value = id;
|
|
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 = {
|
|
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, ip, type, note, enabled) {
|
|
try {
|
|
await api('PUT', '/api/ip-rules/' + id, {
|
|
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');
|
|
}
|
|
}
|