RoutePage/web/user.js
2026-09-15 02:28:40 +08:00

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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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();
}
})();