app.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575
  1. // 全局变量
  2. let token = localStorage.getItem('ftp_token') || '';
  3. let currentPath = '';
  4. let logPage = 1;
  5. // --- API 封装 ---
  6. async function api(method, url, data) {
  7. const opts = {
  8. method,
  9. headers: {
  10. 'Authorization': 'Bearer ' + token,
  11. 'Content-Type': 'application/json'
  12. }
  13. };
  14. if (data && method !== 'GET') {
  15. opts.body = JSON.stringify(data);
  16. }
  17. const resp = await fetch(url, opts);
  18. const json = await resp.json();
  19. if (resp.status === 401) {
  20. token = '';
  21. localStorage.removeItem('ftp_token');
  22. showLogin();
  23. throw new Error('登录已过期');
  24. }
  25. if (json.error) {
  26. throw new Error(json.error);
  27. }
  28. return json.data;
  29. }
  30. // --- 工具函数 ---
  31. function showToast(msg, type = 'success') {
  32. const toast = document.getElementById('toast');
  33. toast.textContent = msg;
  34. toast.className = 'toast ' + type;
  35. setTimeout(() => { toast.className = 'toast'; }, 3000);
  36. }
  37. function formatBytes(bytes) {
  38. if (!bytes || bytes === 0) return '0 B';
  39. const units = ['B', 'KB', 'MB', 'GB', 'TB'];
  40. const i = Math.floor(Math.log(bytes) / Math.log(1024));
  41. return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + units[i];
  42. }
  43. function formatTime(t) {
  44. if (!t) return '-';
  45. return t.replace('T', ' ').substring(0, 19);
  46. }
  47. function showLogin() {
  48. document.getElementById('login-page').style.display = 'flex';
  49. document.getElementById('main-app').style.display = 'none';
  50. }
  51. function showMain() {
  52. document.getElementById('login-page').style.display = 'none';
  53. document.getElementById('main-app').style.display = 'flex';
  54. }
  55. // --- 登录 ---
  56. document.getElementById('login-form').addEventListener('submit', async (e) => {
  57. e.preventDefault();
  58. const username = document.getElementById('login-username').value;
  59. const password = document.getElementById('login-password').value;
  60. try {
  61. const data = await api('POST', '/api/login', { username, password });
  62. token = data.token;
  63. localStorage.setItem('ftp_token', token);
  64. showMain();
  65. loadDashboard();
  66. } catch (err) {
  67. showToast(err.message, 'error');
  68. }
  69. });
  70. document.getElementById('logout-btn').addEventListener('click', () => {
  71. token = '';
  72. localStorage.removeItem('ftp_token');
  73. showLogin();
  74. });
  75. // --- 导航切换 ---
  76. document.querySelectorAll('.sidebar-menu li').forEach(li => {
  77. li.addEventListener('click', () => {
  78. document.querySelectorAll('.sidebar-menu li').forEach(el => el.classList.remove('active'));
  79. li.classList.add('active');
  80. const page = li.dataset.page;
  81. document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
  82. document.getElementById('page-' + page).classList.add('active');
  83. loadPage(page);
  84. });
  85. });
  86. function loadPage(page) {
  87. switch (page) {
  88. case 'dashboard': loadDashboard(); break;
  89. case 'users': loadUsers(); break;
  90. case 'files': loadFiles(currentPath); break;
  91. case 'logs': loadLogs(); break;
  92. case 'online': loadOnline(); break;
  93. case 'ip-rules': loadIPRules(); break;
  94. case 'settings': loadConfig(); break;
  95. }
  96. }
  97. // --- 仪表盘 ---
  98. async function loadDashboard() {
  99. try {
  100. const stats = await api('GET', '/api/dashboard');
  101. document.getElementById('stat-users').textContent = stats.total_users || 0;
  102. document.getElementById('stat-enabled').textContent = stats.enabled_users || 0;
  103. document.getElementById('stat-online').textContent = stats.online_users || 0;
  104. document.getElementById('stat-today-logins').textContent = stats.today_logins || 0;
  105. document.getElementById('stat-today-uploads').textContent = stats.today_uploads || 0;
  106. document.getElementById('stat-today-downloads').textContent = stats.today_downloads || 0;
  107. document.getElementById('stat-upload-bytes').textContent = formatBytes(stats.total_upload_bytes);
  108. document.getElementById('stat-download-bytes').textContent = formatBytes(stats.total_download_bytes);
  109. } catch (err) {
  110. showToast(err.message, 'error');
  111. }
  112. }
  113. // --- 用户管理 ---
  114. async function loadUsers() {
  115. try {
  116. const users = await api('GET', '/api/users');
  117. const tbody = document.getElementById('users-tbody');
  118. tbody.innerHTML = users.map(u => `
  119. <tr>
  120. <td>${u.id}</td>
  121. <td>${u.username}</td>
  122. <td title="${u.home_dir}">${u.home_dir}</td>
  123. <td>${u.permissions}</td>
  124. <td>${u.quota_size > 0 ? formatBytes(u.quota_size) : '无限制'}</td>
  125. <td><span class="${u.enabled ? 'status-enabled' : 'status-disabled'}">${u.enabled ? '启用' : '禁用'}</span></td>
  126. <td>${formatTime(u.created_at)}</td>
  127. <td class="action-btns">
  128. <button class="btn btn-sm" onclick="editUser('${u.username}')">编辑</button>
  129. <button class="btn btn-sm" onclick="resetPassword('${u.username}')">改密</button>
  130. <button class="btn btn-sm btn-danger" onclick="deleteUser('${u.username}')">删除</button>
  131. </td>
  132. </tr>
  133. `).join('');
  134. } catch (err) {
  135. showToast(err.message, 'error');
  136. }
  137. }
  138. function showAddUser() {
  139. document.getElementById('user-modal-title').textContent = '添加用户';
  140. document.getElementById('user-edit-mode').value = 'add';
  141. document.getElementById('user-form').reset();
  142. document.getElementById('user-username').disabled = false;
  143. document.getElementById('user-password').required = true;
  144. document.getElementById('user-enabled').checked = true;
  145. document.getElementById('user-modal').style.display = 'flex';
  146. }
  147. async function editUser(username) {
  148. try {
  149. const user = await api('GET', '/api/users/' + username);
  150. document.getElementById('user-modal-title').textContent = '编辑用户';
  151. document.getElementById('user-edit-mode').value = 'edit';
  152. document.getElementById('user-username').value = user.username;
  153. document.getElementById('user-username').disabled = true;
  154. document.getElementById('user-password').value = '';
  155. document.getElementById('user-password').required = false;
  156. document.getElementById('user-homedir').value = user.home_dir;
  157. document.getElementById('user-permissions').value = user.permissions;
  158. document.getElementById('user-quota-size').value = Math.round(user.quota_size / 1024 / 1024);
  159. document.getElementById('user-quota-files').value = user.quota_files;
  160. document.getElementById('user-upload-rate').value = user.upload_rate;
  161. document.getElementById('user-download-rate').value = user.download_rate;
  162. document.getElementById('user-enabled').checked = user.enabled;
  163. document.getElementById('user-modal').style.display = 'flex';
  164. } catch (err) {
  165. showToast(err.message, 'error');
  166. }
  167. }
  168. document.getElementById('user-form').addEventListener('submit', async (e) => {
  169. e.preventDefault();
  170. const mode = document.getElementById('user-edit-mode').value;
  171. const username = document.getElementById('user-username').value;
  172. const password = document.getElementById('user-password').value;
  173. const homeDir = document.getElementById('user-homedir').value;
  174. const quotaMB = parseInt(document.getElementById('user-quota-size').value) || 0;
  175. const data = {
  176. username,
  177. password,
  178. home_dir: homeDir,
  179. permissions: document.getElementById('user-permissions').value,
  180. quota_size: quotaMB * 1024 * 1024,
  181. quota_files: parseInt(document.getElementById('user-quota-files').value) || 0,
  182. upload_rate: parseInt(document.getElementById('user-upload-rate').value) || 0,
  183. download_rate: parseInt(document.getElementById('user-download-rate').value) || 0,
  184. enabled: document.getElementById('user-enabled').checked
  185. };
  186. try {
  187. if (mode === 'add') {
  188. await api('POST', '/api/users', data);
  189. showToast('用户添加成功');
  190. } else {
  191. await api('PUT', '/api/users/' + username, data);
  192. if (password) {
  193. await api('PUT', '/api/users/' + username + '/password', { password });
  194. }
  195. showToast('用户更新成功');
  196. }
  197. closeUserModal();
  198. loadUsers();
  199. } catch (err) {
  200. showToast(err.message, 'error');
  201. }
  202. });
  203. function closeUserModal() {
  204. document.getElementById('user-modal').style.display = 'none';
  205. }
  206. async function deleteUser(username) {
  207. if (!confirm('确定删除用户 "' + username + '" 吗?')) return;
  208. try {
  209. await api('DELETE', '/api/users/' + username);
  210. showToast('用户已删除');
  211. loadUsers();
  212. } catch (err) {
  213. showToast(err.message, 'error');
  214. }
  215. }
  216. async function resetPassword(username) {
  217. const password = prompt('请输入新密码:');
  218. if (!password) return;
  219. try {
  220. await api('PUT', '/api/users/' + username + '/password', { password });
  221. showToast('密码已更新');
  222. } catch (err) {
  223. showToast(err.message, 'error');
  224. }
  225. }
  226. // --- 文件管理 ---
  227. async function loadFiles(path) {
  228. try {
  229. const url = '/api/files?path=' + encodeURIComponent(path || '');
  230. const data = await api('GET', url);
  231. currentPath = data.path;
  232. const tbody = document.getElementById('files-tbody');
  233. let html = '';
  234. // 返回上级
  235. if (currentPath) {
  236. html += `<tr>
  237. <td colspan="4"><span class="dir-link" onclick="loadFiles('')">[根目录]</span></td>
  238. </tr>`;
  239. }
  240. data.files.forEach(f => {
  241. if (f.is_dir) {
  242. html += `<tr>
  243. <td><span class="file-icon">&#128193;</span><span class="dir-link" onclick="loadFiles('${f.path.replace(/\\/g, '\\\\')}')">${f.name}</span></td>
  244. <td>-</td>
  245. <td>${formatTime(f.mod_time)}</td>
  246. <td class="action-btns">
  247. <button class="btn btn-sm btn-danger" onclick="deleteFile('${f.path.replace(/\\/g, '\\\\')}', true)">删除</button>
  248. </td>
  249. </tr>`;
  250. } else {
  251. html += `<tr>
  252. <td><span class="file-icon">&#128196;</span>${f.name}</td>
  253. <td>${formatBytes(f.size)}</td>
  254. <td>${formatTime(f.mod_time)}</td>
  255. <td class="action-btns">
  256. <button class="btn btn-sm btn-danger" onclick="deleteFile('${f.path.replace(/\\/g, '\\\\')}', false)">删除</button>
  257. </td>
  258. </tr>`;
  259. }
  260. });
  261. if (!data.files.length && !currentPath) {
  262. html = '<tr><td colspan="4" style="text-align:center;color:#999;padding:40px">目录为空</td></tr>';
  263. }
  264. tbody.innerHTML = html;
  265. // 面包屑
  266. document.getElementById('file-breadcrumb').innerHTML = '<span onclick="loadFiles(\'\')">/</span> ' +
  267. currentPath.replace(/\\/g, '/').split('/').filter(Boolean).map((p, i, arr) => {
  268. const subPath = arr.slice(0, i + 1).join('/');
  269. return '<span onclick="loadFiles(\'' + subPath + '\')">' + p + '</span>';
  270. }).join(' / ');
  271. } catch (err) {
  272. showToast(err.message, 'error');
  273. }
  274. }
  275. async function deleteFile(path, isDir) {
  276. const name = path.split(/[\\/]/).pop();
  277. if (!confirm('确定删除 "' + name + '" 吗?' + (isDir ? '将删除文件夹内所有内容!' : ''))) return;
  278. try {
  279. await api('DELETE', '/api/files?path=' + encodeURIComponent(path));
  280. showToast('删除成功');
  281. loadFiles(currentPath);
  282. } catch (err) {
  283. showToast(err.message, 'error');
  284. }
  285. }
  286. function uploadFile() {
  287. document.getElementById('upload-form').reset();
  288. document.getElementById('upload-modal').style.display = 'flex';
  289. }
  290. function closeUploadModal() {
  291. document.getElementById('upload-modal').style.display = 'none';
  292. }
  293. document.getElementById('upload-form').addEventListener('submit', async (e) => {
  294. e.preventDefault();
  295. const fileInput = document.getElementById('upload-file');
  296. if (!fileInput.files.length) return;
  297. const formData = new FormData();
  298. formData.append('file', fileInput.files[0]);
  299. try {
  300. const resp = await fetch('/api/upload?path=' + encodeURIComponent(currentPath), {
  301. method: 'POST',
  302. headers: { 'Authorization': 'Bearer ' + token },
  303. body: formData
  304. });
  305. const json = await resp.json();
  306. if (json.error) throw new Error(json.error);
  307. showToast('上传成功');
  308. closeUploadModal();
  309. loadFiles(currentPath);
  310. } catch (err) {
  311. showToast(err.message, 'error');
  312. }
  313. });
  314. async function createFolder() {
  315. const name = prompt('请输入文件夹名称:');
  316. if (!name) return;
  317. try {
  318. await api('POST', '/api/files', { path: currentPath, name, type: 'dir' });
  319. showToast('文件夹已创建');
  320. loadFiles(currentPath);
  321. } catch (err) {
  322. showToast(err.message, 'error');
  323. }
  324. }
  325. // --- 日志 ---
  326. async function loadLogs() {
  327. const username = document.getElementById('log-username').value;
  328. const action = document.getElementById('log-action').value;
  329. try {
  330. const data = await api('GET', `/api/logs?username=${encodeURIComponent(username)}&action=${action}&page=${logPage}&page_size=20`);
  331. const tbody = document.getElementById('logs-tbody');
  332. if (!data.logs || !data.logs.length) {
  333. tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#999;padding:40px">暂无日志</td></tr>';
  334. } else {
  335. tbody.innerHTML = data.logs.map(l => {
  336. let statusClass = l.status === 'success' ? 'status-enabled' : 'status-disabled';
  337. return `<tr>
  338. <td>${formatTime(l.created_at)}</td>
  339. <td>${l.username || '-'}</td>
  340. <td>${l.ip || '-'}</td>
  341. <td>${l.action}</td>
  342. <td title="${l.file_path}">${l.file_path || '-'}</td>
  343. <td>${l.file_size > 0 ? formatBytes(l.file_size) : '-'}</td>
  344. <td><span class="${statusClass}">${l.status}</span></td>
  345. </tr>`;
  346. }).join('');
  347. }
  348. // 分页
  349. const totalPages = Math.ceil(data.total / 20);
  350. let pagHtml = `<button ${logPage <= 1 ? 'disabled' : ''} onclick="logPage=${logPage - 1};loadLogs()">上一页</button>`;
  351. pagHtml += `<span style="padding:6px 12px">第 ${logPage} / ${totalPages || 1} 页 (共 ${data.total} 条)</span>`;
  352. pagHtml += `<button ${logPage >= totalPages ? 'disabled' : ''} onclick="logPage=${logPage + 1};loadLogs()">下一页</button>`;
  353. document.getElementById('logs-pagination').innerHTML = pagHtml;
  354. } catch (err) {
  355. showToast(err.message, 'error');
  356. }
  357. }
  358. // --- 在线用户 ---
  359. async function loadOnline() {
  360. try {
  361. const users = await api('GET', '/api/online');
  362. const tbody = document.getElementById('online-tbody');
  363. if (!users || !users.length) {
  364. tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:#999;padding:40px">暂无在线用户</td></tr>';
  365. } else {
  366. tbody.innerHTML = users.map(u => `
  367. <tr>
  368. <td>${u.username || '-'}</td>
  369. <td>${u.ip}</td>
  370. <td>${formatTime(u.login_time)}</td>
  371. <td>${formatTime(u.last_activity)}</td>
  372. <td>${u.current_dir || '-'}</td>
  373. </tr>
  374. `).join('');
  375. }
  376. } catch (err) {
  377. showToast(err.message, 'error');
  378. }
  379. }
  380. // --- 系统设置 ---
  381. async function loadConfig() {
  382. try {
  383. const cfg = await api('GET', '/api/config');
  384. document.getElementById('cfg-ftp-port').value = cfg.ftp.port;
  385. document.getElementById('cfg-ftp-passive-min').value = cfg.ftp.passive_port_min;
  386. document.getElementById('cfg-ftp-passive-max').value = cfg.ftp.passive_port_max;
  387. document.getElementById('cfg-ftp-max-conn').value = cfg.ftp.max_connections;
  388. document.getElementById('cfg-ftp-idle-timeout').value = cfg.ftp.idle_timeout;
  389. document.getElementById('cfg-ftp-anonymous').value = String(cfg.ftp.enable_anonymous);
  390. } catch (err) {
  391. showToast(err.message, 'error');
  392. }
  393. }
  394. async function saveConfig() {
  395. const data = {
  396. ftp: {
  397. port: parseInt(document.getElementById('cfg-ftp-port').value),
  398. passive_port_min: parseInt(document.getElementById('cfg-ftp-passive-min').value),
  399. passive_port_max: parseInt(document.getElementById('cfg-ftp-passive-max').value),
  400. max_connections: parseInt(document.getElementById('cfg-ftp-max-conn').value),
  401. idle_timeout: parseInt(document.getElementById('cfg-ftp-idle-timeout').value),
  402. enable_anonymous: document.getElementById('cfg-ftp-anonymous').value === 'true'
  403. },
  404. admin: {
  405. username: document.getElementById('cfg-admin-username').value,
  406. password: document.getElementById('cfg-admin-password').value
  407. }
  408. };
  409. try {
  410. await api('PUT', '/api/config', data);
  411. showToast('配置已保存,部分设置需要重启生效');
  412. } catch (err) {
  413. showToast(err.message, 'error');
  414. }
  415. }
  416. // --- 初始化 ---
  417. if (token) {
  418. showMain();
  419. loadDashboard();
  420. } else {
  421. showLogin();
  422. }
  423. // --- IP规则管理 ---
  424. async function loadIPRules() {
  425. try {
  426. const filter = document.getElementById('ip-rule-filter').value;
  427. let url = '/api/ip-rules';
  428. if (filter === 'global') url += '?username=__empty__';
  429. else if (filter === 'user') url += '?username=__has__';
  430. const rules = await api('GET', url);
  431. const tbody = document.getElementById('ip-rules-tbody');
  432. if (!rules || !rules.length) {
  433. tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:#999;padding:40px">暂无IP规则,所有IP默认允许连接</td></tr>';
  434. return;
  435. }
  436. tbody.innerHTML = rules.map(r => {
  437. const scopeLabel = r.username
  438. ? `<span style="color:#e67e22;font-weight:600">用户: ${r.username}</span>`
  439. : '<span style="color:#667eea;font-weight:600">全局</span>';
  440. const typeLabel = r.type === 'whitelist'
  441. ? '<span style="color:#667eea;font-weight:600">白名单</span>'
  442. : '<span style="color:#ff4d4f;font-weight:600">黑名单</span>';
  443. const statusLabel = r.enabled
  444. ? '<span class="status-enabled">启用</span>'
  445. : '<span class="status-disabled">禁用</span>';
  446. return `<tr>
  447. <td>${r.id}</td>
  448. <td>${scopeLabel}</td>
  449. <td><code style="background:#f5f5f5;padding:2px 6px;border-radius:3px">${r.ip}</code></td>
  450. <td>${typeLabel}</td>
  451. <td>${r.note || '-'}</td>
  452. <td>${statusLabel}</td>
  453. <td>${formatTime(r.created_at)}</td>
  454. <td class="action-btns">
  455. <button class="btn btn-sm" onclick="editIPRule(${r.id}, '${r.username||''}', '${r.ip}', '${r.type}', '${(r.note||'').replace(/'/g, "\\'")}', ${r.enabled})">编辑</button>
  456. <button class="btn btn-sm" onclick="toggleIPRule(${r.id}, '${r.username||''}', '${r.ip}', '${r.type}', '${(r.note||'').replace(/'/g, "\\'")}', ${r.enabled})">${r.enabled ? '禁用' : '启用'}</button>
  457. <button class="btn btn-sm btn-danger" onclick="deleteIPRule(${r.id})">删除</button>
  458. </td>
  459. </tr>`;
  460. }).join('');
  461. } catch (err) {
  462. showToast(err.message, 'error');
  463. }
  464. }
  465. function showAddIPRule() {
  466. document.getElementById('ip-rule-modal-title').textContent = '添加IP规则';
  467. document.getElementById('ip-rule-edit-id').value = '';
  468. document.getElementById('ip-rule-form').reset();
  469. document.getElementById('ip-rule-enabled').checked = true;
  470. document.getElementById('ip-rule-modal').style.display = 'flex';
  471. }
  472. function editIPRule(id, username, ip, type, note, enabled) {
  473. document.getElementById('ip-rule-modal-title').textContent = '编辑IP规则';
  474. document.getElementById('ip-rule-edit-id').value = id;
  475. document.getElementById('ip-rule-username').value = username;
  476. document.getElementById('ip-rule-ip').value = ip;
  477. document.getElementById('ip-rule-type').value = type;
  478. document.getElementById('ip-rule-note').value = note;
  479. document.getElementById('ip-rule-enabled').checked = enabled;
  480. document.getElementById('ip-rule-modal').style.display = 'flex';
  481. }
  482. function closeIPRuleModal() {
  483. document.getElementById('ip-rule-modal').style.display = 'none';
  484. }
  485. document.getElementById('ip-rule-form').addEventListener('submit', async (e) => {
  486. e.preventDefault();
  487. const editId = document.getElementById('ip-rule-edit-id').value;
  488. const data = {
  489. username: document.getElementById('ip-rule-username').value,
  490. ip: document.getElementById('ip-rule-ip').value,
  491. type: document.getElementById('ip-rule-type').value,
  492. note: document.getElementById('ip-rule-note').value,
  493. enabled: document.getElementById('ip-rule-enabled').checked
  494. };
  495. try {
  496. if (editId) {
  497. await api('PUT', '/api/ip-rules/' + editId, data);
  498. showToast('规则已更新');
  499. } else {
  500. await api('POST', '/api/ip-rules', data);
  501. showToast('规则添加成功');
  502. }
  503. closeIPRuleModal();
  504. loadIPRules();
  505. } catch (err) {
  506. showToast(err.message, 'error');
  507. }
  508. });
  509. async function toggleIPRule(id, username, ip, type, note, enabled) {
  510. try {
  511. await api('PUT', '/api/ip-rules/' + id, {
  512. username, ip, type, note, enabled: !enabled
  513. });
  514. showToast(!enabled ? '规则已启用' : '规则已禁用');
  515. loadIPRules();
  516. } catch (err) {
  517. showToast(err.message, 'error');
  518. }
  519. }
  520. async function deleteIPRule(id) {
  521. if (!confirm('确定删除此IP规则吗?')) return;
  522. try {
  523. await api('DELETE', '/api/ip-rules/' + id);
  524. showToast('规则已删除');
  525. loadIPRules();
  526. } catch (err) {
  527. showToast(err.message, 'error');
  528. }
  529. }