513 lines
20 KiB
JavaScript
513 lines
20 KiB
JavaScript
/* =========================================================================
|
|
路由拓扑 · 我的工作台(普通用户后台)
|
|
页面地址:web/user.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);
|
|
};
|
|
}
|
|
|
|
/* ============================================================
|
|
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'); },
|
|
site: function () { return this.req('GET', '/site'); },
|
|
overview: function () { return this.req('GET', '/overview'); },
|
|
mygroups: function () { return this.req('GET', '/mygroups'); },
|
|
listTopos: function () { return this.req('GET', '/topologies'); },
|
|
createTopo: function (p) { return this.req('POST', '/topologies', p); },
|
|
renameTopo: function (id, name) { return this.req('PUT', '/topologies/' + encodeURIComponent(id) + '/rename', { name: name }); },
|
|
setVisibility: function (id, vis) { return this.req('PUT', '/topologies/' + encodeURIComponent(id) + '/permission', { visibility: vis }); },
|
|
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 || '' }
|
|
});
|
|
}
|
|
|
|
/* ============================================================
|
|
渲染小工具
|
|
============================================================ */
|
|
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>'; }
|
|
|
|
/* ============================================================
|
|
状态
|
|
============================================================ */
|
|
const state = {
|
|
view: 'overview',
|
|
currentUser: null,
|
|
all: [],
|
|
mygroups: [],
|
|
mine: { q: '' },
|
|
shared: { q: '' }
|
|
};
|
|
|
|
function isMine(t) { return state.currentUser && t.ownerId === state.currentUser.id; }
|
|
function isShared(t) { return !!t.sharedWithMe; }
|
|
|
|
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 tplDemo() {
|
|
return {
|
|
nodes: [
|
|
{ id: 'n1', label: '192.168.1.0/24', type: 'net', x: 120, y: 130, status: 'confirmed', ports: '', note: '办公网段' },
|
|
{ id: 'n2', label: '192.168.1.10', type: 'host', x: 120, y: 232, status: 'owned', ports: '80, 443', note: 'Web 服务器', parentId: 'n1' },
|
|
{ id: 'n3', label: '10.10.10.0/24', type: 'net', x: 430, y: 100, status: 'confirmed', ports: '', note: '内网核心区' },
|
|
{ id: 'n4', label: 'dc.corp.local', type: 'domain', x: 430, y: 226, status: 'unknown', ports: '389, 445', note: '域控' },
|
|
{ id: 'n5', label: '172.16.0.0/16', type: 'net', x: 740, y: 168, status: 'unknown', ports: '', note: '未知区域' }
|
|
],
|
|
edges: [
|
|
{ id: 'e1', from: 'n1', to: 'n3', label: '' },
|
|
{ id: 'e2', from: 'n1', to: 'n4', label: '' },
|
|
{ id: 'e3', from: 'n3', to: 'n5', label: '' },
|
|
{ id: 'e4', from: 'n2', to: 'n3', label: '' }
|
|
]
|
|
};
|
|
}
|
|
|
|
const TEMPLATES = {
|
|
blank: { label: '空白画布', data: function () { return { nodes: [], edges: [] }; } },
|
|
demo: { label: '示例:小型内网', data: tplDemo }
|
|
};
|
|
|
|
function initTemplates() {
|
|
const sel = $('newTopoTemplate');
|
|
if (!sel) { return; }
|
|
sel.innerHTML = Object.keys(TEMPLATES).map(function (k) {
|
|
return '<option value="' + k + '">' + esc(TEMPLATES[k].label) + '</option>';
|
|
}).join('');
|
|
}
|
|
|
|
/* ============================================================
|
|
视图切换
|
|
============================================================ */
|
|
function switchView(name) {
|
|
state.view = name;
|
|
['overview', 'mine', 'shared', 'groups'].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 === 'mine') { fetchTopos().then(renderMine).catch(handleErr); }
|
|
else if (name === 'shared') { fetchTopos().then(renderShared).catch(handleErr); }
|
|
else if (name === 'groups') { loadGroups(); }
|
|
}
|
|
|
|
function fetchTopos() {
|
|
return API.listTopos().then(function (r) {
|
|
state.all = r.topologies || [];
|
|
return state.all;
|
|
});
|
|
}
|
|
|
|
/* ============================================================
|
|
概览
|
|
============================================================ */
|
|
function loadOverview() {
|
|
Promise.all([API.overview(), fetchTopos(), API.mygroups()]).then(function (res) {
|
|
const ov = res[0].overview || {};
|
|
state.mygroups = (res[2] && res[2].groups) || [];
|
|
$('statCards').innerHTML = [
|
|
statCard('我的拓扑', ov.myTopo, ''),
|
|
statCard('共享给我', ov.sharedToMe, 'accent'),
|
|
statCard('我的分组', ov.myGroups, ''),
|
|
statCard('公开拓扑', ov.publicTopo, 'green')
|
|
].join('');
|
|
|
|
const mine = state.all.filter(isMine).slice(0, 6);
|
|
$('recentMine').innerHTML = mine.length ? mine.map(function (t) {
|
|
return miniRow(fmtTime(t.updatedAt), esc(t.name));
|
|
}).join('') : emptyRow('暂无拓扑');
|
|
|
|
const shared = state.all.filter(isShared).slice(0, 6);
|
|
$('recentShared').innerHTML = shared.length ? shared.map(function (t) {
|
|
return miniRow(fmtTime(t.updatedAt), esc(t.name + ' · ' + (t.ownerName || '')));
|
|
}).join('') : emptyRow('暂无共享');
|
|
}).catch(handleErr);
|
|
}
|
|
|
|
/* ============================================================
|
|
我的拓扑
|
|
============================================================ */
|
|
function mineActions(t) {
|
|
return '<button class="btn" data-act="open" data-id="' + esc(t.id) + '">打开</button>' +
|
|
'<button class="btn" data-act="share" data-id="' + esc(t.id) + '">共享</button>' +
|
|
'<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>';
|
|
}
|
|
|
|
function renderMine() {
|
|
const q = state.mine.q.toLowerCase();
|
|
const rows = state.all.filter(isMine).filter(function (t) {
|
|
return !q || (t.name || '').toLowerCase().indexOf(q) >= 0;
|
|
});
|
|
$('mineRows').innerHTML = rows.length ? rows.map(function (t) {
|
|
const vis = t.visibility === 'public'
|
|
? '<span class="badge public">公开</span>'
|
|
: '<span class="badge private">私有</span>';
|
|
const share = t.shareMode
|
|
? '<span class="badge owner">已共享</span>'
|
|
: '<span class="badge">未共享</span>';
|
|
return '<tr>' +
|
|
'<td>' + esc(t.name) + '</td>' +
|
|
'<td>' + vis + '</td>' +
|
|
'<td>' + share + '</td>' +
|
|
'<td>' + esc(t.nodeCount) + ' / ' + esc(t.edgeCount) + '</td>' +
|
|
'<td>' + esc(fmtTime(t.updatedAt)) + '</td>' +
|
|
'<td class="actions">' + mineActions(t) + '</td>' +
|
|
'</tr>';
|
|
}).join('') : '<tr><td colspan="6" class="empty-row">暂无拓扑</td></tr>';
|
|
}
|
|
|
|
function onMineRowClick(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 === 'open') {
|
|
window.location.href = '../index.php?topo=' + encodeURIComponent(id);
|
|
} else if (act === 'share') {
|
|
window.location.href = '../index.php?topo=' + encodeURIComponent(id) + '&share=1';
|
|
} else 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');
|
|
fetchTopos().then(renderMine).catch(handleErr);
|
|
}).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');
|
|
fetchTopos().then(renderMine).catch(handleErr);
|
|
}).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');
|
|
fetchTopos().then(renderMine).catch(handleErr);
|
|
}).catch(handleErr);
|
|
});
|
|
}
|
|
}
|
|
|
|
function createNewTopo() {
|
|
const name = $('newTopoName').value.trim();
|
|
if (!name) { toast('请输入拓扑名称', 'warn'); return; }
|
|
const sel = $('newTopoTemplate');
|
|
const tpl = (sel && TEMPLATES[sel.value]) ? TEMPLATES[sel.value] : TEMPLATES.blank;
|
|
const data = tpl.data() || { nodes: [], edges: [] };
|
|
API.createTopo({ name: name, nodes: data.nodes || [], edges: data.edges || [] }).then(function (r) {
|
|
$('newTopoName').value = '';
|
|
toast('已创建:' + name, 'ok');
|
|
window.location.href = '../index.php?topo=' + encodeURIComponent(r.topology.id);
|
|
}).catch(handleErr);
|
|
}
|
|
|
|
/* ============================================================
|
|
共享给我
|
|
============================================================ */
|
|
function renderShared() {
|
|
const q = state.shared.q.toLowerCase();
|
|
const rows = state.all.filter(isShared).filter(function (t) {
|
|
return !q || (t.name || '').toLowerCase().indexOf(q) >= 0;
|
|
});
|
|
$('sharedRows').innerHTML = rows.length ? rows.map(function (t) {
|
|
const perm = t.permission === 'edit'
|
|
? '<span class="badge owner">可编辑</span>'
|
|
: '<span class="badge ro">只读</span>';
|
|
return '<tr>' +
|
|
'<td>' + esc(t.name) + '</td>' +
|
|
'<td>' + esc(t.ownerName || '—') + '</td>' +
|
|
'<td>' + perm + '</td>' +
|
|
'<td>' + esc(t.nodeCount) + ' / ' + esc(t.edgeCount) + '</td>' +
|
|
'<td>' + esc(fmtTime(t.updatedAt)) + '</td>' +
|
|
'<td class="actions"><button class="btn" data-act="open" data-id="' + esc(t.id) + '">打开</button></td>' +
|
|
'</tr>';
|
|
}).join('') : '<tr><td colspan="6" class="empty-row">暂无共享拓扑</td></tr>';
|
|
}
|
|
|
|
function onSharedRowClick(e) {
|
|
const b = e.target.closest('button[data-act]');
|
|
if (!b) { return; }
|
|
if (b.getAttribute('data-act') === 'open') {
|
|
window.location.href = '../index.php?topo=' + encodeURIComponent(b.getAttribute('data-id'));
|
|
}
|
|
}
|
|
|
|
/* ============================================================
|
|
我的分组
|
|
============================================================ */
|
|
function loadGroups() {
|
|
API.mygroups().then(function (r) {
|
|
state.mygroups = r.groups || [];
|
|
renderGroups();
|
|
}).catch(handleErr);
|
|
}
|
|
|
|
function renderGroups() {
|
|
const gs = state.mygroups || [];
|
|
$('groupRows').innerHTML = gs.length ? gs.map(function (g) {
|
|
return '<tr><td>' + esc(g.name) + '</td><td>' + esc(g.description || '—') +
|
|
'</td><td>' + esc(g.memberCount) + '</td></tr>';
|
|
}).join('') : '<tr><td colspan="3" class="empty-row">你还没有加入任何分组</td></tr>';
|
|
}
|
|
|
|
/* ============================================================
|
|
品牌
|
|
============================================================ */
|
|
function applyBrand(site) {
|
|
if (!site) { return; }
|
|
if (site.name) { document.title = site.name + ' · 我的工作台'; }
|
|
const el = $('navLogo');
|
|
if (el && site.logo) {
|
|
el.innerHTML = '';
|
|
const img = document.createElement('img');
|
|
img.src = site.logo;
|
|
img.alt = '';
|
|
el.appendChild(img);
|
|
el.classList.add('has-img');
|
|
}
|
|
}
|
|
|
|
/* ============================================================
|
|
事件绑定
|
|
============================================================ */
|
|
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');
|
|
});
|
|
$('btnBackEditor').addEventListener('click', function () {
|
|
window.location.href = '../index.php';
|
|
});
|
|
$('btnGotoAdmin').addEventListener('click', function () {
|
|
window.location.href = 'admin.html';
|
|
});
|
|
|
|
$('mineSearch').addEventListener('input', debounce(function () {
|
|
state.mine.q = this.value.trim(); renderMine();
|
|
}));
|
|
$('sharedSearch').addEventListener('input', debounce(function () {
|
|
state.shared.q = this.value.trim(); renderShared();
|
|
}));
|
|
|
|
$('btnCreateTopo').addEventListener('click', createNewTopo);
|
|
$('newTopoName').addEventListener('keydown', function (e) {
|
|
if (e.key === 'Enter') { e.preventDefault(); createNewTopo(); }
|
|
});
|
|
|
|
$('mineRows').addEventListener('click', onMineRowClick);
|
|
$('sharedRows').addEventListener('click', onSharedRowClick);
|
|
|
|
$('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(); }
|
|
});
|
|
$('confirmOverlay').addEventListener('click', function (e) {
|
|
if (e.target === this) { _dlgFinish(false); }
|
|
});
|
|
}
|
|
|
|
/* ============================================================
|
|
启动
|
|
============================================================ */
|
|
function boot() {
|
|
bindEvents();
|
|
API.me().then(function (d) {
|
|
const u = d.user;
|
|
if (!u) {
|
|
window.location.href = '../index.php';
|
|
return;
|
|
}
|
|
state.currentUser = u;
|
|
$('navUser').textContent = u.username + ' · ' + (u.role === 'admin' ? '管理员' : '普通用户');
|
|
if (u.role === 'admin') { $('btnGotoAdmin').hidden = false; }
|
|
initTemplates();
|
|
API.site().then(applyBrand).catch(function () { /* 忽略:回退默认品牌 */ });
|
|
switchView('overview');
|
|
}).catch(function () {
|
|
window.location.href = '../index.php';
|
|
});
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', boot);
|
|
} else {
|
|
boot();
|
|
}
|
|
})();
|