/* ========================================================================= 路由拓扑 · 我的工作台(普通用户后台) 页面地址: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 '
' + '
' + esc(num === null || num === undefined ? 0 : num) + '
' + '
' + esc(label) + '
'; } function miniRow(time, content) { return '
' + esc(time) + '' + content + '
'; } function emptyRow(msg) { return '
' + esc(msg) + '
'; } /* ============================================================ 状态 ============================================================ */ 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 ''; }).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 '' + '' + '' + '' + ''; } 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' ? '公开' : '私有'; const share = t.shareMode ? '已共享' : '未共享'; return '' + '' + esc(t.name) + '' + '' + vis + '' + '' + share + '' + '' + esc(t.nodeCount) + ' / ' + esc(t.edgeCount) + '' + '' + esc(fmtTime(t.updatedAt)) + '' + '' + mineActions(t) + '' + ''; }).join('') : '暂无拓扑'; } 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' ? '可编辑' : '只读'; return '' + '' + esc(t.name) + '' + '' + esc(t.ownerName || '—') + '' + '' + perm + '' + '' + esc(t.nodeCount) + ' / ' + esc(t.edgeCount) + '' + '' + esc(fmtTime(t.updatedAt)) + '' + '' + ''; }).join('') : '暂无共享拓扑'; } 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 '' + esc(g.name) + '' + esc(g.description || '—') + '' + esc(g.memberCount) + ''; }).join('') : '你还没有加入任何分组'; } /* ============================================================ 品牌 ============================================================ */ 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(); } })();