780 lines
32 KiB
JavaScript
780 lines
32 KiB
JavaScript
/* =========================================================================
|
|
路由拓扑 · 管理控制台(独立页面)
|
|
页面地址: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)); }
|
|
};
|
|
|
|
/* ============================================================
|
|
提示条 / 站内弹窗
|
|
============================================================ */
|
|
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 '<div class="card ' + (cls || '') + '">' +
|
|
'<div class="num">' + esc(num === null || num === undefined ? 0 : num) + '</div>' +
|
|
'<div class="lbl">' + esc(label) + '</div></div>';
|
|
}
|
|
function miniRow(time, content) {
|
|
return '<div class="mini-row"><span class="t">' + esc(time) + '</span><span class="c">' + content + '</span></div>';
|
|
}
|
|
function emptyRow(msg) { return '<div class="empty-row">' + esc(msg) + '</div>'; }
|
|
|
|
function renderPager(containerId, total, page, pageSize, onGo) {
|
|
const totalPages = Math.max(1, Math.ceil((total || 0) / pageSize));
|
|
const el = $(containerId);
|
|
el.innerHTML =
|
|
'<span>共 ' + esc(total || 0) + ' 条 · 第 ' + esc(page) + ' / ' + esc(totalPages) + ' 页</span>' +
|
|
'<button class="btn sm" data-go="prev"' + (page <= 1 ? ' disabled' : '') + '>上一页</button>' +
|
|
'<button class="btn sm" data-go="next"' + (page >= totalPages ? ' disabled' : '') + '>下一页</button>';
|
|
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,
|
|
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') { loadUsers(); }
|
|
else if (name === 'topo') { loadTopoOwnerOptions(); loadTopoAdmin(); }
|
|
else if (name === 'settings') { loadSettings(); }
|
|
else if (name === 'logs') { loadLogs(); }
|
|
}
|
|
|
|
/* ============================================================
|
|
概览
|
|
============================================================ */
|
|
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')
|
|
? '<span class="badge owner">管理员</span>'
|
|
: '<span class="badge">普通用户</span>';
|
|
const status = u.disabled
|
|
? '<span class="badge ro">已禁用</span>'
|
|
: '<span class="badge public">正常</span>';
|
|
const pwdFlag = u.mustChangePassword ? ' <span class="badge ro">待改密</span>' : '';
|
|
return '<tr>' +
|
|
'<td>' + esc(u.username) + '</td>' +
|
|
'<td>' + roleBadge + '</td>' +
|
|
'<td>' + status + pwdFlag + '</td>' +
|
|
'<td>' + esc(u.loginCount) + '</td>' +
|
|
'<td>' + esc(fmtTime(u.lastLoginAt)) + '</td>' +
|
|
'<td>' + esc(fmtTime(u.createdAt)) + '</td>' +
|
|
'<td class="actions">' + userActions(u) + '</td>' +
|
|
'</tr>';
|
|
}).join('') : '<tr><td colspan="7" class="empty-row">暂无用户</td></tr>';
|
|
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;
|
|
const btns = [];
|
|
btns.push('<button class="btn" data-act="pwd" data-id="' + esc(u.id) + '" data-name="' + esc(u.username) + '">重置密码</button>');
|
|
btns.push('<button class="btn" data-act="role" data-id="' + esc(u.id) + '" data-role="' +
|
|
(u.role === 'admin' ? 'user' : 'admin') + '">' + (u.role === 'admin' ? '设为用户' : '设为管理员') + '</button>');
|
|
if (!isSelf) {
|
|
btns.push('<button class="btn" data-act="toggle" data-id="' + esc(u.id) + '" data-disabled="' +
|
|
(u.disabled ? 0 : 1) + '">' + (u.disabled ? '启用' : '禁用') + '</button>');
|
|
btns.push('<button class="btn danger" data-act="del" data-id="' + esc(u.id) + '" data-name="' + esc(u.username) + '">删除</button>');
|
|
}
|
|
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) || '重置失败';
|
|
});
|
|
}
|
|
|
|
/* ============================================================
|
|
拓扑管理
|
|
============================================================ */
|
|
function loadTopoOwnerOptions() {
|
|
API.listUsers({ page: 1, pageSize: 100 }).then(function (d) {
|
|
const sel = $('topoOwner');
|
|
const cur = state.topos.owner;
|
|
const opts = ['<option value="">全部所有者</option>'];
|
|
(d.users || []).forEach(function (u) {
|
|
opts.push('<option value="' + esc(u.id) + '">' + esc(u.username) + '</option>');
|
|
});
|
|
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')
|
|
? '<span class="badge public">公开</span>'
|
|
: '<span class="badge private">私有</span>';
|
|
return '<tr>' +
|
|
'<td class="col-chk"><input type="checkbox" class="topo-chk" data-id="' + esc(t.id) + '" data-name="' + esc(t.name) + '"></td>' +
|
|
'<td>' + esc(t.name) + '</td>' +
|
|
'<td>' + esc(t.ownerName || '—') + '</td>' +
|
|
'<td>' + vis + '</td>' +
|
|
'<td>' + esc(t.nodeCount) + ' / ' + esc(t.edgeCount) + '</td>' +
|
|
'<td>' + esc(fmtTime(t.updatedAt)) + '</td>' +
|
|
'<td class="actions">' +
|
|
'<button class="btn" data-act="rename" data-id="' + esc(t.id) + '" data-name="' + esc(t.name) + '">重命名</button>' +
|
|
'<button class="btn" data-act="vis" data-id="' + esc(t.id) + '" data-vis="' +
|
|
(t.visibility === 'public' ? 'private' : 'public') + '">' +
|
|
(t.visibility === 'public' ? '设为私有' : '设为公开') + '</button>' +
|
|
'<button class="btn danger" data-act="del" data-id="' + esc(t.id) + '" data-name="' + esc(t.name) + '">删除</button>' +
|
|
'</td>' +
|
|
'</tr>';
|
|
}).join('') : '<tr><td colspan="7" class="empty-row">暂无拓扑</td></tr>';
|
|
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 '<tr>' +
|
|
'<td>' + esc(fmtTime(l.createdAt)) + '</td>' +
|
|
'<td>' + esc(l.username || '—') + '</td>' +
|
|
'<td>' + esc(actionLabel(l.action)) + '</td>' +
|
|
'<td>' + esc(l.target || '—') + '</td>' +
|
|
'<td>' + esc(l.detail || '—') + '</td>' +
|
|
'<td>' + esc(l.ip || '—') + '</td>' +
|
|
'</tr>';
|
|
}).join('') : '<tr><td colspan="6" class="empty-row">暂无日志</td></tr>';
|
|
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);
|
|
|
|
$('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();
|
|
}
|
|
})();
|