/* ========================================================================= 路由拓扑 · 管理控制台(独立页面) 页面地址:web/admin.html,资源相对 web/,API 相对站点根(../index.php) ========================================================================= */ (function () { 'use strict'; /* ============================================================ 基础工具 ============================================================ */ function $(id) { return document.getElementById(id); } function esc(s) { return String(s === null || s === undefined ? '' : s).replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); } function qs(obj) { const parts = []; for (const k in obj) { if (Object.prototype.hasOwnProperty.call(obj, k) && obj[k] !== '' && obj[k] !== null && obj[k] !== undefined) { parts.push(encodeURIComponent(k) + '=' + encodeURIComponent(obj[k])); } } return parts.length ? ('?' + parts.join('&')) : ''; } function fmtTime(iso) { if (!iso) { return '—'; } const d = new Date(iso); if (isNaN(d.getTime())) { return String(iso); } const p = function (n) { return n < 10 ? '0' + n : '' + n; }; return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()); } function debounce(fn, ms) { let t = null; return function () { const self = this, args = arguments; clearTimeout(t); t = setTimeout(function () { fn.apply(self, args); }, ms || 260); }; } function ts() { const d = new Date(), p = function (n) { return n < 10 ? '0' + n : '' + n; }; return d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '-' + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds()); } function downloadJson(obj, filename) { const str = JSON.stringify(obj, null, 2); const blob = new Blob([str], { type: 'application/json;charset=utf-8' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(function () { URL.revokeObjectURL(a.href); }, 8000); } /* ============================================================ API 客户端(独立页,base = ../index.php) ============================================================ */ const API = { req: function (method, path, body) { const opt = { method: method, credentials: 'same-origin', headers: {} }; if (body !== undefined) { opt.headers['Content-Type'] = 'application/json'; opt.body = JSON.stringify(body); } return fetch('../index.php?r=' + encodeURIComponent('/api' + path), opt).then(function (r) { return r.text().then(function (txt) { let data = null; try { data = txt ? JSON.parse(txt) : null; } catch (e) { data = null; } if (!r.ok || (data && data.ok === false)) { const msg = (data && data.error) ? data.error : ('请求失败 (' + r.status + ')'); const err = new Error(msg); err.status = r.status; throw err; } return data || {}; }); }); }, me: function () { return this.req('GET', '/me'); }, dashboard: function () { return this.req('GET', '/dashboard'); }, logs: function (p) { return this.req('GET', '/logs' + qs(p)); }, listUsers: function (p) { return this.req('GET', '/users' + qs(p)); }, createUser: function (p) { return this.req('POST', '/users', p); }, updateUser: function (id, p) { return this.req('PUT', '/users/' + encodeURIComponent(id), p); }, resetUserPassword: function (id, p) { return this.req('PUT', '/users/' + encodeURIComponent(id) + '/password', p); }, deleteUser: function (id) { return this.req('DELETE', '/users/' + encodeURIComponent(id)); }, adminTopologies: function (p) { return this.req('GET', '/admin/topologies' + qs(p)); }, exportTopos: function (ids) { const q = (ids && ids.length) ? ('?ids=' + encodeURIComponent(ids.join(','))) : ''; return this.req('GET', '/admin/topologies/export' + q); }, siteSettings: function () { return this.req('GET', '/admin/settings'); }, saveSiteSettings: function (p) { return this.req('PUT', '/admin/settings', p); }, setVisibility: function (id, vis) { return this.req('PUT', '/topologies/' + encodeURIComponent(id) + '/permission', { visibility: vis }); }, renameTopo: function (id, name) { return this.req('PUT', '/topologies/' + encodeURIComponent(id) + '/rename', { name: name }); }, deleteTopo: function (id) { return this.req('DELETE', '/topologies/' + encodeURIComponent(id)); }, groups: function () { return this.req('GET', '/groups'); }, createGroup: function (p) { return this.req('POST', '/groups', p); }, updateGroup: function (id, p) { return this.req('PUT', '/groups/' + encodeURIComponent(id), p); }, deleteGroup: function (id) { return this.req('DELETE', '/groups/' + encodeURIComponent(id)); }, groupMembers: function (id) { return this.req('GET', '/groups/' + encodeURIComponent(id) + '/members'); }, setGroupMembers: function (id, ids) { return this.req('PUT', '/groups/' + encodeURIComponent(id) + '/members', { userIds: ids }); } }; /* ============================================================ 提示条 / 站内弹窗 ============================================================ */ let toastTimer = null; function toast(msg, type) { const el = $('toast'); el.textContent = msg; el.className = 'toast' + (type ? (' ' + type) : ''); el.classList.add('show'); clearTimeout(toastTimer); toastTimer = setTimeout(function () { el.classList.remove('show'); }, 2600); } function openOverlay(id) { const o = $(id); if (o) { o.hidden = false; } } function closeOverlay(id) { const o = $(id); if (o) { o.hidden = true; } } let _dlgResolve = null; let _dlgInput = false; function _dlgFinish(result) { closeOverlay('confirmOverlay'); const r = _dlgResolve; _dlgResolve = null; if (r) { r(result); } } function _dlgConfirmOk() { if (_dlgInput) { const v = $('confirmField').value.trim(); if (!v) { $('confirmErr').textContent = '请输入内容'; return; } _dlgFinish(v); } else { _dlgFinish(true); } } function _uiDialog(o) { return new Promise(function (resolve) { _dlgResolve = resolve; _dlgInput = !!o.input; $('confirmTitle').textContent = o.title || '请确认'; const msg = $('confirmMsg'); msg.textContent = o.message || ''; msg.hidden = !o.message; const okBtn = $('confirmOk'); okBtn.textContent = o.okText || '确定'; okBtn.className = 'btn ' + (o.danger ? 'danger' : 'primary'); const wrap = $('confirmFieldWrap'); const field = $('confirmField'); if (o.input) { wrap.hidden = false; $('confirmFieldLabel').textContent = o.input.label || ''; field.type = o.input.type || 'text'; field.placeholder = o.input.placeholder || ''; field.value = o.input.value || ''; } else { wrap.hidden = true; } $('confirmErr').textContent = ''; openOverlay('confirmOverlay'); setTimeout(function () { if (o.input) { field.focus(); field.select(); } else { okBtn.focus(); } }, 30); }); } function uiConfirm(message, opts) { opts = opts || {}; return _uiDialog({ title: opts.title || '请确认', message: message, okText: opts.okText || '确定', danger: !!opts.danger }); } function uiPrompt(label, opts) { opts = opts || {}; return _uiDialog({ title: opts.title || '请输入', message: opts.message || '', okText: opts.okText || '确定', input: { label: label, placeholder: opts.placeholder || '', type: opts.type || 'text', value: opts.value || '' } }); } /* ============================================================ 渲染小工具 ============================================================ */ const ACTION_LABEL = { login: '登录', logout: '退出', register: '注册', password_change: '修改密码', user_create: '创建用户', user_update: '更新用户', user_delete: '删除用户', user_reset_password: '重置用户密码', topo_create: '创建拓扑', topo_delete: '删除拓扑', topo_permission: '设置可见性', topo_rename: '重命名拓扑', site_settings: '网站设置' }; function actionLabel(a) { return ACTION_LABEL[a] || a || '—'; } function statCard(label, num, cls) { return '
' + '
' + esc(num === null || num === undefined ? 0 : num) + '
' + '
' + esc(label) + '
'; } function miniRow(time, content) { return '
' + esc(time) + '' + content + '
'; } function emptyRow(msg) { return '
' + esc(msg) + '
'; } function renderPager(containerId, total, page, pageSize, onGo) { const totalPages = Math.max(1, Math.ceil((total || 0) / pageSize)); const el = $(containerId); el.innerHTML = '共 ' + esc(total || 0) + ' 条 · 第 ' + esc(page) + ' / ' + esc(totalPages) + ' 页' + '' + ''; const prev = el.querySelector('[data-go="prev"]'); const next = el.querySelector('[data-go="next"]'); if (prev) { prev.addEventListener('click', function () { if (page > 1) { onGo(page - 1); } }); } if (next) { next.addEventListener('click', function () { if (page < totalPages) { onGo(page + 1); } }); } } /* ============================================================ 状态 ============================================================ */ const state = { view: 'overview', currentUser: null, usersTab: 'list', groups: [], users: { page: 1, pageSize: 10, q: '', total: 0 }, topos: { page: 1, pageSize: 10, q: '', owner: '', total: 0 }, logs: { page: 1, pageSize: 12, q: '', total: 0 } }; function handleErr(err) { toast((err && err.message) || '操作失败', 'err'); if (err && (err.status === 401 || err.status === 403)) { setTimeout(function () { window.location.href = '../index.php'; }, 1200); } } /* ============================================================ 视图切换 ============================================================ */ function switchView(name) { state.view = name; ['overview', 'users', 'topo', 'settings', 'logs'].forEach(function (v) { const el = $('view-' + v); if (el) { el.hidden = (v !== name); } }); Array.prototype.forEach.call(document.querySelectorAll('.cnav-item'), function (it) { it.classList.toggle('active', it.getAttribute('data-view') === name); }); if (name === 'overview') { loadOverview(); } else if (name === 'users') { loadUserTab(); } else if (name === 'topo') { loadTopoOwnerOptions(); loadTopoAdmin(); } else if (name === 'settings') { loadSettings(); } else if (name === 'logs') { loadLogs(); } } /* 用户管理:用户列表 / 分组管理 子页切换 */ function applyUserTab() { const isGroups = (state.usersTab === 'groups'); $('userTab-list').hidden = isGroups; $('userTab-groups').hidden = !isGroups; Array.prototype.forEach.call(document.querySelectorAll('#userTabs .tab'), function (t) { t.classList.toggle('active', t.getAttribute('data-tab') === state.usersTab); }); } function loadUserTab() { applyUserTab(); if (state.usersTab === 'groups') { loadGroups(); } else { loadUsers(); } } /* ============================================================ 概览 ============================================================ */ function loadOverview() { API.dashboard().then(function (d) { const s = d.stats || {}; $('statCards').innerHTML = [ statCard('用户总数', s.userCount, ''), statCard('管理员', s.adminCount, 'accent'), statCard('已禁用', s.disabledCount, 'amber'), statCard('拓扑总数', s.topoCount, ''), statCard('公开拓扑', s.publicCount, 'green'), statCard('今日新增拓扑', s.todayTopoCount, ''), statCard('今日登录', s.todayLoginCount, 'accent'), statCard('今日操作', s.todayLogCount, '') ].join(''); const logs = d.recentLogs || []; $('recentLogs').innerHTML = logs.length ? logs.map(function (l) { return miniRow(fmtTime(l.createdAt), esc((l.username || '系统') + ' · ' + (l.detail || actionLabel(l.action)))); }).join('') : emptyRow('暂无记录'); const topos = d.recentTopos || []; $('recentTopos').innerHTML = topos.length ? topos.map(function (t) { return miniRow(fmtTime(t.updatedAt), esc(t.name + ' · ' + (t.ownerName || '未知'))); }).join('') : emptyRow('暂无拓扑'); }).catch(handleErr); } /* ============================================================ 用户管理 ============================================================ */ function loadUsers() { const q = state.users; API.listUsers({ page: q.page, pageSize: q.pageSize, q: q.q }).then(function (d) { const rows = d.users || []; $('userRows').innerHTML = rows.length ? rows.map(function (u) { const roleBadge = (u.role === 'admin') ? '管理员' : '普通用户'; const status = u.disabled ? '已禁用' : '正常'; const pwdFlag = u.mustChangePassword ? ' 待改密' : ''; const protFlag = u.protected ? ' 受保护' : ''; return '' + '' + esc(u.username) + protFlag + '' + '' + roleBadge + '' + '' + status + pwdFlag + '' + '' + esc(u.loginCount) + '' + '' + esc(fmtTime(u.lastLoginAt)) + '' + '' + esc(fmtTime(u.createdAt)) + '' + '' + userActions(u) + '' + ''; }).join('') : '暂无用户'; renderPager('userPager', d.total, q.page, q.pageSize, function (p) { q.page = p; loadUsers(); }); }).catch(handleErr); } function userActions(u) { const isSelf = state.currentUser && state.currentUser.id === u.id; if (u.protected) { return '受保护账号,不可被修改'; } const btns = []; btns.push(''); btns.push(''); if (!isSelf) { btns.push(''); btns.push(''); } return btns.join(''); } function onUserRowClick(e) { const b = e.target.closest('button[data-act]'); if (!b) { return; } const act = b.getAttribute('data-act'); const id = b.getAttribute('data-id'); if (act === 'pwd') { openResetDialog(id, b.getAttribute('data-name')); } else if (act === 'role') { const role = b.getAttribute('data-role'); const label = (role === 'admin' ? '管理员' : '普通用户'); uiConfirm('确认将该用户角色改为「' + label + '」?', { title: '修改角色' }).then(function (ok) { if (!ok) { return; } API.updateUser(id, { role: role }).then(function () { toast('角色已更新', 'ok'); loadUsers(); }).catch(handleErr); }); } else if (act === 'toggle') { const disabled = b.getAttribute('data-disabled') === '1'; uiConfirm(disabled ? '确认禁用该账号?禁用后该用户将无法登录。' : '确认启用该账号?', { title: disabled ? '禁用账号' : '启用账号', danger: disabled }).then(function (ok) { if (!ok) { return; } API.updateUser(id, { disabled: disabled }).then(function () { toast(disabled ? '已禁用' : '已启用', 'ok'); loadUsers(); }).catch(handleErr); }); } else if (act === 'del') { uiConfirm('确认删除用户「' + b.getAttribute('data-name') + '」?该操作不可恢复。', { title: '删除用户', danger: true, okText: '删除' }).then(function (ok) { if (!ok) { return; } API.deleteUser(id).then(function () { toast('用户已删除', 'ok'); loadUsers(); }).catch(handleErr); }); } } function openUserDialog() { $('userDlgTitle').textContent = '新建用户'; $('uName').value = ''; $('uPass').value = ''; $('uRole').value = 'user'; $('userErr').textContent = ''; openOverlay('userOverlay'); setTimeout(function () { $('uName').focus(); }, 30); } function submitUserDialog() { const name = $('uName').value.trim(); const pass = $('uPass').value; const role = $('uRole').value; $('userErr').textContent = ''; if (!name) { $('userErr').textContent = '请输入用户名'; return; } if (!pass) { $('userErr').textContent = '请输入密码'; return; } $('btnSaveUser').disabled = true; API.createUser({ username: name, password: pass, role: role }).then(function () { $('btnSaveUser').disabled = false; closeOverlay('userOverlay'); toast('用户已创建', 'ok'); state.users.page = 1; loadUsers(); }).catch(function (err) { $('btnSaveUser').disabled = false; $('userErr').textContent = (err && err.message) || '创建失败'; }); } /* ============================================================ 重置用户密码(需先验证管理员本人密码) ============================================================ */ let resetUserId = null; function openResetDialog(id, username) { resetUserId = id; $('resetTip').textContent = '为用户「' + username + '」设置新密码,该用户下次登录需再次修改。'; $('resetAdminPw').value = ''; $('resetNewPw').value = ''; $('resetNewPw2').value = ''; $('resetShow').checked = false; ['resetAdminPw', 'resetNewPw', 'resetNewPw2'].forEach(function (i) { $(i).type = 'password'; }); $('resetErr').textContent = ''; openOverlay('resetOverlay'); setTimeout(function () { $('resetAdminPw').focus(); }, 30); } function submitResetDialog() { if (!resetUserId) { return; } const adminPw = $('resetAdminPw').value; const np = $('resetNewPw').value; const np2 = $('resetNewPw2').value; const err = $('resetErr'); err.textContent = ''; if (!adminPw) { err.textContent = '请输入您(管理员)的密码'; return; } if (!np) { err.textContent = '请输入新密码'; return; } if (np !== np2) { err.textContent = '两次输入的新密码不一致'; return; } $('btnDoReset').disabled = true; API.resetUserPassword(resetUserId, { adminPassword: adminPw, newPassword: np }).then(function () { $('btnDoReset').disabled = false; closeOverlay('resetOverlay'); toast('密码已重置', 'ok'); loadUsers(); }).catch(function (e) { $('btnDoReset').disabled = false; err.textContent = (e && e.message) || '重置失败'; }); } /* ============================================================ 用户分组 ============================================================ */ let editingGroupId = null; let memberGroupId = null; let memberSelected = {}; let memberCandidates = []; function loadGroups() { API.groups().then(function (d) { state.groups = d.groups || []; renderGroups(); }).catch(handleErr); } function renderGroups() { const rows = state.groups || []; $('groupRows').innerHTML = rows.length ? rows.map(function (g) { return '' + '' + esc(g.name) + '' + '' + esc(g.description || '—') + '' + '' + esc(g.memberCount) + '' + '' + esc(fmtTime(g.createdAt)) + '' + '' + '' + '' + '' + '' + ''; }).join('') : '暂无分组'; } function onGroupRowClick(e) { const b = e.target.closest('button[data-act]'); if (!b) { return; } const act = b.getAttribute('data-act'); const id = b.getAttribute('data-id'); if (act === 'edit') { openGroupDialog(id, b.getAttribute('data-name'), b.getAttribute('data-desc')); } else if (act === 'del') { uiConfirm('确认删除分组「' + b.getAttribute('data-name') + '」?该分组将从所有共享设置中移除。', { title: '删除分组', danger: true, okText: '删除' }).then(function (ok) { if (!ok) { return; } API.deleteGroup(id).then(function () { toast('分组已删除', 'ok'); loadGroups(); }).catch(handleErr); }); } else if (act === 'members') { openMemberDialog(id, b.getAttribute('data-name')); } } function openGroupDialog(id, name, desc) { editingGroupId = id || null; $('groupDlgTitle').textContent = id ? '编辑分组' : '新建分组'; $('gName').value = name || ''; $('gDesc').value = desc || ''; $('groupErr').textContent = ''; openOverlay('groupOverlay'); setTimeout(function () { $('gName').focus(); $('gName').select(); }, 30); } function submitGroupDialog() { const name = $('gName').value.trim(); const desc = $('gDesc').value.trim(); if (!name) { $('groupErr').textContent = '请输入分组名称'; return; } $('btnSaveGroup').disabled = true; const req = editingGroupId ? API.updateGroup(editingGroupId, { name: name, description: desc }) : API.createGroup({ name: name, description: desc }); req.then(function () { $('btnSaveGroup').disabled = false; closeOverlay('groupOverlay'); toast(editingGroupId ? '分组已更新' : '分组已创建', 'ok'); editingGroupId = null; loadGroups(); }).catch(function (err) { $('btnSaveGroup').disabled = false; $('groupErr').textContent = (err && err.message) || '保存失败'; }); } function openMemberDialog(id, name) { memberGroupId = id; memberSelected = {}; memberCandidates = []; $('memberDlgTitle').textContent = '管理成员'; $('memberMsg').textContent = '为分组「' + name + '」选择成员,保存后该分组的共享将对选中成员生效。'; $('memberFilter').value = ''; $('memberErr').textContent = ''; $('memberList').innerHTML = '
加载中…
'; openOverlay('memberOverlay'); Promise.all([API.listUsers({ page: 1, pageSize: 100 }), API.groupMembers(id)]).then(function (res) { memberCandidates = res[0].users || []; (res[1].members || []).forEach(function (m) { memberSelected[m.id] = true; }); renderMemberList(); }).catch(function (err) { $('memberList').innerHTML = '
' + esc((err && err.message) || '加载失败') + '
'; }); } function renderMemberList() { const q = ($('memberFilter').value || '').trim().toLowerCase(); const list = memberCandidates.filter(function (u) { return !q || (u.username || '').toLowerCase().indexOf(q) >= 0; }); $('memberList').innerHTML = list.length ? list.map(function (u) { return ''; }).join('') : '
无匹配用户
'; } function onMemberListChange(e) { const t = e.target; if (!t.classList || !t.classList.contains('member-chk')) { return; } const id = t.getAttribute('data-id'); if (t.checked) { memberSelected[id] = true; } else { delete memberSelected[id]; } } function submitMemberDialog() { if (!memberGroupId) { return; } const ids = Object.keys(memberSelected); $('btnSaveMember').disabled = true; API.setGroupMembers(memberGroupId, ids).then(function () { $('btnSaveMember').disabled = false; closeOverlay('memberOverlay'); toast('成员已保存(共 ' + ids.length + ' 人)', 'ok'); memberGroupId = null; loadGroups(); }).catch(function (err) { $('btnSaveMember').disabled = false; $('memberErr').textContent = (err && err.message) || '保存失败'; }); } /* ============================================================ 拓扑管理 ============================================================ */ function loadTopoOwnerOptions() { API.listUsers({ page: 1, pageSize: 100 }).then(function (d) { const sel = $('topoOwner'); const cur = state.topos.owner; const opts = ['']; (d.users || []).forEach(function (u) { opts.push(''); }); sel.innerHTML = opts.join(''); sel.value = cur; }).catch(function () { /* 忽略:仅用于筛选下拉 */ }); } function loadTopoAdmin() { const q = state.topos; API.adminTopologies({ page: q.page, pageSize: q.pageSize, q: q.q, owner: q.owner }).then(function (d) { const rows = d.topologies || []; const allSel = $('topoSelectAll'); if (allSel) { allSel.checked = false; } $('topoRows').innerHTML = rows.length ? rows.map(function (t) { const vis = (t.visibility === 'public') ? '公开' : '私有'; return '' + '' + '' + esc(t.name) + '' + '' + esc(t.ownerName || '—') + '' + '' + vis + '' + '' + esc(t.nodeCount) + ' / ' + esc(t.edgeCount) + '' + '' + esc(fmtTime(t.updatedAt)) + '' + '' + '' + '' + '' + '' + ''; }).join('') : '暂无拓扑'; renderPager('topoPager', d.total, q.page, q.pageSize, function (p) { q.page = p; loadTopoAdmin(); }); }).catch(handleErr); } function onTopoRowClick(e) { const b = e.target.closest('button[data-act]'); if (!b) { return; } const act = b.getAttribute('data-act'); const id = b.getAttribute('data-id'); if (act === 'rename') { const curName = b.getAttribute('data-name'); uiPrompt('新名称', { title: '重命名拓扑', message: '为拓扑「' + curName + '」设置新名称(最多 60 个字符)。', placeholder: '例如:某内网横向拓扑', value: curName, okText: '保存' }).then(function (v) { if (v === null) { return; } v = String(v).trim(); if (!v || v === curName) { return; } API.renameTopo(id, v).then(function () { toast('已重命名', 'ok'); loadTopoAdmin(); }).catch(handleErr); }); } else if (act === 'vis') { const vis = b.getAttribute('data-vis'); const label = (vis === 'public' ? '公开' : '私有'); uiConfirm('确认将该拓扑设为「' + label + '」?', { title: '修改可见性' }).then(function (ok) { if (!ok) { return; } API.setVisibility(id, vis).then(function () { toast('可见性已更新', 'ok'); loadTopoAdmin(); }).catch(handleErr); }); } else if (act === 'del') { uiConfirm('确认删除拓扑「' + b.getAttribute('data-name') + '」?该操作不可恢复。', { title: '删除拓扑', danger: true, okText: '删除' }).then(function (ok) { if (!ok) { return; } API.deleteTopo(id).then(function () { toast('拓扑已删除', 'ok'); loadTopoAdmin(); }).catch(handleErr); }); } } /* ============================================================ 拓扑批量操作 / 导出 ============================================================ */ function selectedTopoIds() { return Array.prototype.map.call( document.querySelectorAll('#topoRows .topo-chk:checked'), function (c) { return c.getAttribute('data-id'); } ); } function exportTopos(all) { const ids = all ? [] : selectedTopoIds(); if (!all && !ids.length) { toast('请先勾选要导出的拓扑', 'warn'); return; } API.exportTopos(ids).then(function (d) { const list = d.topologies || []; if (!list.length) { toast('没有可导出的拓扑', 'warn'); return; } const payload = { version: 1, type: 'route-topology-bundle', exportedAt: d.exportedAt || new Date().toISOString(), count: list.length, topologies: list.map(function (t) { return { name: t.name, ownerName: t.ownerName, visibility: t.visibility, nodeCount: t.nodeCount, edgeCount: t.edgeCount, updatedAt: t.updatedAt, nodes: t.nodes || [], edges: t.edges || [] }; }) }; downloadJson(payload, 'route-topologies-' + ts() + '.json'); toast('已导出 ' + list.length + ' 个拓扑', 'ok'); }).catch(handleErr); } function deleteSelectedTopos() { const ids = selectedTopoIds(); if (!ids.length) { toast('请先勾选要删除的拓扑', 'warn'); return; } uiConfirm('确认删除所选 ' + ids.length + ' 个拓扑?该操作不可恢复。', { title: '批量删除', danger: true, okText: '删除' }).then(function (ok) { if (!ok) { return; } Promise.all(ids.map(function (id) { return API.deleteTopo(id); })).then(function () { toast('已删除 ' + ids.length + ' 个拓扑', 'ok'); loadTopoAdmin(); }).catch(handleErr); }); } /* ============================================================ 网站设置 ============================================================ */ let logoValue = ''; // 表单中的 Logo(data URI 或 '') function renderLogoPreview(logo) { const box = $('logoPreview'); if (!box) { return; } if (logo) { box.innerHTML = ''; const img = document.createElement('img'); img.src = logo; img.alt = 'logo'; box.appendChild(img); box.classList.add('has-img'); } else { box.textContent = 'R'; box.classList.remove('has-img'); } } function applyAdminBrand(logo) { const el = document.querySelector('.console-brand .logo'); if (!el) { return; } if (logo) { el.innerHTML = ''; const img = document.createElement('img'); img.src = logo; img.alt = ''; el.appendChild(img); el.classList.add('has-img'); } else { el.textContent = 'R'; el.classList.remove('has-img'); } } function loadSettings() { API.siteSettings().then(function (d) { const s = d.settings || {}; $('setSiteName').value = s.site_name || ''; $('setAllowReg').checked = !!s.allow_registration; $('setTtl').value = (s.session_ttl_days || 30); logoValue = s.site_logo || ''; renderLogoPreview(logoValue); applyAdminBrand(logoValue); }).catch(handleErr); } function saveSettings() { const name = $('setSiteName').value.trim(); if (!name) { toast('请输入网站名称', 'warn'); return; } const ttl = parseInt($('setTtl').value, 10); if (!ttl || ttl < 1) { toast('会话有效期需为不小于 1 的整数', 'warn'); return; } $('btnSaveSettings').disabled = true; API.saveSiteSettings({ site_name: name, site_logo: logoValue, allow_registration: $('setAllowReg').checked, session_ttl_days: ttl }).then(function (d) { $('btnSaveSettings').disabled = false; const s = d.settings || {}; logoValue = s.site_logo || ''; renderLogoPreview(logoValue); applyAdminBrand(logoValue); $('setSiteName').value = s.site_name || name; toast('设置已保存', 'ok'); }).catch(function (err) { $('btnSaveSettings').disabled = false; handleErr(err); }); } function onPickLogo() { const input = $('logoFile'); const f = input.files && input.files[0]; if (!f) { return; } if (f.size > 300 * 1024) { toast('图片过大,请控制在 300KB 以内', 'warn'); input.value = ''; return; } const reader = new FileReader(); reader.onload = function () { logoValue = String(reader.result || ''); renderLogoPreview(logoValue); }; reader.onerror = function () { toast('读取图片失败', 'err'); }; reader.readAsDataURL(f); input.value = ''; } /* ============================================================ 操作日志 ============================================================ */ function loadLogs() { const q = state.logs; API.logs({ page: q.page, pageSize: q.pageSize, q: q.q }).then(function (d) { const rows = d.logs || []; $('logRows').innerHTML = rows.length ? rows.map(function (l) { return '' + '' + esc(fmtTime(l.createdAt)) + '' + '' + esc(l.username || '—') + '' + '' + esc(actionLabel(l.action)) + '' + '' + esc(l.target || '—') + '' + '' + esc(l.detail || '—') + '' + '' + esc(l.ip || '—') + '' + ''; }).join('') : '暂无日志'; renderPager('logPager', d.total, q.page, q.pageSize, function (p) { q.page = p; loadLogs(); }); }).catch(handleErr); } /* ============================================================ 事件绑定 ============================================================ */ function bindEvents() { Array.prototype.forEach.call(document.querySelectorAll('.cnav-item'), function (it) { it.addEventListener('click', function () { switchView(it.getAttribute('data-view')); }); }); $('btnCollapseNav').addEventListener('click', function () { $('console').classList.toggle('nav-collapsed'); }); $('btnCloseAdmin').addEventListener('click', function () { window.location.href = '../index.php'; }); $('userSearch').addEventListener('input', debounce(function () { state.users.q = this.value.trim(); state.users.page = 1; loadUsers(); })); $('topoSearch').addEventListener('input', debounce(function () { state.topos.q = this.value.trim(); state.topos.page = 1; loadTopoAdmin(); })); $('logSearch').addEventListener('input', debounce(function () { state.logs.q = this.value.trim(); state.logs.page = 1; loadLogs(); })); $('topoOwner').addEventListener('change', function () { state.topos.owner = this.value; state.topos.page = 1; loadTopoAdmin(); }); $('topoSelectAll').addEventListener('change', function () { const checked = this.checked; Array.prototype.forEach.call(document.querySelectorAll('#topoRows .topo-chk'), function (c) { c.checked = checked; }); }); $('btnExportSelectedTopos').addEventListener('click', function () { exportTopos(false); }); $('btnExportAllTopos').addEventListener('click', function () { exportTopos(true); }); $('btnDeleteSelectedTopos').addEventListener('click', deleteSelectedTopos); $('btnSaveSettings').addEventListener('click', saveSettings); $('btnPickLogo').addEventListener('click', function () { $('logoFile').click(); }); $('logoFile').addEventListener('change', onPickLogo); $('btnClearLogo').addEventListener('click', function () { logoValue = ''; renderLogoPreview(''); }); $('userRows').addEventListener('click', onUserRowClick); $('topoRows').addEventListener('click', onTopoRowClick); Array.prototype.forEach.call(document.querySelectorAll('#userTabs .tab'), function (t) { t.addEventListener('click', function () { state.usersTab = t.getAttribute('data-tab'); loadUserTab(); }); }); $('btnNewGroup').addEventListener('click', function () { openGroupDialog(null, '', ''); }); $('btnSaveGroup').addEventListener('click', submitGroupDialog); $('btnCancelGroup').addEventListener('click', function () { closeOverlay('groupOverlay'); }); $('btnCloseGroup').addEventListener('click', function () { closeOverlay('groupOverlay'); }); $('gName').addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); submitGroupDialog(); } }); $('groupRows').addEventListener('click', onGroupRowClick); $('btnSaveMember').addEventListener('click', submitMemberDialog); $('btnCancelMember').addEventListener('click', function () { closeOverlay('memberOverlay'); }); $('btnCloseMember').addEventListener('click', function () { closeOverlay('memberOverlay'); }); $('memberList').addEventListener('change', onMemberListChange); $('memberFilter').addEventListener('input', renderMemberList); $('btnNewUser').addEventListener('click', openUserDialog); $('btnSaveUser').addEventListener('click', submitUserDialog); $('btnCancelUser').addEventListener('click', function () { closeOverlay('userOverlay'); }); $('btnCloseUser').addEventListener('click', function () { closeOverlay('userOverlay'); }); $('uPass').addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); submitUserDialog(); } }); $('btnDoReset').addEventListener('click', submitResetDialog); $('btnCancelReset').addEventListener('click', function () { closeOverlay('resetOverlay'); }); $('btnCloseReset').addEventListener('click', function () { closeOverlay('resetOverlay'); }); $('resetShow').addEventListener('change', function () { const t = this.checked ? 'text' : 'password'; ['resetAdminPw', 'resetNewPw', 'resetNewPw2'].forEach(function (i) { $(i).type = t; }); }); ['resetNewPw', 'resetNewPw2'].forEach(function (i) { $(i).addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); submitResetDialog(); } }); }); $('confirmOk').addEventListener('click', _dlgConfirmOk); $('confirmCancel').addEventListener('click', function () { _dlgFinish(false); }); $('confirmClose').addEventListener('click', function () { _dlgFinish(false); }); $('confirmField').addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); _dlgConfirmOk(); } }); } /* ============================================================ 启动:校验管理员身份 ============================================================ */ function boot() { bindEvents(); API.me().then(function (d) { const u = d.user; if (!u || u.role !== 'admin') { toast('需要管理员权限,正在返回…', 'warn'); setTimeout(function () { window.location.href = '../index.php'; }, 900); return; } state.currentUser = u; $('navUser').textContent = u.username + ' · 管理员'; loadSettings(); switchView('overview'); }).catch(function () { window.location.href = '../index.php'; }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot); } else { boot(); } })();