commit c3ff86d2c14c1570418b3e238ef571ffbf57221c
Author: MasonLiu <2857911564@qq.com>
Date: Tue Sep 15 02:00:21 2026 +0800
新建仓库
diff --git a/config.php b/config.php
new file mode 100644
index 0000000..d933a80
--- /dev/null
+++ b/config.php
@@ -0,0 +1,41 @@
+ array(
+ // 驱动:sqlite(默认,零配置)| mysql
+ 'driver' => 'sqlite',
+
+ 'sqlite' => array(
+ // SQLite 数据库文件路径(相对于项目根目录的 data 目录)
+ 'path' => __DIR__ . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'app.db',
+ ),
+
+ 'mysql' => array(
+ 'host' => '127.0.0.1',
+ 'port' => 3306,
+ 'database' => 'route_topo',
+ 'username' => 'root',
+ 'password' => '',
+ 'charset' => 'utf8mb4',
+ ),
+ ),
+
+ /* ---------- 账号与会话 ---------- */
+ // 是否允许访客自助注册普通账号(如需完全由管理员分配账号,改为 false)
+ 'allow_registration' => true,
+
+ // 会话有效期(秒),默认 30 天
+ 'session_ttl' => 60 * 60 * 24 * 30,
+
+ // 首次启动时自动创建的默认管理员
+ 'default_admin' => array(
+ 'username' => 'admin',
+ 'password' => 'admin123',
+ ),
+);
diff --git a/index.php b/index.php
new file mode 100644
index 0000000..5c71998
--- /dev/null
+++ b/index.php
@@ -0,0 +1,1147 @@
+= 7.3;存储使用数据库(SQLite / MySQL),详见 config.php。
+ *
+ * 目录约定:
+ * web/ 前端静态文件(可整体替换)
+ * data/ 数据目录(数据库文件,持久保存)
+ *
+ * 启动:php -S 0.0.0.0:8080 index.php 或 Apache 站点根指向本目录
+ */
+require_once __DIR__ . DIRECTORY_SEPARATOR . 'lib' . DIRECTORY_SEPARATOR . 'repo.php';
+
+define('SESSION_COOKIE', 'rt_session');
+define('WEB_DIR', __DIR__ . DIRECTORY_SEPARATOR . 'web');
+
+/* ============================================================
+ * 输出 / 输入
+ * ============================================================ */
+function json_out($data, $code = 200)
+{
+ if (!headers_sent()) {
+ http_response_code($code);
+ header('Content-Type: application/json; charset=utf-8');
+ header('Cache-Control: no-store');
+ }
+ echo json_encode($data, JSON_UNESCAPED_UNICODE);
+ exit;
+}
+
+function api_ok($extra = array())
+{
+ $payload = array('ok' => true);
+ if (is_array($extra)) {
+ $payload = array_merge($payload, $extra);
+ }
+ json_out($payload, 200);
+}
+
+function api_error($message, $code = 400)
+{
+ json_out(array('ok' => false, 'error' => $message), $code);
+}
+
+function json_input()
+{
+ $raw = file_get_contents('php://input');
+ if ($raw === false || $raw === '') {
+ return array();
+ }
+ $data = json_decode($raw, true);
+ return is_array($data) ? $data : array();
+}
+
+function cfg($key, $default = null)
+{
+ $c = app_config();
+ return array_key_exists($key, $c) ? $c[$key] : $default;
+}
+
+/**
+ * 站点设置:config.php 提供默认值,数据库中保存的值优先
+ */
+function site_settings()
+{
+ $defaults = array(
+ 'site_name' => '路由拓扑',
+ 'site_logo' => '',
+ 'allow_registration' => cfg('allow_registration', true) ? '1' : '0',
+ 'session_ttl_days' => (string) max(1, (int) round(((int) cfg('session_ttl', 60 * 60 * 24 * 30)) / 86400)),
+ );
+ $saved = settings_all();
+ foreach ($defaults as $k => $v) {
+ if (array_key_exists($k, $saved) && $saved[$k] !== null && $saved[$k] !== '') {
+ $defaults[$k] = $saved[$k];
+ }
+ }
+ return $defaults;
+}
+
+function session_ttl_seconds()
+{
+ $days = (int) site_settings()['session_ttl_days'];
+ if ($days < 1) {
+ $days = 30;
+ }
+ return $days * 86400;
+}
+
+function client_ip()
+{
+ return isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '';
+}
+
+function user_agent_str()
+{
+ $ua = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '';
+ return str_cut($ua, 250);
+}
+
+function str_len($s)
+{
+ $s = (string) $s;
+ return function_exists('mb_strlen') ? mb_strlen($s, 'UTF-8') : strlen($s);
+}
+
+function str_cut($s, $len)
+{
+ $s = (string) $s;
+ return function_exists('mb_substr') ? mb_substr($s, 0, $len, 'UTF-8') : substr($s, 0, $len);
+}
+
+function valid_id($id)
+{
+ return is_string($id) && preg_match('/^[A-Za-z0-9_\-]{1,64}$/', $id) === 1;
+}
+
+function page_params()
+{
+ $page = isset($_GET['page']) ? (int) $_GET['page'] : 1;
+ $pageSize = isset($_GET['pageSize']) ? (int) $_GET['pageSize'] : 10;
+ if ($page < 1) {
+ $page = 1;
+ }
+ if ($pageSize < 1) {
+ $pageSize = 10;
+ }
+ if ($pageSize > 100) {
+ $pageSize = 100;
+ }
+ return array($page, $pageSize, ($page - 1) * $pageSize);
+}
+
+/* ============================================================
+ * 会话
+ * ============================================================ */
+function set_session_cookie($token, $expires)
+{
+ $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
+ if (PHP_VERSION_ID >= 70300) {
+ setcookie(SESSION_COOKIE, $token, array(
+ 'expires' => $expires,
+ 'path' => '/',
+ 'httponly' => true,
+ 'samesite' => 'Lax',
+ 'secure' => $secure,
+ ));
+ } else {
+ setcookie(SESSION_COOKIE, $token, $expires, '/; samesite=Lax', '', $secure, true);
+ }
+}
+
+function clear_session_cookie()
+{
+ if (PHP_VERSION_ID >= 70300) {
+ setcookie(SESSION_COOKIE, '', array(
+ 'expires' => time() - 3600,
+ 'path' => '/',
+ 'httponly' => true,
+ 'samesite' => 'Lax',
+ ));
+ } else {
+ setcookie(SESSION_COOKIE, '', time() - 3600, '/');
+ }
+}
+
+function current_user()
+{
+ static $cached = null;
+ static $done = false;
+ if ($done) {
+ return $cached;
+ }
+ $done = true;
+
+ $token = isset($_COOKIE[SESSION_COOKIE]) ? $_COOKIE[SESSION_COOKIE] : '';
+ if ($token === '') {
+ return null;
+ }
+ $s = session_find($token);
+ if (!$s) {
+ return null;
+ }
+ if ((int) $s['expires_at'] < time()) {
+ session_delete($token);
+ return null;
+ }
+ $u = user_find_by_id($s['user_id']);
+ if (!$u || !empty($u['disabled'])) {
+ return null;
+ }
+ $cached = $u;
+ return $cached;
+}
+
+function require_auth()
+{
+ $u = current_user();
+ if (!$u) {
+ api_error('未登录或登录已过期', 401);
+ }
+ return $u;
+}
+
+function require_admin()
+{
+ $u = require_auth();
+ if (!isset($u['role']) || $u['role'] !== 'admin') {
+ api_error('需要管理员权限', 403);
+ }
+ return $u;
+}
+
+/* ============================================================
+ * 转换
+ * ============================================================ */
+function public_user($u)
+{
+ if (!$u) {
+ return null;
+ }
+ return array(
+ 'id' => $u['id'],
+ 'username' => $u['username'],
+ 'role' => isset($u['role']) ? $u['role'] : 'user',
+ 'disabled' => !empty($u['disabled']),
+ 'mustChangePassword' => !empty($u['must_change_password']),
+ 'createdAt' => isset($u['created_at']) ? $u['created_at'] : '',
+ 'lastLoginAt' => isset($u['last_login_at']) ? $u['last_login_at'] : '',
+ 'loginCount' => isset($u['login_count']) ? (int) $u['login_count'] : 0,
+ );
+}
+
+function log_row_out($r)
+{
+ return array(
+ 'id' => isset($r['id']) ? (int) $r['id'] : 0,
+ 'userId' => $r['user_id'],
+ 'username' => $r['username'],
+ 'action' => $r['action'],
+ 'target' => $r['target'],
+ 'detail' => $r['detail'],
+ 'ip' => $r['ip'],
+ 'createdAt' => $r['created_at'],
+ );
+}
+
+function can_write_topo($row, $user)
+{
+ if (!$row || !$user) {
+ return false;
+ }
+ if ($user['role'] === 'admin') {
+ return true;
+ }
+ return $row['owner_id'] === $user['id'];
+}
+
+function can_read_topo($row, $user)
+{
+ if (!$row || !$user) {
+ return false;
+ }
+ if ($user['role'] === 'admin') {
+ return true;
+ }
+ if ($row['owner_id'] === $user['id']) {
+ return true;
+ }
+ return $row['visibility'] === 'public';
+}
+
+function topo_summary($row, $user, $names)
+{
+ $ownerId = $row['owner_id'];
+ return array(
+ 'id' => $row['id'],
+ 'name' => $row['name'],
+ 'ownerId' => $ownerId,
+ 'ownerName' => isset($names[$ownerId]) ? $names[$ownerId] : '未知',
+ 'visibility' => ($row['visibility'] === 'public') ? 'public' : 'private',
+ 'createdAt' => isset($row['created_at']) ? $row['created_at'] : '',
+ 'updatedAt' => isset($row['updated_at']) ? $row['updated_at'] : '',
+ 'nodeCount' => (int) $row['node_count'],
+ 'edgeCount' => (int) $row['edge_count'],
+ 'canEdit' => can_write_topo($row, $user),
+ );
+}
+
+/* ============================================================
+ * 认证接口
+ * ============================================================ */
+function api_login($in)
+{
+ $username = isset($in['username']) ? trim((string) $in['username']) : '';
+ $password = isset($in['password']) ? (string) $in['password'] : '';
+ if ($username === '' || $password === '') {
+ api_error('请输入用户名和密码');
+ }
+ $u = user_find_by_name($username);
+ if (!$u || !password_verify($password, $u['password_hash'])) {
+ api_error('用户名或密码错误', 401);
+ }
+ if (!empty($u['disabled'])) {
+ api_error('该账号已被禁用', 403);
+ }
+
+ $ttl = session_ttl_seconds();
+ $token = bin2hex(random_bytes(32));
+ $expires = time() + $ttl;
+ session_purge_expired(time());
+ session_create($token, $u['id'], $expires, client_ip(), user_agent_str());
+ user_touch_login($u['id']);
+ log_add($u['id'], $u['username'], 'login', '', '登录成功', client_ip());
+ set_session_cookie($token, $expires);
+
+ api_ok(array('user' => public_user(user_find_by_id($u['id']))));
+}
+
+/**
+ * 密码强度校验(不满足时返回错误文案,满足返回 null)
+ */
+function weak_password($pw, $username)
+{
+ $pw = (string) $pw;
+ if (strlen($pw) < 8) {
+ return '密码至少 8 位';
+ }
+ if (!preg_match('/[A-Za-z]/', $pw) || !preg_match('/[0-9]/', $pw)) {
+ return '密码必须同时包含字母和数字';
+ }
+ if ($username !== null && $username !== '' && strcasecmp($pw, (string) $username) === 0) {
+ return '密码不能与用户名相同';
+ }
+ return null;
+}
+
+function api_register($in)
+{
+ if (site_settings()['allow_registration'] !== '1') {
+ api_error('当前未开放注册,请联系管理员开通账号', 403);
+ }
+ $username = isset($in['username']) ? trim((string) $in['username']) : '';
+ $password = isset($in['password']) ? (string) $in['password'] : '';
+ if ($username === '' || $password === '') {
+ api_error('用户名和密码不能为空');
+ }
+ if (!preg_match('/^[A-Za-z0-9_.\-]{2,32}$/', $username)) {
+ api_error('用户名需为 2-32 位字母、数字、_ . -');
+ }
+ $weak = weak_password($password, $username);
+ if ($weak !== null) {
+ api_error($weak);
+ }
+ if (user_find_by_name($username)) {
+ api_error('用户名已存在', 409);
+ }
+
+ $id = gen_id('u');
+ user_insert(array(
+ 'id' => $id,
+ 'username' => $username,
+ 'password_hash' => password_hash($password, PASSWORD_DEFAULT),
+ 'role' => 'user',
+ 'disabled' => 0,
+ 'must_change_password' => 0,
+ 'created_at' => now_iso(),
+ ));
+
+ $ttl = session_ttl_seconds();
+ $token = bin2hex(random_bytes(32));
+ $expires = time() + $ttl;
+ session_create($token, $id, $expires, client_ip(), user_agent_str());
+ user_touch_login($id);
+ log_add($id, $username, 'register', '', '注册账号', client_ip());
+ set_session_cookie($token, $expires);
+
+ api_ok(array('user' => public_user(user_find_by_id($id))));
+}
+
+function api_logout()
+{
+ $u = current_user();
+ $token = isset($_COOKIE[SESSION_COOKIE]) ? $_COOKIE[SESSION_COOKIE] : '';
+ if ($token !== '') {
+ session_delete($token);
+ }
+ if ($u) {
+ log_add($u['id'], $u['username'], 'logout', '', '退出登录', client_ip());
+ }
+ clear_session_cookie();
+ api_ok(array('user' => null));
+}
+
+/* 公开:站点基本信息(名称 / Logo / 是否开放注册) */
+function api_site()
+{
+ $s = site_settings();
+ api_ok(array(
+ 'site' => array(
+ 'name' => $s['site_name'],
+ 'logo' => $s['site_logo'],
+ 'allowRegistration' => ($s['allow_registration'] === '1'),
+ )
+ ));
+}
+
+function api_change_password($user, $in)
+{
+ $old = isset($in['oldPassword']) ? (string) $in['oldPassword'] : '';
+ $new = isset($in['newPassword']) ? (string) $in['newPassword'] : '';
+ if (!password_verify($old, $user['password_hash'])) {
+ api_error('原密码不正确', 403);
+ }
+ $weak = weak_password($new, $user['username']);
+ if ($weak !== null) {
+ api_error($weak);
+ }
+ if (password_verify($new, $user['password_hash'])) {
+ api_error('新密码不能与原密码相同');
+ }
+ user_update($user['id'], array(
+ 'password_hash' => password_hash($new, PASSWORD_DEFAULT),
+ 'must_change_password' => 0,
+ ));
+ log_add($user['id'], $user['username'], 'password_change', '', '修改密码', client_ip());
+ api_ok(array('user' => public_user(user_find_by_id($user['id']))));
+}
+
+/* ============================================================
+ * 用户管理接口(管理员)
+ * ============================================================ */
+function admin_count()
+{
+ return (int) db_val("SELECT COUNT(*) FROM users WHERE role = 'admin' AND disabled = 0");
+}
+
+function api_users_list()
+{
+ list($page, $pageSize, $offset) = page_params();
+ $q = isset($_GET['q']) ? trim((string) $_GET['q']) : '';
+ $total = user_count($q);
+ $rows = user_search($q, $pageSize, $offset);
+ $list = array();
+ foreach ($rows as $u) {
+ $list[] = public_user($u);
+ }
+ api_ok(array('users' => $list, 'total' => $total, 'page' => $page, 'pageSize' => $pageSize));
+}
+
+function api_user_create($admin, $in)
+{
+ $username = isset($in['username']) ? trim((string) $in['username']) : '';
+ $password = isset($in['password']) ? (string) $in['password'] : '';
+ $role = (isset($in['role']) && $in['role'] === 'admin') ? 'admin' : 'user';
+
+ if ($username === '' || $password === '') {
+ api_error('用户名和密码不能为空');
+ }
+ if (!preg_match('/^[A-Za-z0-9_.\-]{2,32}$/', $username)) {
+ api_error('用户名需为 2-32 位字母、数字、_ . -');
+ }
+ $weak = weak_password($password, $username);
+ if ($weak !== null) {
+ api_error($weak);
+ }
+ if (user_find_by_name($username)) {
+ api_error('用户名已存在', 409);
+ }
+
+ $id = gen_id('u');
+ user_insert(array(
+ 'id' => $id,
+ 'username' => $username,
+ 'password_hash' => password_hash($password, PASSWORD_DEFAULT),
+ 'role' => $role,
+ 'disabled' => 0,
+ 'must_change_password' => 1,
+ 'created_at' => now_iso(),
+ ));
+ log_add($admin['id'], $admin['username'], 'user_create', $id, '创建用户 ' . $username, client_ip());
+ api_ok(array('user' => public_user(user_find_by_id($id))));
+}
+
+function api_user_update($admin, $id, $in)
+{
+ $u = user_find_by_id($id);
+ if (!$u) {
+ api_error('用户不存在', 404);
+ }
+
+ $fields = array();
+ $changes = array();
+
+ if (isset($in['role'])) {
+ $newRole = ($in['role'] === 'admin') ? 'admin' : 'user';
+ if ($u['role'] === 'admin' && $newRole !== 'admin' && admin_count() <= 1) {
+ api_error('至少保留一名管理员');
+ }
+ $fields['role'] = $newRole;
+ $changes[] = '角色改为' . ($newRole === 'admin' ? '管理员' : '普通用户');
+ }
+
+ if (isset($in['disabled'])) {
+ $disabled = !empty($in['disabled']) ? 1 : 0;
+ if ($disabled) {
+ if ($u['id'] === $admin['id']) {
+ api_error('不能禁用当前登录账号');
+ }
+ if ($u['role'] === 'admin' && admin_count() <= 1) {
+ api_error('至少保留一名可用管理员');
+ }
+ }
+ $fields['disabled'] = $disabled;
+ $changes[] = $disabled ? '禁用账号' : '启用账号';
+ }
+
+ if ($fields) {
+ user_update($id, $fields);
+ log_add($admin['id'], $admin['username'], 'user_update', $id, '更新用户 ' . $u['username'] . ':' . implode('、', $changes), client_ip());
+ }
+ api_ok(array('user' => public_user(user_find_by_id($id))));
+}
+
+/**
+ * 重置指定用户的密码:需先验证当前管理员本人的密码
+ */
+function api_user_reset_password($admin, $id, $in)
+{
+ $u = user_find_by_id($id);
+ if (!$u) {
+ api_error('用户不存在', 404);
+ }
+ $adminPw = isset($in['adminPassword']) ? (string) $in['adminPassword'] : '';
+ $newPw = isset($in['newPassword']) ? (string) $in['newPassword'] : '';
+ if (!password_verify($adminPw, $admin['password_hash'])) {
+ api_error('管理员密码不正确', 403);
+ }
+ $weak = weak_password($newPw, $u['username']);
+ if ($weak !== null) {
+ api_error($weak);
+ }
+ if (password_verify($newPw, $u['password_hash'])) {
+ api_error('新密码不能与原密码相同');
+ }
+ user_update($id, array(
+ 'password_hash' => password_hash($newPw, PASSWORD_DEFAULT),
+ 'must_change_password' => 1,
+ ));
+ log_add($admin['id'], $admin['username'], 'user_reset_password', $id, '重置用户密码 ' . $u['username'], client_ip());
+ api_ok(array('user' => public_user(user_find_by_id($id))));
+}
+
+function api_user_delete($admin, $id)
+{
+ $u = user_find_by_id($id);
+ if (!$u) {
+ api_error('用户不存在', 404);
+ }
+ if ($u['id'] === $admin['id']) {
+ api_error('不能删除当前登录账号');
+ }
+ if ($u['role'] === 'admin' && admin_count() <= 1) {
+ api_error('至少保留一名管理员');
+ }
+ user_delete($id);
+ log_add($admin['id'], $admin['username'], 'user_delete', $id, '删除用户 ' . $u['username'], client_ip());
+ api_ok();
+}
+
+/* ============================================================
+ * 拓扑接口
+ * ============================================================ */
+function api_topo_list($user)
+{
+ $rows = topo_list_for_user($user);
+ $names = user_name_map();
+ $out = array();
+ foreach ($rows as $r) {
+ $out[] = topo_summary($r, $user, $names);
+ }
+ api_ok(array('topologies' => $out));
+}
+
+function api_topo_create($user, $in)
+{
+ $name = isset($in['name']) ? trim((string) $in['name']) : '';
+ if ($name === '') {
+ $name = '未命名拓扑';
+ }
+ if (str_len($name) > 60) {
+ $name = str_cut($name, 60);
+ }
+ $nodes = (isset($in['nodes']) && is_array($in['nodes'])) ? $in['nodes'] : array();
+ $edges = (isset($in['edges']) && is_array($in['edges'])) ? $in['edges'] : array();
+
+ $id = gen_id('t');
+ $now = now_iso();
+ topo_insert(array(
+ 'id' => $id,
+ 'name' => $name,
+ 'owner_id' => $user['id'],
+ 'visibility' => 'private',
+ 'data' => json_encode(array('nodes' => $nodes, 'edges' => $edges), JSON_UNESCAPED_UNICODE),
+ 'node_count' => count($nodes),
+ 'edge_count' => count($edges),
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ));
+ log_add($user['id'], $user['username'], 'topo_create', $id, '创建拓扑 ' . $name, client_ip());
+ api_ok(array('topology' => topo_summary(topo_find($id), $user, user_name_map())));
+}
+
+function api_topo_get($user, $id)
+{
+ if (!valid_id($id)) {
+ api_error('非法的拓扑 ID');
+ }
+ $row = topo_find($id);
+ if (!$row) {
+ api_error('拓扑不存在', 404);
+ }
+ if (!can_read_topo($row, $user)) {
+ api_error('无权访问该拓扑', 403);
+ }
+ $content = json_decode($row['data'], true);
+ if (!is_array($content)) {
+ $content = array();
+ }
+ api_ok(array(
+ 'topology' => topo_summary($row, $user, user_name_map()),
+ 'data' => array(
+ 'nodes' => (isset($content['nodes']) && is_array($content['nodes'])) ? $content['nodes'] : array(),
+ 'edges' => (isset($content['edges']) && is_array($content['edges'])) ? $content['edges'] : array(),
+ ),
+ ));
+}
+
+function api_topo_save($user, $id, $in)
+{
+ if (!valid_id($id)) {
+ api_error('非法的拓扑 ID');
+ }
+ $row = topo_find($id);
+ if (!$row) {
+ api_error('拓扑不存在', 404);
+ }
+ if (!can_write_topo($row, $user)) {
+ api_error('无权修改该拓扑', 403);
+ }
+ $nodes = (isset($in['nodes']) && is_array($in['nodes'])) ? $in['nodes'] : array();
+ $edges = (isset($in['edges']) && is_array($in['edges'])) ? $in['edges'] : array();
+
+ $name = null;
+ if (isset($in['name']) && trim((string) $in['name']) !== '') {
+ $name = str_cut(trim((string) $in['name']), 60);
+ }
+ topo_update(
+ $id,
+ json_encode(array('nodes' => $nodes, 'edges' => $edges), JSON_UNESCAPED_UNICODE),
+ count($nodes),
+ count($edges),
+ now_iso(),
+ $name
+ );
+
+ api_ok(array('topology' => topo_summary(topo_find($id), $user, user_name_map())));
+}
+
+function api_topo_delete($user, $id)
+{
+ if (!valid_id($id)) {
+ api_error('非法的拓扑 ID');
+ }
+ $row = topo_find($id);
+ if (!$row) {
+ api_error('拓扑不存在', 404);
+ }
+ if (!can_write_topo($row, $user)) {
+ api_error('无权删除该拓扑', 403);
+ }
+ topo_delete($id);
+ log_add($user['id'], $user['username'], 'topo_delete', $id, '删除拓扑 ' . $row['name'], client_ip());
+ api_ok();
+}
+
+function api_topo_permission($user, $id, $in)
+{
+ if (!valid_id($id)) {
+ api_error('非法的拓扑 ID');
+ }
+ $row = topo_find($id);
+ if (!$row) {
+ api_error('拓扑不存在', 404);
+ }
+ if (!can_write_topo($row, $user)) {
+ api_error('无权修改该拓扑', 403);
+ }
+ $vis = (isset($in['visibility']) && $in['visibility'] === 'public') ? 'public' : 'private';
+ topo_set_visibility($id, $vis, now_iso());
+ log_add($user['id'], $user['username'], 'topo_permission', $id, '拓扑可见性设为' . ($vis === 'public' ? '公开' : '私有') . ':' . $row['name'], client_ip());
+ api_ok(array('topology' => topo_summary(topo_find($id), $user, user_name_map())));
+}
+
+function api_topo_rename($user, $id, $in)
+{
+ if (!valid_id($id)) {
+ api_error('非法的拓扑 ID');
+ }
+ $row = topo_find($id);
+ if (!$row) {
+ api_error('拓扑不存在', 404);
+ }
+ if (!can_write_topo($row, $user)) {
+ api_error('无权修改该拓扑', 403);
+ }
+ $name = isset($in['name']) ? trim((string) $in['name']) : '';
+ if ($name === '') {
+ api_error('拓扑名称不能为空');
+ }
+ if (str_len($name) > 60) {
+ $name = str_cut($name, 60);
+ }
+ topo_set_name($id, $name, now_iso());
+ log_add($user['id'], $user['username'], 'topo_rename', $id, '重命名拓扑 ' . $row['name'] . ' → ' . $name, client_ip());
+ api_ok(array('topology' => topo_summary(topo_find($id), $user, user_name_map())));
+}
+
+function api_admin_topologies()
+{
+ list($page, $pageSize, $offset) = page_params();
+ $q = isset($_GET['q']) ? trim((string) $_GET['q']) : '';
+ $owner = isset($_GET['owner']) ? trim((string) $_GET['owner']) : '';
+ $total = topo_admin_count($q, $owner);
+ $rows = topo_admin_search($q, $owner, $pageSize, $offset);
+ $user = current_user();
+ $names = user_name_map();
+ $list = array();
+ foreach ($rows as $r) {
+ $list[] = topo_summary($r, $user, $names);
+ }
+ api_ok(array('topologies' => $list, 'total' => $total, 'page' => $page, 'pageSize' => $pageSize));
+}
+
+/* 导出全部或指定的拓扑(含完整节点/连线数据),仅管理员可用 */
+function api_admin_topologies_export()
+{
+ $idsParam = isset($_GET['ids']) ? trim((string) $_GET['ids']) : '';
+ $ids = array();
+ if ($idsParam !== '') {
+ foreach (explode(',', $idsParam) as $one) {
+ $one = trim($one);
+ if ($one !== '' && valid_id($one)) {
+ $ids[] = $one;
+ }
+ }
+ if (!$ids) {
+ api_error('未提供有效的拓扑 ID');
+ }
+ }
+
+ $rows = topo_export_rows($ids);
+ $names = user_name_map();
+ $list = array();
+ foreach ($rows as $r) {
+ $content = json_decode($r['data'], true);
+ if (!is_array($content)) {
+ $content = array();
+ }
+ $list[] = array(
+ 'id' => $r['id'],
+ 'name' => $r['name'],
+ 'ownerId' => $r['owner_id'],
+ 'ownerName' => isset($names[$r['owner_id']]) ? $names[$r['owner_id']] : '未知',
+ 'visibility' => ($r['visibility'] === 'public') ? 'public' : 'private',
+ 'createdAt' => isset($r['created_at']) ? $r['created_at'] : '',
+ 'updatedAt' => isset($r['updated_at']) ? $r['updated_at'] : '',
+ 'nodeCount' => (int) $r['node_count'],
+ 'edgeCount' => (int) $r['edge_count'],
+ 'nodes' => (isset($content['nodes']) && is_array($content['nodes'])) ? $content['nodes'] : array(),
+ 'edges' => (isset($content['edges']) && is_array($content['edges'])) ? $content['edges'] : array(),
+ );
+ }
+ api_ok(array('topologies' => $list, 'total' => count($list), 'exportedAt' => now_iso()));
+}
+
+/* 站点设置:读取(管理员) */
+function api_admin_settings_get()
+{
+ $s = site_settings();
+ api_ok(array(
+ 'settings' => array(
+ 'site_name' => $s['site_name'],
+ 'site_logo' => $s['site_logo'],
+ 'allow_registration' => ($s['allow_registration'] === '1'),
+ 'session_ttl_days' => (int) $s['session_ttl_days'],
+ )
+ ));
+}
+
+/* 站点设置:保存(管理员) */
+function api_admin_settings_update($user, $in)
+{
+ $changes = array();
+
+ if (isset($in['site_name'])) {
+ $name = trim((string) $in['site_name']);
+ if ($name === '') {
+ api_error('网站名称不能为空');
+ }
+ if (str_len($name) > 40) {
+ $name = str_cut($name, 40);
+ }
+ setting_set('site_name', $name);
+ $changes[] = '名称';
+ }
+
+ if (array_key_exists('site_logo', $in)) {
+ $logo = (string) $in['site_logo'];
+ if ($logo !== '') {
+ if (!preg_match('#^data:image/(png|jpe?g|gif|webp|svg\+xml);base64,#i', $logo)) {
+ api_error('Logo 格式不支持,请上传 PNG / JPG / GIF / WEBP / SVG 图片');
+ }
+ if (strlen($logo) > 400000) {
+ api_error('Logo 图片过大,请控制在 300KB 以内');
+ }
+ }
+ setting_set('site_logo', $logo);
+ $changes[] = 'Logo';
+ }
+
+ if (array_key_exists('allow_registration', $in)) {
+ setting_set('allow_registration', !empty($in['allow_registration']) ? '1' : '0');
+ $changes[] = '注册开关';
+ }
+
+ if (isset($in['session_ttl_days'])) {
+ $days = (int) $in['session_ttl_days'];
+ if ($days < 1) {
+ $days = 1;
+ }
+ if ($days > 365) {
+ $days = 365;
+ }
+ setting_set('session_ttl_days', (string) $days);
+ $changes[] = '会话有效期';
+ }
+
+ log_add($user['id'], $user['username'], 'site_settings', '', '更新站点设置:' . ($changes ? implode('、', $changes) : '无变更'), client_ip());
+ api_admin_settings_get();
+}
+
+/* ============================================================
+ * 概览 / 日志(管理员)
+ * ============================================================ */
+function api_dashboard()
+{
+ $stats = stats_overview();
+ $user = current_user();
+ $names = user_name_map();
+
+ $recentLogs = array();
+ foreach (log_recent(8) as $r) {
+ $recentLogs[] = log_row_out($r);
+ }
+ $recentTopos = array();
+ foreach (topo_recent(5) as $r) {
+ $recentTopos[] = topo_summary($r, $user, $names);
+ }
+ api_ok(array('stats' => $stats, 'recentLogs' => $recentLogs, 'recentTopos' => $recentTopos));
+}
+
+function api_logs()
+{
+ list($page, $pageSize, $offset) = page_params();
+ $q = isset($_GET['q']) ? trim((string) $_GET['q']) : '';
+ $total = log_count($q);
+ $rows = log_search($q, $pageSize, $offset);
+ $list = array();
+ foreach ($rows as $r) {
+ $list[] = log_row_out($r);
+ }
+ api_ok(array('logs' => $list, 'total' => $total, 'page' => $page, 'pageSize' => $pageSize));
+}
+
+/* ============================================================
+ * 路由
+ * ============================================================ */
+function handle_api($method, $segments)
+{
+ $resource = isset($segments[1]) ? $segments[1] : '';
+ $id = isset($segments[2]) ? $segments[2] : '';
+ $action = isset($segments[3]) ? $segments[3] : '';
+
+ switch ($resource) {
+
+ case 'login':
+ if ($method !== 'POST') {
+ api_error('方法不允许', 405);
+ }
+ api_login(json_input());
+ break;
+
+ case 'register':
+ if ($method !== 'POST') {
+ api_error('方法不允许', 405);
+ }
+ api_register(json_input());
+ break;
+
+ case 'logout':
+ if ($method !== 'POST') {
+ api_error('方法不允许', 405);
+ }
+ api_logout();
+ break;
+
+ case 'me':
+ if ($method !== 'GET') {
+ api_error('方法不允许', 405);
+ }
+ $u = current_user();
+ api_ok(array('user' => $u ? public_user($u) : null));
+ break;
+
+ case 'site':
+ if ($method !== 'GET') {
+ api_error('方法不允许', 405);
+ }
+ api_site();
+ break;
+
+ case 'password':
+ if ($method !== 'POST' && $method !== 'PUT') {
+ api_error('方法不允许', 405);
+ }
+ api_change_password(require_auth(), json_input());
+ break;
+
+ case 'dashboard':
+ require_admin();
+ if ($method !== 'GET') {
+ api_error('方法不允许', 405);
+ }
+ api_dashboard();
+ break;
+
+ case 'logs':
+ require_admin();
+ if ($method !== 'GET') {
+ api_error('方法不允许', 405);
+ }
+ api_logs();
+ break;
+
+ case 'users':
+ $admin = require_admin();
+ if ($method === 'GET' && $id === '') {
+ api_users_list();
+ }
+ if ($method === 'POST' && $id === '') {
+ api_user_create($admin, json_input());
+ }
+ if ($id !== '') {
+ if ($action === 'password' && $method === 'PUT') {
+ api_user_reset_password($admin, $id, json_input());
+ }
+ if ($action === '' && $method === 'PUT') {
+ api_user_update($admin, $id, json_input());
+ }
+ if ($action === '' && $method === 'DELETE') {
+ api_user_delete($admin, $id);
+ }
+ }
+ api_error('接口不存在', 404);
+ break;
+
+ case 'admin':
+ $admin = require_admin();
+ if ($id === 'topologies' && $action === '' && $method === 'GET') {
+ api_admin_topologies();
+ }
+ if ($id === 'topologies' && $action === 'export' && $method === 'GET') {
+ api_admin_topologies_export();
+ }
+ if ($id === 'settings' && $action === '') {
+ if ($method === 'GET') {
+ api_admin_settings_get();
+ }
+ if ($method === 'PUT') {
+ api_admin_settings_update($admin, json_input());
+ }
+ }
+ api_error('接口不存在', 404);
+ break;
+
+ case 'topologies':
+ $user = require_auth();
+ if ($method === 'GET' && $id === '') {
+ api_topo_list($user);
+ }
+ if ($method === 'POST' && $id === '') {
+ api_topo_create($user, json_input());
+ }
+ if ($id !== '') {
+ if ($action === 'permission' && $method === 'PUT') {
+ api_topo_permission($user, $id, json_input());
+ }
+ if ($action === 'rename' && $method === 'PUT') {
+ api_topo_rename($user, $id, json_input());
+ }
+ if ($action === '') {
+ if ($method === 'GET') {
+ api_topo_get($user, $id);
+ }
+ if ($method === 'PUT') {
+ api_topo_save($user, $id, json_input());
+ }
+ if ($method === 'DELETE') {
+ api_topo_delete($user, $id);
+ }
+ }
+ }
+ api_error('接口不存在', 404);
+ break;
+
+ default:
+ api_error('接口不存在', 404);
+ }
+}
+
+/* ============================================================
+ * 静态文件
+ * ============================================================ */
+function mime_type($file)
+{
+ $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
+ $map = array(
+ 'html' => 'text/html; charset=utf-8',
+ 'htm' => 'text/html; charset=utf-8',
+ 'js' => 'application/javascript; charset=utf-8',
+ 'mjs' => 'application/javascript; charset=utf-8',
+ 'css' => 'text/css; charset=utf-8',
+ 'json' => 'application/json; charset=utf-8',
+ 'png' => 'image/png',
+ 'jpg' => 'image/jpeg',
+ 'jpeg' => 'image/jpeg',
+ 'gif' => 'image/gif',
+ 'svg' => 'image/svg+xml',
+ 'webp' => 'image/webp',
+ 'ico' => 'image/x-icon',
+ 'woff' => 'font/woff',
+ 'woff2' => 'font/woff2',
+ 'ttf' => 'font/ttf',
+ 'map' => 'application/json; charset=utf-8',
+ 'txt' => 'text/plain; charset=utf-8',
+ );
+ return isset($map[$ext]) ? $map[$ext] : 'application/octet-stream';
+}
+
+function serve_static($path)
+{
+ $rel = ltrim((string) $path, '/');
+ if ($rel === '' || $rel === 'index.php' || $rel === 'web') {
+ $rel = 'index.html';
+ }
+ // 兼容以 web/ 前缀访问(如 Apache 直接请求 /web/style.css)
+ if (strpos($rel, 'web/') === 0) {
+ $rel = substr($rel, 4);
+ }
+ // 仅放行前端资源,避免通过静态服务泄露后端文件
+ $ext = strtolower(pathinfo($rel, PATHINFO_EXTENSION));
+ $allow = array('html', 'htm', 'css', 'js', 'mjs', 'json', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'woff', 'woff2', 'ttf', 'txt', 'map');
+ if (!in_array($ext, $allow, true)) {
+ $rel = 'index.html';
+ }
+
+ $root = realpath(WEB_DIR);
+ $file = realpath(WEB_DIR . DIRECTORY_SEPARATOR . $rel);
+
+ if ($root === false || $file === false || strpos($file, $root . DIRECTORY_SEPARATOR) !== 0 || !is_file($file)) {
+ $index = realpath(WEB_DIR . DIRECTORY_SEPARATOR . 'index.html');
+ if ($index !== false && is_file($index)) {
+ header('Content-Type: text/html; charset=utf-8');
+ readfile($index);
+ return;
+ }
+ http_response_code(404);
+ header('Content-Type: text/plain; charset=utf-8');
+ echo '404 Not Found';
+ return;
+ }
+
+ header('Content-Type: ' . mime_type($file));
+ header('Content-Length: ' . filesize($file));
+ readfile($file);
+}
+
+/* ============================================================
+ * 入口
+ * ============================================================ */
+ensure_seed();
+
+$method = isset($_SERVER['REQUEST_METHOD']) ? strtoupper($_SERVER['REQUEST_METHOD']) : 'GET';
+
+// 支持三种入口(无需 URL 重写):
+// 1) ?r=/api/xxx 查询串,兼容性最好(前端默认使用)
+// 2) /index.php/api/xxx PATH_INFO
+// 3) /api/xxx 需重写或内置服务器路由
+if (isset($_GET['r']) && $_GET['r'] !== '') {
+ $r = (string) $_GET['r'];
+ $rq = parse_url($r, PHP_URL_QUERY);
+ if ($rq !== null && $rq !== false && $rq !== '') {
+ parse_str($rq, $extra);
+ if (is_array($extra)) {
+ foreach ($extra as $k => $v) {
+ if (!isset($_GET[$k])) {
+ $_GET[$k] = $v;
+ }
+ }
+ }
+ }
+ $parsedPath = parse_url($r, PHP_URL_PATH);
+} elseif (!empty($_SERVER['PATH_INFO'])) {
+ $parsedPath = $_SERVER['PATH_INFO'];
+} else {
+ $uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/';
+ $parsedPath = parse_url($uri, PHP_URL_PATH);
+}
+if ($parsedPath === false || $parsedPath === null || $parsedPath === '') {
+ $parsedPath = '/';
+}
+
+$segments = array_values(array_filter(explode('/', $parsedPath), function ($s) {
+ return $s !== '';
+}));
+
+if (count($segments) > 0 && $segments[0] === 'index.php') {
+ array_shift($segments);
+}
+
+if (count($segments) > 0 && $segments[0] === 'api') {
+ handle_api($method, $segments);
+ exit;
+}
+
+serve_static($parsedPath);
diff --git a/lib/db.php b/lib/db.php
new file mode 100644
index 0000000..1ebc3fa
--- /dev/null
+++ b/lib/db.php
@@ -0,0 +1,189 @@
+ PDO::ERRMODE_EXCEPTION,
+ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+ );
+
+ if ($driver === 'mysql') {
+ $options[PDO::ATTR_EMULATE_PREPARES] = false;
+ $m = $cfg['db']['mysql'];
+ $dsn = 'mysql:host=' . $m['host'] . ';port=' . $m['port'] . ';dbname=' . $m['database'] . ';charset=' . $m['charset'];
+ $pdo = new PDO($dsn, $m['username'], $m['password'], $options);
+ } else {
+ $path = $cfg['db']['sqlite']['path'];
+ $dir = dirname($path);
+ if (!is_dir($dir)) {
+ @mkdir($dir, 0777, true);
+ }
+ $pdo = new PDO('sqlite:' . $path, null, null, $options);
+ // 使用回滚日志(非 WAL):事务提交即写入主库文件,
+ // 避免 -wal/-shm 在站点迁移、文件覆盖等场景下丢失,导致写入看似成功但未真正保存
+ $pdo->exec('PRAGMA journal_mode = DELETE');
+ $pdo->exec('PRAGMA synchronous = NORMAL');
+ $pdo->exec('PRAGMA busy_timeout = 5000');
+ $pdo->exec('PRAGMA foreign_keys = ON');
+ }
+
+ init_schema($pdo, $driver);
+ return $pdo;
+}
+
+/**
+ * 建表(幂等)
+ */
+function init_schema($pdo, $driver)
+{
+ if ($driver === 'mysql') {
+ $pdo->exec("CREATE TABLE IF NOT EXISTS users (
+ id VARCHAR(32) NOT NULL,
+ username VARCHAR(32) NOT NULL,
+ password_hash VARCHAR(255) NOT NULL,
+ role VARCHAR(16) NOT NULL DEFAULT 'user',
+ disabled TINYINT NOT NULL DEFAULT 0,
+ must_change_password TINYINT NOT NULL DEFAULT 0,
+ created_at VARCHAR(32) DEFAULT NULL,
+ last_login_at VARCHAR(32) DEFAULT NULL,
+ login_count INT NOT NULL DEFAULT 0,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_users_username (username)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
+
+ $pdo->exec("CREATE TABLE IF NOT EXISTS sessions (
+ token VARCHAR(64) NOT NULL,
+ user_id VARCHAR(32) NOT NULL,
+ created_at VARCHAR(32) DEFAULT NULL,
+ expires_at BIGINT NOT NULL,
+ ip VARCHAR(45) DEFAULT NULL,
+ user_agent VARCHAR(255) DEFAULT NULL,
+ PRIMARY KEY (token),
+ KEY idx_sessions_user (user_id)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
+
+ $pdo->exec("CREATE TABLE IF NOT EXISTS topologies (
+ id VARCHAR(32) NOT NULL,
+ name VARCHAR(120) NOT NULL,
+ owner_id VARCHAR(32) NOT NULL,
+ visibility VARCHAR(16) NOT NULL DEFAULT 'private',
+ data LONGTEXT,
+ node_count INT NOT NULL DEFAULT 0,
+ edge_count INT NOT NULL DEFAULT 0,
+ created_at VARCHAR(32) DEFAULT NULL,
+ updated_at VARCHAR(32) DEFAULT NULL,
+ PRIMARY KEY (id),
+ KEY idx_topo_owner (owner_id),
+ KEY idx_topo_updated (updated_at)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
+
+ $pdo->exec("CREATE TABLE IF NOT EXISTS activity_logs (
+ id INT NOT NULL AUTO_INCREMENT,
+ user_id VARCHAR(32) DEFAULT NULL,
+ username VARCHAR(32) DEFAULT NULL,
+ action VARCHAR(40) DEFAULT NULL,
+ target VARCHAR(64) DEFAULT NULL,
+ detail VARCHAR(255) DEFAULT NULL,
+ ip VARCHAR(45) DEFAULT NULL,
+ created_at VARCHAR(32) DEFAULT NULL,
+ PRIMARY KEY (id),
+ KEY idx_logs_created (created_at)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
+
+ $pdo->exec("CREATE TABLE IF NOT EXISTS settings (
+ skey VARCHAR(64) NOT NULL,
+ sval LONGTEXT,
+ updated_at VARCHAR(32) DEFAULT NULL,
+ PRIMARY KEY (skey)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
+
+ return;
+ }
+
+ /* ---------- SQLite ---------- */
+ $pdo->exec("CREATE TABLE IF NOT EXISTS users (
+ id TEXT NOT NULL PRIMARY KEY,
+ username TEXT NOT NULL UNIQUE,
+ password_hash TEXT NOT NULL,
+ role TEXT NOT NULL DEFAULT 'user',
+ disabled INTEGER NOT NULL DEFAULT 0,
+ must_change_password INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT,
+ last_login_at TEXT,
+ login_count INTEGER NOT NULL DEFAULT 0
+ )");
+
+ $pdo->exec("CREATE TABLE IF NOT EXISTS sessions (
+ token TEXT NOT NULL PRIMARY KEY,
+ user_id TEXT NOT NULL,
+ created_at TEXT,
+ expires_at INTEGER NOT NULL,
+ ip TEXT,
+ user_agent TEXT
+ )");
+
+ $pdo->exec("CREATE TABLE IF NOT EXISTS topologies (
+ id TEXT NOT NULL PRIMARY KEY,
+ name TEXT NOT NULL,
+ owner_id TEXT NOT NULL,
+ visibility TEXT NOT NULL DEFAULT 'private',
+ data TEXT,
+ node_count INTEGER NOT NULL DEFAULT 0,
+ edge_count INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT,
+ updated_at TEXT
+ )");
+
+ $pdo->exec("CREATE TABLE IF NOT EXISTS activity_logs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id TEXT,
+ username TEXT,
+ action TEXT,
+ target TEXT,
+ detail TEXT,
+ ip TEXT,
+ created_at TEXT
+ )");
+
+ $pdo->exec("CREATE TABLE IF NOT EXISTS settings (
+ skey TEXT NOT NULL PRIMARY KEY,
+ sval TEXT,
+ updated_at TEXT
+ )");
+
+ $pdo->exec("CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)");
+ $pdo->exec("CREATE INDEX IF NOT EXISTS idx_topo_owner ON topologies(owner_id)");
+ $pdo->exec("CREATE INDEX IF NOT EXISTS idx_logs_created ON activity_logs(created_at)");
+}
diff --git a/lib/repo.php b/lib/repo.php
new file mode 100644
index 0000000..7f16464
--- /dev/null
+++ b/lib/repo.php
@@ -0,0 +1,479 @@
+prepare($sql);
+ $st->execute($params);
+ return $st->fetchAll();
+}
+
+function db_one($sql, $params = array())
+{
+ $st = db()->prepare($sql);
+ $st->execute($params);
+ $row = $st->fetch();
+ return $row === false ? null : $row;
+}
+
+function db_val($sql, $params = array())
+{
+ $st = db()->prepare($sql);
+ $st->execute($params);
+ return $st->fetchColumn();
+}
+
+function db_run($sql, $params = array())
+{
+ $st = db()->prepare($sql);
+ $st->execute($params);
+ return $st;
+}
+
+function like_param($q)
+{
+ return '%' . $q . '%';
+}
+
+/* ============================================================
+ * 用户
+ * ============================================================ */
+function user_find_by_name($username)
+{
+ return db_one('SELECT * FROM users WHERE username = ?', array($username));
+}
+
+function user_find_by_id($id)
+{
+ return db_one('SELECT * FROM users WHERE id = ?', array($id));
+}
+
+function user_name_map()
+{
+ $map = array();
+ foreach (db_all('SELECT id, username FROM users') as $r) {
+ $map[$r['id']] = $r['username'];
+ }
+ return $map;
+}
+
+function user_search($q, $limit, $offset)
+{
+ $limit = (int) $limit;
+ $offset = (int) $offset;
+ if ($q === '') {
+ return db_all('SELECT * FROM users ORDER BY created_at DESC, username ASC LIMIT ' . $limit . ' OFFSET ' . $offset);
+ }
+ $p = like_param($q);
+ return db_all(
+ 'SELECT * FROM users WHERE username LIKE ? OR role LIKE ? ORDER BY created_at DESC, username ASC LIMIT ' . $limit . ' OFFSET ' . $offset,
+ array($p, $p)
+ );
+}
+
+function user_count($q)
+{
+ if ($q === '') {
+ return (int) db_val('SELECT COUNT(*) FROM users');
+ }
+ $p = like_param($q);
+ return (int) db_val('SELECT COUNT(*) FROM users WHERE username LIKE ? OR role LIKE ?', array($p, $p));
+}
+
+function user_insert($data)
+{
+ db_run(
+ 'INSERT INTO users (id, username, password_hash, role, disabled, must_change_password, created_at, last_login_at, login_count)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
+ array(
+ $data['id'],
+ $data['username'],
+ $data['password_hash'],
+ isset($data['role']) ? $data['role'] : 'user',
+ !empty($data['disabled']) ? 1 : 0,
+ !empty($data['must_change_password']) ? 1 : 0,
+ isset($data['created_at']) ? $data['created_at'] : now_iso(),
+ isset($data['last_login_at']) ? $data['last_login_at'] : null,
+ isset($data['login_count']) ? (int) $data['login_count'] : 0,
+ )
+ );
+ return $data['id'];
+}
+
+function user_update($id, $fields)
+{
+ $allowed = array('username', 'password_hash', 'role', 'disabled', 'must_change_password');
+ $sets = array();
+ $params = array();
+ foreach ($allowed as $f) {
+ if (array_key_exists($f, $fields)) {
+ $sets[] = $f . ' = ?';
+ $params[] = $fields[$f];
+ }
+ }
+ if (!$sets) {
+ return false;
+ }
+ $params[] = $id;
+ db_run('UPDATE users SET ' . implode(', ', $sets) . ' WHERE id = ?', $params);
+ return true;
+}
+
+function user_delete($id)
+{
+ db_run('DELETE FROM users WHERE id = ?', array($id));
+ db_run('DELETE FROM sessions WHERE user_id = ?', array($id));
+}
+
+function user_touch_login($id)
+{
+ db_run('UPDATE users SET last_login_at = ?, login_count = login_count + 1 WHERE id = ?', array(now_iso(), $id));
+}
+
+/* ============================================================
+ * 会话
+ * ============================================================ */
+function session_create($token, $userId, $expires, $ip, $ua)
+{
+ db_run(
+ 'INSERT INTO sessions (token, user_id, created_at, expires_at, ip, user_agent) VALUES (?, ?, ?, ?, ?, ?)',
+ array($token, $userId, now_iso(), (int) $expires, $ip, $ua)
+ );
+}
+
+function session_find($token)
+{
+ return db_one('SELECT * FROM sessions WHERE token = ?', array($token));
+}
+
+function session_delete($token)
+{
+ db_run('DELETE FROM sessions WHERE token = ?', array($token));
+}
+
+function session_delete_by_user($userId)
+{
+ db_run('DELETE FROM sessions WHERE user_id = ?', array($userId));
+}
+
+function session_purge_expired($now)
+{
+ db_run('DELETE FROM sessions WHERE expires_at < ?', array((int) $now));
+}
+
+/* ============================================================
+ * 拓扑
+ * ============================================================ */
+function topo_find($id)
+{
+ return db_one('SELECT * FROM topologies WHERE id = ?', array($id));
+}
+
+function topo_list_for_user($user)
+{
+ /* 主应用「我的拓扑」仅返回:当前用户创建的 + 公开的。
+ 全部拓扑(含他人私有)只能在管理后台查看,见 topo_admin_search()。 */
+ return db_all(
+ 'SELECT * FROM topologies WHERE owner_id = ? OR visibility = ? ORDER BY updated_at DESC',
+ array($user['id'], 'public')
+ );
+}
+
+function topo_admin_search($q, $ownerId, $limit, $offset)
+{
+ $limit = (int) $limit;
+ $offset = (int) $offset;
+ $where = array();
+ $params = array();
+ if ($q !== '') {
+ $where[] = 'name LIKE ?';
+ $params[] = like_param($q);
+ }
+ if ($ownerId !== '') {
+ $where[] = 'owner_id = ?';
+ $params[] = $ownerId;
+ }
+ $sql = 'SELECT * FROM topologies';
+ if ($where) {
+ $sql .= ' WHERE ' . implode(' AND ', $where);
+ }
+ $sql .= ' ORDER BY updated_at DESC LIMIT ' . $limit . ' OFFSET ' . $offset;
+ return db_all($sql, $params);
+}
+
+function topo_admin_count($q, $ownerId)
+{
+ $where = array();
+ $params = array();
+ if ($q !== '') {
+ $where[] = 'name LIKE ?';
+ $params[] = like_param($q);
+ }
+ if ($ownerId !== '') {
+ $where[] = 'owner_id = ?';
+ $params[] = $ownerId;
+ }
+ $sql = 'SELECT COUNT(*) FROM topologies';
+ if ($where) {
+ $sql .= ' WHERE ' . implode(' AND ', $where);
+ }
+ return (int) db_val($sql, $params);
+}
+
+function topo_recent($limit)
+{
+ $limit = (int) $limit;
+ return db_all('SELECT * FROM topologies ORDER BY updated_at DESC LIMIT ' . $limit);
+}
+
+/* 导出用:根据 id 列表返回完整行;ids 为空时返回全部 */
+function topo_export_rows($ids)
+{
+ if (empty($ids)) {
+ return db_all('SELECT * FROM topologies ORDER BY updated_at DESC');
+ }
+ $ph = implode(',', array_fill(0, count($ids), '?'));
+ return db_all('SELECT * FROM topologies WHERE id IN (' . $ph . ') ORDER BY updated_at DESC', $ids);
+}
+
+function topo_insert($t)
+{
+ db_run(
+ 'INSERT INTO topologies (id, name, owner_id, visibility, data, node_count, edge_count, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
+ array(
+ $t['id'],
+ $t['name'],
+ $t['owner_id'],
+ isset($t['visibility']) ? $t['visibility'] : 'private',
+ isset($t['data']) ? $t['data'] : '{"nodes":[],"edges":[]}',
+ isset($t['node_count']) ? (int) $t['node_count'] : 0,
+ isset($t['edge_count']) ? (int) $t['edge_count'] : 0,
+ isset($t['created_at']) ? $t['created_at'] : now_iso(),
+ isset($t['updated_at']) ? $t['updated_at'] : now_iso(),
+ )
+ );
+ return $t['id'];
+}
+
+function topo_update($id, $data, $nodeCount, $edgeCount, $updatedAt, $name = null)
+{
+ if ($name !== null && $name !== '') {
+ db_run(
+ 'UPDATE topologies SET data = ?, node_count = ?, edge_count = ?, updated_at = ?, name = ? WHERE id = ?',
+ array($data, (int) $nodeCount, (int) $edgeCount, $updatedAt, $name, $id)
+ );
+ } else {
+ db_run(
+ 'UPDATE topologies SET data = ?, node_count = ?, edge_count = ?, updated_at = ? WHERE id = ?',
+ array($data, (int) $nodeCount, (int) $edgeCount, $updatedAt, $id)
+ );
+ }
+}
+
+function topo_delete($id)
+{
+ db_run('DELETE FROM topologies WHERE id = ?', array($id));
+}
+
+function topo_set_visibility($id, $vis, $updatedAt)
+{
+ db_run('UPDATE topologies SET visibility = ?, updated_at = ? WHERE id = ?', array($vis, $updatedAt, $id));
+}
+
+function topo_set_name($id, $name, $updatedAt)
+{
+ db_run('UPDATE topologies SET name = ?, updated_at = ? WHERE id = ?', array($name, $updatedAt, $id));
+}
+
+/* ============================================================
+ * 操作日志
+ * ============================================================ */
+function log_add($userId, $username, $action, $target, $detail, $ip)
+{
+ db_run(
+ 'INSERT INTO activity_logs (user_id, username, action, target, detail, ip, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
+ array($userId, $username, $action, $target, $detail, $ip, now_iso())
+ );
+}
+
+function log_search($q, $limit, $offset)
+{
+ $limit = (int) $limit;
+ $offset = (int) $offset;
+ if ($q === '') {
+ return db_all('SELECT * FROM activity_logs ORDER BY id DESC LIMIT ' . $limit . ' OFFSET ' . $offset);
+ }
+ $p = like_param($q);
+ return db_all(
+ 'SELECT * FROM activity_logs WHERE username LIKE ? OR action LIKE ? OR target LIKE ? OR detail LIKE ?
+ ORDER BY id DESC LIMIT ' . $limit . ' OFFSET ' . $offset,
+ array($p, $p, $p, $p)
+ );
+}
+
+function log_count($q)
+{
+ if ($q === '') {
+ return (int) db_val('SELECT COUNT(*) FROM activity_logs');
+ }
+ $p = like_param($q);
+ return (int) db_val(
+ 'SELECT COUNT(*) FROM activity_logs WHERE username LIKE ? OR action LIKE ? OR target LIKE ? OR detail LIKE ?',
+ array($p, $p, $p, $p)
+ );
+}
+
+function log_recent($limit)
+{
+ $limit = (int) $limit;
+ return db_all('SELECT * FROM activity_logs ORDER BY id DESC LIMIT ' . $limit);
+}
+
+/* ============================================================
+ * 统计
+ * ============================================================ */
+function stats_overview()
+{
+ $today = date('Y-m-d') . '%';
+ return array(
+ 'userCount' => (int) db_val('SELECT COUNT(*) FROM users'),
+ 'adminCount' => (int) db_val("SELECT COUNT(*) FROM users WHERE role = 'admin' AND disabled = 0"),
+ 'disabledCount' => (int) db_val('SELECT COUNT(*) FROM users WHERE disabled = 1'),
+ 'topoCount' => (int) db_val('SELECT COUNT(*) FROM topologies'),
+ 'publicCount' => (int) db_val("SELECT COUNT(*) FROM topologies WHERE visibility = 'public'"),
+ 'todayTopoCount' => (int) db_val('SELECT COUNT(*) FROM topologies WHERE created_at LIKE ?', array($today)),
+ 'todayLoginCount' => (int) db_val("SELECT COUNT(*) FROM activity_logs WHERE action = 'login' AND created_at LIKE ?", array($today)),
+ 'todayLogCount' => (int) db_val('SELECT COUNT(*) FROM activity_logs WHERE created_at LIKE ?', array($today)),
+ );
+}
+
+/* ============================================================
+ * 站点设置(key-value)
+ * ============================================================ */
+function setting_get($key, $default = null)
+{
+ $row = db_one('SELECT sval FROM settings WHERE skey = ?', array($key));
+ return $row === null ? $default : $row['sval'];
+}
+
+function setting_set($key, $val)
+{
+ $now = now_iso();
+ if (db_one('SELECT skey FROM settings WHERE skey = ?', array($key)) !== null) {
+ db_run('UPDATE settings SET sval = ?, updated_at = ? WHERE skey = ?', array($val, $now, $key));
+ } else {
+ db_run('INSERT INTO settings (skey, sval, updated_at) VALUES (?, ?, ?)', array($key, $val, $now));
+ }
+}
+
+function settings_all()
+{
+ $out = array();
+ foreach (db_all('SELECT skey, sval FROM settings') as $r) {
+ $out[$r['skey']] = $r['sval'];
+ }
+ return $out;
+}
+
+/* ============================================================
+ * 旧 JSON 数据迁移
+ * ============================================================ */
+function migrate_legacy_json()
+{
+ $dir = data_dir();
+ $usersFile = $dir . DIRECTORY_SEPARATOR . 'users.json';
+ $topoIndexFile = $dir . DIRECTORY_SEPARATOR . 'topologies.json';
+ $topoDir = $dir . DIRECTORY_SEPARATOR . 'topologies';
+
+ if (is_file($usersFile) && (int) db_val('SELECT COUNT(*) FROM users') === 0) {
+ $data = json_decode(@file_get_contents($usersFile), true);
+ if (isset($data['users']) && is_array($data['users'])) {
+ foreach ($data['users'] as $u) {
+ if (empty($u['id']) || empty($u['username'])) {
+ continue;
+ }
+ user_insert(array(
+ 'id' => $u['id'],
+ 'username' => $u['username'],
+ 'password_hash' => isset($u['passwordHash']) ? $u['passwordHash'] : '',
+ 'role' => isset($u['role']) ? $u['role'] : 'user',
+ 'disabled' => !empty($u['disabled']) ? 1 : 0,
+ 'must_change_password' => !empty($u['mustChangePassword']) ? 1 : 0,
+ 'created_at' => isset($u['createdAt']) ? $u['createdAt'] : now_iso(),
+ ));
+ }
+ }
+ @rename($usersFile, $usersFile . '.bak');
+ }
+
+ if (is_file($topoIndexFile)) {
+ $idx = json_decode(@file_get_contents($topoIndexFile), true);
+ if (isset($idx['topologies']) && is_array($idx['topologies'])) {
+ foreach ($idx['topologies'] as $t) {
+ if (empty($t['id']) || topo_find($t['id'])) {
+ continue;
+ }
+ $content = array('nodes' => array(), 'edges' => array());
+ $cf = $topoDir . DIRECTORY_SEPARATOR . $t['id'] . '.json';
+ if (is_file($cf)) {
+ $c = json_decode(@file_get_contents($cf), true);
+ if (is_array($c)) {
+ $content = $c;
+ }
+ }
+ topo_insert(array(
+ 'id' => $t['id'],
+ 'name' => isset($t['name']) ? $t['name'] : '未命名',
+ 'owner_id' => isset($t['ownerId']) ? $t['ownerId'] : '',
+ 'visibility' => isset($t['visibility']) ? $t['visibility'] : 'private',
+ 'data' => json_encode($content, JSON_UNESCAPED_UNICODE),
+ 'node_count' => isset($content['nodes']) ? count($content['nodes']) : 0,
+ 'edge_count' => isset($content['edges']) ? count($content['edges']) : 0,
+ 'created_at' => isset($t['createdAt']) ? $t['createdAt'] : now_iso(),
+ 'updated_at' => isset($t['updatedAt']) ? $t['updatedAt'] : now_iso(),
+ ));
+ }
+ }
+ @rename($topoIndexFile, $topoIndexFile . '.bak');
+ }
+}
+
+/**
+ * 首次初始化:迁移旧数据 + 创建默认管理员
+ */
+function ensure_seed()
+{
+ migrate_legacy_json();
+
+ if ((int) db_val('SELECT COUNT(*) FROM users') > 0) {
+ return;
+ }
+ $cfg = app_config();
+ $admin = isset($cfg['default_admin']) ? $cfg['default_admin'] : array('username' => 'admin', 'password' => 'admin123');
+ user_insert(array(
+ 'id' => gen_id('u'),
+ 'username' => $admin['username'],
+ 'password_hash' => password_hash($admin['password'], PASSWORD_DEFAULT),
+ 'role' => 'admin',
+ 'disabled' => 0,
+ 'must_change_password' => 1,
+ 'created_at' => now_iso(),
+ ));
+}
diff --git a/web/admin.html b/web/admin.html
new file mode 100644
index 0000000..7e5bb4c
--- /dev/null
+++ b/web/admin.html
@@ -0,0 +1,222 @@
+
+
+
+
+
+管理控制台 · 路由拓扑
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 用户名 | 角色 | 状态 | 登录次数 | 最近登录 | 创建时间 | 操作 |
+
+
+
+
+
+
+
+
+
+
+
拓扑管理
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
品牌
+
+
网站 Logo
+
+
R
+
+
+
+
+
+
+
支持 PNG / JPG / GIF / WEBP / SVG,建议方形透明底,不超过 300KB。
+
+
+
+
+
注册与会话
+
+
+
修改后,新登录的会话按该时长计算有效期(1 - 365 天)。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
新建用户
×
+
+
+
+
新建用户首次登录后需强制修改密码
+
+
+
+
+
+
+
+
+
+
+
+
重置用户密码
×
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
请确认
×
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/admin.js b/web/admin.js
new file mode 100644
index 0000000..7f9c0ec
--- /dev/null
+++ b/web/admin.js
@@ -0,0 +1,779 @@
+/* =========================================================================
+ 路由拓扑 · 管理控制台(独立页面)
+ 页面地址: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 '' +
+ '
' + 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,
+ 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')
+ ? '管理员'
+ : '普通用户';
+ const status = u.disabled
+ ? '已禁用'
+ : '正常';
+ const pwdFlag = u.mustChangePassword ? ' 待改密' : '';
+ return '' +
+ '| ' + esc(u.username) + ' | ' +
+ '' + 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;
+ 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) || '重置失败';
+ });
+ }
+
+ /* ============================================================
+ 拓扑管理
+ ============================================================ */
+ 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);
+
+ $('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();
+ }
+})();
diff --git a/web/app.js b/web/app.js
new file mode 100644
index 0000000..abd2ae5
--- /dev/null
+++ b/web/app.js
@@ -0,0 +1,2026 @@
+(function () {
+'use strict';
+
+/* ============================================================
+ 常量
+ ============================================================ */
+const NS = 'http://www.w3.org/2000/svg';
+const FONT = 'ui-monospace, Consolas, "Cascadia Mono", "Microsoft YaHei", monospace';
+const LAST_TOPO_KEY = 'route-topo-last';
+
+const NODE_STYLE = {
+ net: { minW:152, h:48, r:12, fs:14, fill:'#eef4ff', stroke:'#3b6fd4' },
+ host: { minW:118, h:42, r:11, fs:13, fill:'#e9fbf5', stroke:'#0d9488' },
+ domain: { minW:128, h:42, r:11, fs:13, fill:'#f4efff', stroke:'#7c3aed' },
+ other: { minW:118, h:42, r:11, fs:13, fill:'#f1f5f9', stroke:'#64748b' }
+};
+
+const STATUSES = {
+ unknown: { label:'未验证', color:'#94a3b8' },
+ confirmed: { label:'已确认', color:'#10b981' },
+ pivot: { label:'跳板', color:'#f59e0b' },
+ owned: { label:'已控制', color:'#ef4444' },
+ blocked: { label:'不可达', color:'#64748b' }
+};
+
+const TYPES = ['net','host','domain','other'];
+
+/* ============================================================
+ 运行时状态
+ ============================================================ */
+let state = { nodes: [], edges: [], view: { tx: 0, ty: 0, s: 1 } };
+let selectedId = null;
+let drag = null; // {type:'node'|'pan'|'link', ...}
+let history = []; // 撤销栈(快照字符串)
+let fieldSnapshot = null;
+
+/* 登录 / 存储相关 */
+let currentUser = null; // {id,username,role,...} 或 null
+let topoId = null; // 当前打开的拓扑 ID
+let topoName = ''; // 当前拓扑名称
+let topoCanEdit = false; // 当前拓扑是否可写
+let readOnly = false; // 登录但无写权限(他人公开拓扑)
+let topoScope = 'mine'; // 我的拓扑面板范围:'mine'(当前用户) | 'public'(公开)
+let dirty = false;
+let saving = false;
+let loading = false; // 载入数据时抑制脏标记
+let saveTimer = null;
+let lastSavedAt = null;
+let loginMode = 'login'; // 'login' | 'register'
+let siteAllowRegistration = true; // 站点是否开放注册(由后端设置控制)
+
+/* ============================================================
+ DOM
+ ============================================================ */
+const svg = document.getElementById('svg');
+const viewport = document.getElementById('viewport');
+const statsEl = document.getElementById('stats');
+const inspEmpty= document.getElementById('inspEmpty');
+const inspBody = document.getElementById('inspBody');
+const inspMeta = document.getElementById('inspMeta');
+const fLabel = document.getElementById('fLabel');
+const fType = document.getElementById('fType');
+const fStatus = document.getElementById('fStatus');
+const fPorts = document.getElementById('fPorts');
+const fNote = document.getElementById('fNote');
+const fileInput= document.getElementById('fileInput');
+const btnUndo = document.getElementById('btnUndo');
+const saveStateEl = document.getElementById('saveState');
+const bannerEl = document.getElementById('banner');
+const btnSaveEl= document.getElementById('btnSave');
+const btnUserEl= document.getElementById('btnUser');
+const btnMyToposEl = document.getElementById('btnMyTopos');
+
+/* ============================================================
+ 基础工具
+ ============================================================ */
+function el(tag, attrs, children) {
+ const e = document.createElementNS(NS, tag);
+ if (attrs) {
+ for (const k in attrs) {
+ const v = attrs[k];
+ if (v === null || v === undefined || v === false) continue;
+ e.setAttribute(k, v);
+ }
+ }
+ if (children !== undefined && children !== null) {
+ (Array.isArray(children) ? children : [children]).forEach(c => {
+ if (c === null || c === undefined || c === false) return;
+ e.append(typeof c === 'object' ? c : document.createTextNode(String(c)));
+ });
+ }
+ return e;
+}
+
+function uid() { return 'n' + Math.random().toString(36).slice(2, 9); }
+function getNode(id) { return state.nodes.find(n => n.id === id) || null; }
+function esc(s) {
+ return String(s == null ? '' : s)
+ .replace(/&/g,'&').replace(//g,'>')
+ .replace(/"/g,'"').replace(/'/g,''');
+}
+function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
+function ts() {
+ const d = new Date(), p = n => String(n).padStart(2,'0');
+ return `${d.getFullYear()}${p(d.getMonth()+1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
+}
+function fmtTime(iso) {
+ if (!iso) return '—';
+ const d = new Date(iso);
+ if (isNaN(d.getTime())) return '—';
+ const p = n => String(n).padStart(2,'0');
+ return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
+}
+
+const _mc = document.createElement('canvas').getContext('2d');
+function textWidth(text, font) {
+ _mc.font = font;
+ return _mc.measureText(String(text || ' ')).width;
+}
+
+/* 编辑权限:游客可在浏览器内临时编辑;登录后按拓扑归属判定 */
+function canEdit() { return currentUser ? topoCanEdit : true; }
+function canSave() { return !!currentUser && !!topoId && topoCanEdit; }
+
+/* ============================================================
+ 尺寸 / 几何
+ ============================================================ */
+function nodeMetrics(n) {
+ const isChild = !!n.parentId;
+ const st = NODE_STYLE[n.type] || NODE_STYLE.other;
+ const fs = isChild ? 11.5 : st.fs;
+ const font = `600 ${fs}px ${FONT}`;
+ const tw = textWidth(n.label || ' ', font);
+ const leftPad = isChild ? 10 : 14;
+ const gap = isChild ? 8 : 11;
+ const rightPad = isChild ? 14 : 18;
+ const w = Math.max(isChild ? 96 : st.minW, leftPad + gap + tw + rightPad);
+ const h = isChild ? 30 : st.h;
+ return { w, h, r: isChild ? 7 : st.r, fs, leftPad, gap, rightPad, tw };
+}
+
+/** 从矩形中心向 (dx,dy) 方向射出,求与矩形边界的交点 */
+function rayToRect(cx, cy, w, h, dx, dy) {
+ const hw = w / 2 + 3, hh = h / 2 + 3;
+ const ax = Math.abs(dx), ay = Math.abs(dy);
+ let k = Infinity;
+ if (ax > 1e-6) k = Math.min(k, hw / ax);
+ if (ay > 1e-6) k = Math.min(k, hh / ay);
+ if (!isFinite(k)) k = 0;
+ return { x: cx + dx * k, y: cy + dy * k };
+}
+
+/** 计算两节点之间的连线路径与中点。
+ * p1、p2 均落在「两节点中心的连线」上,因此起点指向 p2 的方向
+ * 即为指向目标节点中心的方向;末端预留一小段沿该方向的直线段,
+ * 让 marker 箭头(orient=auto)能准确指向目标节点,而非随弧线切线偏移。 */
+function edgeCurve(a, b) {
+ const ma = nodeMetrics(a), mb = nodeMetrics(b);
+ const dx = b.x - a.x, dy = b.y - a.y;
+ const p1 = rayToRect(a.x, a.y, ma.w, ma.h, dx, dy);
+ const p2 = rayToRect(b.x, b.y, mb.w, mb.h, -dx, -dy);
+ const dist = Math.hypot(p2.x - p1.x, p2.y - p1.y) || 1;
+ const ux = (p2.x - p1.x) / dist, uy = (p2.y - p1.y) / dist; /* 指向目标节点中心 */
+ const nx = -uy, ny = ux; /* 法向,用于弯曲弧线 */
+ const bow = Math.min(dist * 0.16, 52);
+ const head = Math.min(14, dist * 0.35); /* 末端直线段长度(箭头方向段) */
+ const q2 = { x: p2.x - ux * head, y: p2.y - uy * head };
+ const cx = (p1.x + q2.x) / 2 + nx * bow, cy = (p1.y + q2.y) / 2 + ny * bow;
+ return {
+ d: `M ${p1.x.toFixed(2)} ${p1.y.toFixed(2)} Q ${cx.toFixed(2)} ${cy.toFixed(2)} ${q2.x.toFixed(2)} ${q2.y.toFixed(2)} L ${p2.x.toFixed(2)} ${p2.y.toFixed(2)}`,
+ mid: { x: (p1.x + 2*cx + q2.x) / 4, y: (p1.y + 2*cy + q2.y) / 4 }
+ };
+}
+
+/* ============================================================
+ 渲染
+ ============================================================ */
+function render() {
+ const v = state.view;
+ viewport.setAttribute('transform', `translate(${v.tx.toFixed(2)},${v.ty.toFixed(2)}) scale(${v.s.toFixed(4)})`);
+ viewport.textContent = '';
+
+ const gEdges = el('g', { class: 'layer-edges' });
+ const gNodes = el('g', { class: 'layer-nodes' });
+
+ /* ---- 1. 父子虚线(归属关系) ---- */
+ state.nodes.forEach(c => {
+ if (!c.parentId) return;
+ const p = getNode(c.parentId);
+ if (!p) return;
+ const mp = nodeMetrics(p), mc = nodeMetrics(c);
+ const dx = c.x - p.x, dy = c.y - p.y;
+ const a = rayToRect(p.x, p.y, mp.w, mp.h, dx, dy);
+ const b = rayToRect(c.x, c.y, mc.w, mc.h, -dx, -dy);
+ gEdges.append(el('path', {
+ d: `M ${a.x.toFixed(2)} ${a.y.toFixed(2)} L ${b.x.toFixed(2)} ${b.y.toFixed(2)}`,
+ stroke: '#cbd5e1', 'stroke-width': 1.3,
+ 'stroke-dasharray': '4 4', fill: 'none'
+ }));
+ });
+
+ /* ---- 2. 有向连线 ---- */
+ state.edges.forEach(edge => {
+ const a = getNode(edge.from), b = getNode(edge.to);
+ if (!a || !b) return;
+ const { d, mid } = edgeCurve(a, b);
+ const g = el('g', { class: 'edge', 'data-id': edge.id });
+
+ g.append(el('path', { class:'edge-hit', d, fill:'none', stroke:'transparent', 'stroke-width':16 }));
+ g.append(el('path', {
+ class: 'edge-line', d, fill: 'none', stroke: '#94a3b8',
+ 'stroke-width': 1.8, 'marker-end': 'url(#arrow)'
+ }));
+
+ const del = el('g', {
+ class: 'edge-del', 'data-id': edge.id,
+ transform: `translate(${mid.x.toFixed(2)},${mid.y.toFixed(2)})`
+ });
+ del.append(el('circle', { r: 9, fill: '#ef4444', stroke: '#ffffff', 'stroke-width': 1.5 }));
+ del.append(el('path', {
+ d: 'M -3.1 -3.1 L 3.1 3.1 M 3.1 -3.1 L -3.1 3.1',
+ stroke: '#fff', 'stroke-width': 1.8, 'stroke-linecap': 'round'
+ }));
+ g.append(del);
+ gEdges.append(g);
+ });
+
+ /* ---- 3. 连线拖拽中的临时线 ---- */
+ if (drag && drag.type === 'link') {
+ const a = getNode(drag.from);
+ if (a) {
+ const m = nodeMetrics(a);
+ const dx = drag.cursor.x - a.x, dy = drag.cursor.y - a.y;
+ const p1 = rayToRect(a.x, a.y, m.w, m.h, dx, dy);
+ let p2 = drag.cursor;
+ const tgt = drag.target ? getNode(drag.target) : null;
+ if (tgt && tgt.id !== a.id) {
+ const mt = nodeMetrics(tgt);
+ p2 = rayToRect(tgt.x, tgt.y, mt.w, mt.h, a.x - tgt.x, a.y - tgt.y);
+ }
+ gEdges.append(el('path', {
+ d: `M ${p1.x.toFixed(2)} ${p1.y.toFixed(2)} L ${p2.x.toFixed(2)} ${p2.y.toFixed(2)}`,
+ stroke: tgt ? '#10b981' : '#3b82f6',
+ 'stroke-width': 2, 'stroke-dasharray': '6 4',
+ fill: 'none', 'stroke-linecap': 'round'
+ }));
+ }
+ }
+
+ /* ---- 4. 节点 ---- */
+ state.nodes.forEach(n => gNodes.append(buildNode(n)));
+
+ viewport.append(gEdges, gNodes);
+ updateStats();
+}
+
+function buildNode(n) {
+ const m = nodeMetrics(n);
+ const st = NODE_STYLE[n.type] || NODE_STYLE.other;
+ const isChild = !!n.parentId;
+ const status = STATUSES[n.status] || STATUSES.unknown;
+ const sel = n.id === selectedId;
+ const isTarget= !!(drag && drag.type === 'link' && drag.target === n.id && drag.from !== n.id);
+
+ const g = el('g', {
+ class: 'node' + (isChild ? ' child' : '') + (sel ? ' selected' : ''),
+ 'data-id': n.id,
+ transform: `translate(${n.x.toFixed(2)},${n.y.toFixed(2)})`
+ });
+
+ const strokeColor = isTarget ? '#10b981' : (sel ? '#2563eb' : st.stroke);
+
+ /* 投影 */
+ g.append(el('rect', {
+ x: -m.w/2, y: -m.h/2 + 3, width: m.w, height: m.h,
+ rx: m.r, ry: m.r, fill: 'rgba(15,23,42,.12)'
+ }));
+
+ /* 主体 */
+ g.append(el('rect', {
+ x: -m.w/2, y: -m.h/2, width: m.w, height: m.h,
+ rx: m.r, ry: m.r,
+ fill: st.fill, stroke: strokeColor,
+ 'stroke-width': (sel || isTarget) ? 2 : 1.3
+ }));
+
+ /* 左侧状态色条 */
+ g.append(el('rect', {
+ x: -m.w/2 + 1.5, y: -m.h/2 + (m.h - Math.min(m.h - 14, 26)) / 2,
+ width: 3, height: Math.min(m.h - 14, 26),
+ rx: 1.5, fill: status.color, opacity: .95
+ }));
+
+ /* 标签文本 */
+ g.append(el('text', {
+ x: (-m.w/2 + m.leftPad + m.gap).toFixed(2),
+ y: 0.5,
+ 'dominant-baseline': 'central',
+ 'font-family': FONT,
+ 'font-size': m.fs,
+ 'font-weight': 600,
+ fill: isChild ? '#475569' : '#1e293b',
+ 'pointer-events': 'none'
+ }, n.label || ''));
+
+ /* 端口小提示 */
+ if (n.ports) {
+ g.append(el('text', {
+ x: (m.w/2 - m.rightPad + 4).toFixed(2),
+ y: 0.5,
+ 'text-anchor': 'end',
+ 'dominant-baseline': 'central',
+ 'font-family': FONT, 'font-size': 10,
+ fill: '#94a3b8', 'pointer-events': 'none'
+ }, '·'));
+ }
+
+ /* 连线手柄(右端):蓝色端口 + 白色箭头,提示「从这里拉线连接其它节点」 */
+ const linkH = el('g', {
+ class: 'link-handle', 'data-id': n.id,
+ transform: `translate(${(m.w/2).toFixed(2)},0)`
+ });
+ linkH.append(el('circle', { r: 18, fill: 'transparent' })); /* 更大的触控热区 */
+ linkH.append(el('circle', { r: 7.5, fill: '#3b82f6', stroke: '#ffffff', 'stroke-width': 2 }));
+ linkH.append(el('path', {
+ d: 'M -1.9 -2.9 L 2.2 0 L -1.9 2.9 Z',
+ fill: '#ffffff', 'pointer-events': 'none'
+ }));
+ g.append(linkH);
+
+ /* 子节点手柄(底部):绿色端口 + 白色加号,提示「在这里新增子节点」 */
+ if (!isChild) {
+ const addH = el('g', {
+ class: 'add-handle', 'data-id': n.id,
+ transform: `translate(0,${(m.h/2).toFixed(2)})`
+ });
+ addH.append(el('circle', { r: 17, fill: 'transparent' })); /* 更大的触控热区 */
+ addH.append(el('circle', { r: 7.5, fill: '#10b981', stroke: '#ffffff', 'stroke-width': 2 }));
+ addH.append(el('path', {
+ d: 'M -3 0 L 3 0 M 0 -3 L 0 3',
+ stroke: '#ffffff', 'stroke-width': 2, 'stroke-linecap': 'round',
+ fill: 'none', 'pointer-events': 'none'
+ }));
+ g.append(addH);
+ }
+
+ return g;
+}
+
+function updateStats() {
+ statsEl.textContent =
+ `节点 ${state.nodes.length} · 连线 ${state.edges.length} · 缩放 ${Math.round(state.view.s * 100)}%`;
+}
+
+/* ============================================================
+ 历史记录
+ ============================================================ */
+function snapshot() {
+ return JSON.stringify({ nodes: state.nodes, edges: state.edges });
+}
+function pushHistory(snap) {
+ if (!snap) return;
+ if (history.length && history[history.length - 1] === snap) return;
+ history.push(snap);
+ if (history.length > 100) history.shift();
+ updateUndoBtn();
+}
+function undo() {
+ if (!canEdit()) return;
+ const s = history.pop();
+ updateUndoBtn();
+ if (!s) return;
+ try {
+ const d = JSON.parse(s);
+ state.nodes = d.nodes || [];
+ state.edges = d.edges || [];
+ } catch (e) { return; }
+ if (selectedId && !getNode(selectedId)) selectedId = null;
+ render();
+ syncInspector();
+ onDataChanged();
+}
+function updateUndoBtn() { btnUndo.disabled = history.length === 0 || !canEdit(); }
+
+/* ============================================================
+ 坐标换算
+ ============================================================ */
+function toWorld(e) {
+ const r = svg.getBoundingClientRect();
+ return {
+ x: (e.clientX - r.left - state.view.tx) / state.view.s,
+ y: (e.clientY - r.top - state.view.ty) / state.view.s
+ };
+}
+
+/* ============================================================
+ 节点 / 连线 操作
+ ============================================================ */
+function createNode(opts) {
+ opts = opts || {};
+ const n = {
+ id: uid(),
+ label: opts.label || '新节点',
+ type: TYPES.includes(opts.type) ? opts.type : 'host',
+ x: Number.isFinite(opts.x) ? opts.x : 0,
+ y: Number.isFinite(opts.y) ? opts.y : 0,
+ parentId: opts.parentId || null,
+ status: STATUSES[opts.status] ? opts.status : 'unknown',
+ ports: opts.ports || '',
+ note: opts.note || ''
+ };
+ state.nodes.push(n);
+ return n;
+}
+
+function selectNode(id) {
+ if (selectedId === id) return;
+ selectedId = id;
+ syncInspector();
+ render();
+}
+
+function addNodeAtCenter() {
+ if (!canEdit()) return;
+ const r = svg.getBoundingClientRect();
+ const wx = (r.width / 2 - state.view.tx) / state.view.s;
+ const wy = (r.height / 2 - state.view.ty) / state.view.s;
+ pushHistory(snapshot());
+ const n = createNode({ x: wx, y: wy, label: '新节点', type: 'host' });
+ selectedId = n.id;
+ render();
+ syncInspector();
+ onDataChanged();
+ requestAnimationFrame(() => { fLabel.focus(); fLabel.select(); });
+}
+
+function addChildNode(parentId) {
+ if (!canEdit()) return;
+ const p = getNode(parentId);
+ if (!p) return;
+ pushHistory(snapshot());
+ const pm = nodeMetrics(p);
+ const siblings = state.nodes.filter(n => n.parentId === parentId).length;
+ const c = createNode({
+ label: '10.0.0.' + (siblings + 1),
+ type: 'host',
+ x: p.x,
+ y: p.y + pm.h / 2 + 46,
+ parentId: parentId
+ });
+ selectedId = c.id;
+ render();
+ syncInspector();
+ onDataChanged();
+ requestAnimationFrame(() => { fLabel.focus(); fLabel.select(); });
+}
+
+function addEdge(from, to) {
+ if (!canEdit()) return;
+ if (!from || !to || from === to) return;
+ if (state.edges.some(e => e.from === from && e.to === to)) return;
+ pushHistory(snapshot());
+ state.edges.push({ id: uid(), from: from, to: to, label: '' });
+ onDataChanged();
+}
+
+function removeEdge(id) {
+ if (!canEdit()) return;
+ const i = state.edges.findIndex(e => e.id === id);
+ if (i < 0) return;
+ pushHistory(snapshot());
+ state.edges.splice(i, 1);
+ render();
+ onDataChanged();
+}
+
+function removeNode(id) {
+ if (!canEdit()) return;
+ if (!getNode(id)) return;
+ pushHistory(snapshot());
+ state.nodes = state.nodes.filter(n => n.id !== id && n.parentId !== id);
+ state.edges = state.edges.filter(e => e.from !== id && e.to !== id);
+ if (selectedId === id) selectedId = null;
+ render();
+ syncInspector();
+ onDataChanged();
+}
+
+function deleteSelected() {
+ if (!selectedId) return;
+ removeNode(selectedId);
+}
+
+/* ============================================================
+ 指针交互
+ ============================================================ */
+svg.addEventListener('pointerdown', function (e) {
+ if (e.button !== 0 && e.button !== 1) return;
+ const t = e.target;
+
+ /* --- 删除连线按钮 --- */
+ const delBtn = t.closest && t.closest('.edge-del');
+ if (delBtn) {
+ e.preventDefault();
+ removeEdge(delBtn.getAttribute('data-id'));
+ return;
+ }
+
+ /* --- 连线手柄 --- */
+ const lh = t.closest && t.closest('.link-handle');
+ if (lh) {
+ if (!canEdit()) return;
+ e.preventDefault();
+ const id = lh.getAttribute('data-id');
+ drag = { type: 'link', from: id, cursor: toWorld(e), target: null };
+ capture(e);
+ render();
+ return;
+ }
+
+ /* --- 添加子节点手柄 --- */
+ const ah = t.closest && t.closest('.add-handle');
+ if (ah) {
+ e.preventDefault();
+ addChildNode(ah.getAttribute('data-id'));
+ return;
+ }
+
+ /* --- 节点本体 --- */
+ const ng = t.closest && t.closest('.node');
+ if (ng) {
+ e.preventDefault();
+ const id = ng.getAttribute('data-id');
+ const n = getNode(id);
+ if (!n) return;
+
+ if (selectedId !== id) { selectedId = id; syncInspector(); }
+
+ if (!canEdit()) { render(); return; }
+
+ const pt = toWorld(e);
+ const group = [n].concat(state.nodes.filter(c => c.parentId === id));
+
+ drag = {
+ type: 'node',
+ id: id,
+ before: snapshot(),
+ moved: false,
+ items: group.map(x => ({ id: x.id, ox: x.x - pt.x, oy: x.y - pt.y }))
+ };
+ capture(e);
+ render();
+ return;
+ }
+
+ /* --- 空白:平移 --- */
+ e.preventDefault();
+ drag = {
+ type: 'pan',
+ startX: e.clientX, startY: e.clientY,
+ tx: state.view.tx, ty: state.view.ty
+ };
+ capture(e);
+});
+
+function capture(e) {
+ try { svg.setPointerCapture(e.pointerId); } catch (err) { /* ignore */ }
+}
+
+svg.addEventListener('pointermove', function (e) {
+ if (!drag) return;
+
+ if (drag.type === 'node') {
+ const pt = toWorld(e);
+ let moved = false;
+ drag.items.forEach(d => {
+ const n = getNode(d.id);
+ if (!n) return;
+ const nx = pt.x + d.ox, ny = pt.y + d.oy;
+ if (Math.abs(nx - n.x) > 0.01 || Math.abs(ny - n.y) > 0.01) moved = true;
+ n.x = nx; n.y = ny;
+ });
+ if (moved) drag.moved = true;
+ render();
+ return;
+ }
+
+ if (drag.type === 'pan') {
+ state.view.tx = drag.tx + (e.clientX - drag.startX);
+ state.view.ty = drag.ty + (e.clientY - drag.startY);
+ render();
+ return;
+ }
+
+ if (drag.type === 'link') {
+ drag.cursor = toWorld(e);
+ let hovered = null;
+ const under = document.elementFromPoint(e.clientX, e.clientY);
+ if (under && under.closest) {
+ const ng = under.closest('.node');
+ if (ng) hovered = ng.getAttribute('data-id');
+ }
+ drag.target = (hovered && hovered !== drag.from) ? hovered : null;
+ render();
+ }
+});
+
+function endDrag(e) {
+ if (!drag) return;
+ const d = drag;
+ drag = null;
+
+ if (d.type === 'link') {
+ if (d.target) addEdge(d.from, d.target);
+ render();
+ return;
+ }
+
+ if (d.type === 'node') {
+ if (d.moved) { pushHistory(d.before); onDataChanged(); }
+ render();
+ return;
+ }
+
+ if (d.type === 'pan') {
+ const dist = Math.hypot(e.clientX - d.startX, e.clientY - d.startY);
+ if (dist < 4) { // 视作点击空白
+ selectedId = null;
+ syncInspector();
+ }
+ render();
+ }
+}
+
+svg.addEventListener('pointerup', endDrag);
+svg.addEventListener('pointercancel', function (e) {
+ if (drag) { drag = null; render(); }
+});
+
+/* --- 双击空白新建节点 --- */
+svg.addEventListener('dblclick', function (e) {
+ if (e.target.closest && e.target.closest('.node')) return;
+ if (!canEdit()) return;
+ const pt = toWorld(e);
+ pushHistory(snapshot());
+ const n = createNode({ x: pt.x, y: pt.y, label: '新节点', type: 'host' });
+ selectedId = n.id;
+ render();
+ syncInspector();
+ onDataChanged();
+ requestAnimationFrame(() => { fLabel.focus(); fLabel.select(); });
+});
+
+/* --- 滚轮缩放 --- */
+svg.addEventListener('wheel', function (e) {
+ e.preventDefault();
+ const r = svg.getBoundingClientRect();
+ const px = e.clientX - r.left;
+ const py = e.clientY - r.top;
+
+ const wx = (px - state.view.tx) / state.view.s;
+ const wy = (py - state.view.ty) / state.view.s;
+
+ const factor = Math.exp(-e.deltaY * 0.0016);
+ const ns = clamp(state.view.s * factor, 0.15, 4);
+
+ state.view.s = ns;
+ state.view.tx = px - wx * ns;
+ state.view.ty = py - wy * ns;
+ render();
+}, { passive: false });
+
+/* ============================================================
+ 键盘
+ ============================================================ */
+document.addEventListener('keydown', function (e) {
+ const tag = (e.target.tagName || '').toLowerCase();
+ if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
+
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') {
+ e.preventDefault();
+ undo();
+ return;
+ }
+ if (e.key === 'Delete' || e.key === 'Backspace') {
+ if (selectedId) { e.preventDefault(); deleteSelected(); }
+ return;
+ }
+ if (e.key === 'Escape') {
+ selectedId = null;
+ syncInspector();
+ render();
+ }
+});
+
+/* ============================================================
+ 检查器
+ ============================================================ */
+(function initStatusSelect() {
+ fStatus.innerHTML = Object.keys(STATUSES)
+ .map(k => ``).join('');
+})();
+
+(function initLegend() {
+ document.getElementById('legend').innerHTML =
+ '状态图例
' +
+ Object.keys(STATUSES).map(k =>
+ `${STATUSES[k].label}`
+ ).join('') +
+ '
';
+})();
+
+function syncInspector() {
+ const n = getNode(selectedId);
+ if (!n) {
+ inspEmpty.hidden = false;
+ inspBody.hidden = true;
+ return;
+ }
+ inspEmpty.hidden = true;
+ inspBody.hidden = false;
+
+ fLabel.value = n.label || '';
+ fType.value = n.type || 'host';
+ fStatus.value = STATUSES[n.status] ? n.status : 'unknown';
+ fPorts.value = n.ports || '';
+ fNote.value = n.note || '';
+
+ const ins = state.edges.filter(e => e.to === n.id)
+ .map(e => getNode(e.from)).filter(Boolean);
+ const outs = state.edges.filter(e => e.from === n.id)
+ .map(e => getNode(e.to)).filter(Boolean);
+ const parent = n.parentId ? getNode(n.parentId) : null;
+
+ inspMeta.innerHTML =
+ `上游${ins.length ? ins.map(x => esc(x.label)).join('、') : '—'}
` +
+ `下游${outs.length ? outs.map(x => esc(x.label)).join('、') : '—'}
` +
+ (parent ? `所属${esc(parent.label)}
` : '');
+
+ updateEditability();
+}
+
+fLabel.addEventListener('input', function () {
+ const n = getNode(selectedId); if (!n) return;
+ n.label = fLabel.value;
+ render();
+ onDataChanged();
+});
+fType.addEventListener('change', function () {
+ const n = getNode(selectedId); if (!n) return;
+ n.type = fType.value;
+ render();
+ syncInspector();
+ onDataChanged();
+});
+fStatus.addEventListener('change', function () {
+ const n = getNode(selectedId); if (!n) return;
+ n.status = fStatus.value;
+ render();
+ onDataChanged();
+});
+fPorts.addEventListener('input', function () {
+ const n = getNode(selectedId); if (!n) return;
+ n.ports = fPorts.value;
+ render();
+ onDataChanged();
+});
+fNote.addEventListener('input', function () {
+ const n = getNode(selectedId); if (!n) return;
+ n.note = fNote.value;
+ onDataChanged();
+});
+
+[fLabel, fType, fStatus, fPorts, fNote].forEach(inp => {
+ inp.addEventListener('focus', () => { fieldSnapshot = snapshot(); });
+ inp.addEventListener('blur', () => {
+ if (fieldSnapshot && fieldSnapshot !== snapshot()) pushHistory(fieldSnapshot);
+ fieldSnapshot = null;
+ });
+});
+
+document.getElementById('btnAddChild').addEventListener('click', function () {
+ if (selectedId) addChildNode(selectedId);
+});
+document.getElementById('btnDelete').addEventListener('click', function () {
+ deleteSelected();
+});
+
+/* 根据权限启用/禁用编辑相关控件 */
+function updateEditability() {
+ const ok = canEdit();
+ ['btnAdd','btnUndo','btnAuto','btnClear','btnAddChild','btnDelete'].forEach(id => {
+ const b = document.getElementById(id);
+ if (b) b.disabled = !ok;
+ });
+ const undoBtn = document.getElementById('btnUndo');
+ if (undoBtn) undoBtn.disabled = !ok || history.length === 0;
+ [fLabel, fType, fStatus, fPorts, fNote].forEach(i => { if (i) i.disabled = !ok; });
+}
+
+/* ============================================================
+ 工具栏
+ ============================================================ */
+document.getElementById('btnAdd').addEventListener('click', addNodeAtCenter);
+btnUndo.addEventListener('click', undo);
+
+document.getElementById('btnFit').addEventListener('click', fitView);
+
+document.getElementById('btnAuto').addEventListener('click', function () {
+ autoLayout();
+});
+
+document.getElementById('btnClear').addEventListener('click', function () {
+ if (!canEdit()) return;
+ if (!state.nodes.length) return;
+ uiConfirm('确定清空所有节点与连线?此操作可撤销。', { title: '清空画布', okText: '清空', danger: true })
+ .then(function (ok) {
+ if (!ok) return;
+ pushHistory(snapshot());
+ state.nodes = [];
+ state.edges = [];
+ selectedId = null;
+ render();
+ syncInspector();
+ onDataChanged();
+ });
+});
+
+/* ============================================================
+ 视图适配 / 自动整理
+ ============================================================ */
+function contentBounds() {
+ if (!state.nodes.length) return null;
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+ state.nodes.forEach(n => {
+ const m = nodeMetrics(n);
+ minX = Math.min(minX, n.x - m.w / 2);
+ maxX = Math.max(maxX, n.x + m.w / 2);
+ minY = Math.min(minY, n.y - m.h / 2);
+ maxY = Math.max(maxY, n.y + m.h / 2);
+ });
+ return { minX, minY, maxX, maxY, w: maxX - minX, h: maxY - minY };
+}
+
+function fitView() {
+ const b = contentBounds();
+ if (!b) { state.view = { tx: 0, ty: 0, s: 1 }; render(); return; }
+ const r = svg.getBoundingClientRect();
+ const pad = 80;
+ const w = b.w + pad * 2, h = b.h + pad * 2;
+ const s = clamp(Math.min(r.width / w, r.height / h), 0.15, 1.6);
+ state.view.s = s;
+ state.view.tx = r.width / 2 - (b.minX + b.maxX) / 2 * s;
+ state.view.ty = r.height / 2 - (b.minY + b.maxY) / 2 * s;
+ render();
+}
+
+function autoLayout() {
+ if (!canEdit()) return;
+ if (!state.nodes.length) return;
+ pushHistory(snapshot());
+
+ const tops = state.nodes.filter(n => !n.parentId);
+ if (!tops.length) return;
+
+ const indeg = new Map();
+ tops.forEach(n => indeg.set(n.id, 0));
+ state.edges.forEach(e => {
+ if (indeg.has(e.to)) indeg.set(e.to, indeg.get(e.to) + 1);
+ });
+
+ let queue = tops.filter(n => indeg.get(n.id) === 0).map(n => n.id);
+ if (!queue.length) queue = [tops[0].id];
+
+ const depth = new Map();
+ const seen = new Set();
+ queue.forEach(id => { depth.set(id, 0); seen.add(id); });
+
+ let guard = 0;
+ while (queue.length && guard++ < 5000) {
+ const id = queue.shift();
+ const d = depth.get(id) || 0;
+ state.edges.filter(e => e.from === id).forEach(e => {
+ if (!indeg.has(e.to)) return;
+ const cur = depth.has(e.to) ? depth.get(e.to) : -1;
+ if (d + 1 > cur) depth.set(e.to, d + 1);
+ if (!seen.has(e.to)) { seen.add(e.to); queue.push(e.to); }
+ });
+ }
+ tops.forEach(n => { if (!depth.has(n.id)) depth.set(n.id, 0); });
+
+ const layers = new Map();
+ tops.forEach(n => {
+ const d = depth.get(n.id) || 0;
+ if (!layers.has(d)) layers.set(d, []);
+ layers.get(d).push(n);
+ });
+
+ const COL = 230, ROW = 118;
+ const keys = Array.from(layers.keys()).sort((a, b) => a - b);
+ let maxRowW = 0;
+ keys.forEach(d => {
+ const arr = layers.get(d);
+ arr.sort((a, b) => (a.x - b.x) || (a.label > b.label ? 1 : -1));
+ maxRowW = Math.max(maxRowW, arr.length * COL);
+ });
+
+ keys.forEach(d => {
+ const arr = layers.get(d);
+ const rowW = arr.length * COL;
+ const startX = -rowW / 2 + COL / 2 + maxRowW / 2;
+ arr.forEach((n, i) => {
+ n.x = startX + i * COL;
+ n.y = 120 + d * ROW;
+ });
+ });
+
+ /* 子节点跟随父节点重排 */
+ tops.forEach(p => {
+ const pm = nodeMetrics(p);
+ const kids = state.nodes.filter(c => c.parentId === p.id);
+ kids.forEach((c, i) => {
+ c.x = p.x + (i - (kids.length - 1) / 2) * 150;
+ c.y = p.y + pm.h / 2 + 50;
+ });
+ });
+
+ render();
+ onDataChanged();
+ requestAnimationFrame(fitView);
+}
+
+/* ============================================================
+ 变更标记 / 保存
+ ============================================================ */
+function onDataChanged() {
+ if (loading) return;
+ dirty = true;
+ updateSaveState();
+ updateBanner();
+ scheduleSave();
+}
+
+function updateSaveState() {
+ if (!saveStateEl) return;
+ saveStateEl.className = 'save-state';
+ if (!currentUser) { saveStateEl.textContent = ''; return; }
+ if (readOnly) { saveStateEl.textContent = '只读'; return; }
+ if (saving) { saveStateEl.textContent = '保存中…'; saveStateEl.classList.add('dirty'); return; }
+ if (!topoId) { saveStateEl.textContent = '未保存'; saveStateEl.classList.add('dirty'); return; }
+ if (dirty) { saveStateEl.textContent = '未保存'; saveStateEl.classList.add('dirty'); return; }
+ saveStateEl.textContent = '已保存';
+ saveStateEl.classList.add('ok');
+}
+
+function scheduleSave() {
+ // 仅在已绑定拓扑时自动保存;未绑定拓扑需手动点「保存」触发新建
+ if (!canSave() || !topoId) return;
+ clearTimeout(saveTimer);
+ saveTimer = setTimeout(function () { saveNow(true); }, 1200);
+}
+
+function defaultTopoName() {
+ const d = new Date(), p = n => String(n).padStart(2, '0');
+ return '拓扑 ' + d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) +
+ ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
+}
+
+function saveNow(silent) {
+ if (!currentUser || readOnly || saving) return Promise.resolve();
+ saving = true;
+ updateSaveState();
+
+ let p;
+ if (topoId && topoCanEdit) {
+ p = API.saveTopo(topoId, { nodes: state.nodes, edges: state.edges, name: topoName });
+ } else {
+ const name = (topoName && topoName.trim()) ? topoName.trim() : defaultTopoName();
+ p = API.createTopo({ name: name, nodes: state.nodes, edges: state.edges });
+ }
+
+ return p.then(function (r) {
+ if (r && r.topology) {
+ topoId = r.topology.id;
+ topoName = r.topology.name;
+ topoCanEdit = true;
+ readOnly = false;
+ lastSavedAt = r.topology.updatedAt;
+ try { localStorage.setItem(LAST_TOPO_KEY, topoId); } catch (e) {}
+ }
+ dirty = false;
+ if (!silent) toast('已保存:' + topoName, 'ok');
+ }).catch(function (e) {
+ saveStateEl.textContent = '保存失败';
+ saveStateEl.className = 'save-state err';
+ if (!silent) toastErr('保存失败:' + e.message);
+ }).then(function () {
+ saving = false;
+ updateSaveState();
+ updateEditability();
+ updateBanner();
+ });
+}
+
+/* ============================================================
+ 轻提示
+ ============================================================ */
+let toastTimer = null;
+function toast(msg, type) {
+ const t = document.getElementById('toast');
+ if (!t) return;
+ t.textContent = msg;
+ t.className = 'toast' + (type ? ' ' + type : '');
+ void t.offsetWidth; /* 强制重排,连续提示也有过渡 */
+ t.classList.add('show');
+ clearTimeout(toastTimer);
+ toastTimer = setTimeout(function () { t.classList.remove('show'); }, 2600);
+}
+function toastErr(msg) { toast(msg, 'err'); }
+
+/* ============================================================
+ API 客户端
+ ============================================================ */
+function qs(obj) {
+ const parts = [];
+ if (obj) {
+ for (const k in obj) {
+ const v = obj[k];
+ if (v === '' || v === null || v === undefined) continue;
+ parts.push(encodeURIComponent(k) + '=' + encodeURIComponent(v));
+ }
+ }
+ return parts.length ? ('?' + parts.join('&')) : '';
+}
+
+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'); },
+ login: function (u, p) { return this.req('POST', '/login', { username: u, password: p }); },
+ register: function (u, p) { return this.req('POST', '/register', { username: u, password: p }); },
+ logout: function () { return this.req('POST', '/logout', {}); },
+ changePassword: function (o, n) { return this.req('POST', '/password', { oldPassword: o, newPassword: n }); },
+ listTopos: function () { return this.req('GET', '/topologies'); },
+ getTopo: function (id) { return this.req('GET', '/topologies/' + encodeURIComponent(id)); },
+ createTopo: function (payload) { return this.req('POST', '/topologies', payload); },
+ saveTopo: function (id, payload) { return this.req('PUT', '/topologies/' + encodeURIComponent(id), payload); },
+ deleteTopo: function (id) { return this.req('DELETE', '/topologies/' + encodeURIComponent(id)); },
+ 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 }); },
+ 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); },
+ deleteUser: function (id) { return this.req('DELETE', '/users/' + encodeURIComponent(id)); },
+ dashboard: function () { return this.req('GET', '/dashboard'); },
+ logs: function (p) { return this.req('GET', '/logs' + qs(p)); },
+ adminTopologies: function (p) { return this.req('GET', '/admin/topologies' + qs(p)); }
+};
+
+/* ============================================================
+ 弹窗工具
+ ============================================================ */
+function openOverlay(id) { const o = document.getElementById(id); if (o) o.hidden = false; }
+function closeOverlay(id) { const o = document.getElementById(id); if (o) o.hidden = true; }
+
+/* 站内确认 / 输入弹窗(替代原生 confirm / prompt) */
+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 = document.getElementById('confirmField').value.trim();
+ if (!v) { document.getElementById('confirmErr').textContent = '请输入内容'; return; }
+ _dlgFinish(v);
+ } else {
+ _dlgFinish(true);
+ }
+}
+function _uiDialog(o) {
+ return new Promise(function (resolve) {
+ _dlgResolve = resolve;
+ _dlgInput = !!o.input;
+ document.getElementById('confirmTitle').textContent = o.title || '请确认';
+ const msg = document.getElementById('confirmMsg');
+ msg.textContent = o.message || '';
+ msg.hidden = !o.message;
+ const okBtn = document.getElementById('confirmOk');
+ okBtn.textContent = o.okText || '确定';
+ okBtn.className = 'btn ' + (o.danger ? 'danger' : 'primary');
+ const wrap = document.getElementById('confirmFieldWrap');
+ const field = document.getElementById('confirmField');
+ if (o.input) {
+ wrap.hidden = false;
+ document.getElementById('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;
+ }
+ document.getElementById('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 openLogin() {
+ loginMode = 'login';
+ applyLoginMode();
+ document.getElementById('loginErr').textContent = '';
+ document.getElementById('loginPass').value = '';
+ document.getElementById('loginPass2').value = '';
+ const show = document.getElementById('loginShow');
+ if (show) show.checked = false;
+ ['loginPass', 'loginPass2'].forEach(function (id) {
+ const el = document.getElementById(id);
+ if (el) el.type = 'password';
+ });
+ openOverlay('loginOverlay');
+ setTimeout(function () { document.getElementById('loginUser').focus(); }, 30);
+}
+
+function applyLoginMode() {
+ const isReg = (loginMode !== 'login');
+ const title = document.getElementById('loginTitle');
+ const submit = document.getElementById('btnDoLogin');
+ const toggle = document.getElementById('btnRegister');
+ const tip = document.getElementById('loginTip');
+ const pass2Wrap = document.getElementById('loginPass2Wrap');
+ const pass2 = document.getElementById('loginPass2');
+ if (isReg) {
+ title.textContent = '注册';
+ submit.textContent = '注册并登录';
+ toggle.textContent = '返回登录';
+ tip.textContent = '注册的账号为普通用户,可由管理员调整权限';
+ } else {
+ title.textContent = '登录';
+ submit.textContent = '登录';
+ toggle.textContent = '注册新账号';
+ tip.textContent = '默认管理员 admin / admin123';
+ }
+ if (pass2Wrap) pass2Wrap.hidden = !isReg;
+ if (pass2 && !isReg) pass2.value = '';
+}
+
+/* 应用站点设置(名称 / Logo / 注册开关) */
+function applySite(site) {
+ if (!site) return;
+ if (site.name) {
+ document.title = site.name;
+ const h1 = document.getElementById('brandName');
+ if (h1) h1.textContent = site.name;
+ }
+ applyBrandLogo('brandLogo', site.logo);
+ siteAllowRegistration = (site.allowRegistration !== false);
+ const regBtn = document.getElementById('btnRegister');
+ if (regBtn) regBtn.hidden = !siteAllowRegistration;
+}
+
+/* 品牌 Logo:有图片时显示图片,否则回退为字母 R */
+function applyBrandLogo(id, logo) {
+ const el = document.getElementById(id);
+ if (!el) return;
+ if (logo) {
+ el.classList.add('has-img');
+ el.innerHTML = '';
+ const img = document.createElement('img');
+ img.src = logo;
+ img.alt = '';
+ el.appendChild(img);
+ } else {
+ el.classList.remove('has-img');
+ el.textContent = 'R';
+ }
+}
+
+function doLoginOrRegister() {
+ const u = document.getElementById('loginUser').value.trim();
+ const p = document.getElementById('loginPass').value;
+ const errEl = document.getElementById('loginErr');
+ errEl.textContent = '';
+ if (!u || !p) { errEl.textContent = '请输入用户名和密码'; return; }
+ if (loginMode !== 'login') {
+ const p2 = document.getElementById('loginPass2').value;
+ if (p !== p2) { errEl.textContent = '两次输入的密码不一致'; return; }
+ }
+
+ const req = (loginMode === 'login') ? API.login(u, p) : API.register(u, p);
+ req.then(function (r) {
+ currentUser = r.user;
+ closeOverlay('loginOverlay');
+ onUserChanged();
+ bootAfterLogin();
+ maybeForcePwdChange();
+ }).catch(function (e) { errEl.textContent = e.message; });
+}
+
+function bootAfterLogin() {
+ toast('已登录:' + currentUser.username, 'ok');
+ API.listTopos().then(function (r) {
+ const list = r.topologies || [];
+ let last = null;
+ try { last = localStorage.getItem(LAST_TOPO_KEY); } catch (e) {}
+ let target = null;
+ if (last) { target = list.filter(function (t) { return t.id === last; })[0] || null; }
+ if (!target && list.length) {
+ target = list.slice().sort(function (a, b) {
+ return (b.updatedAt || '').localeCompare(a.updatedAt || '');
+ })[0];
+ }
+ if (target) { openTopology(target.id); }
+ else { loadDraft(); }
+ }).catch(function (e) { toastErr(e.message); loadDraft(); });
+}
+
+function maybeForcePwdChange() {
+ if (currentUser && currentUser.mustChangePassword) {
+ openPwdOverlay(true);
+ return true;
+ }
+ return false;
+}
+
+let pwdForced = false;
+
+function pwdStrengthError(pw, oldPw, username) {
+ if (!pw || pw.length < 8) return '密码至少 8 位';
+ if (!/[A-Za-z]/.test(pw) || !/[0-9]/.test(pw)) return '密码必须同时包含字母和数字';
+ if (username && pw.toLowerCase() === String(username).toLowerCase()) return '密码不能与用户名相同';
+ if (oldPw && pw === oldPw) return '新密码不能与原密码相同';
+ return null;
+}
+
+function openPwdOverlay(forced) {
+ pwdForced = !!forced;
+ document.getElementById('pwdOld').value = '';
+ document.getElementById('pwdNew').value = '';
+ document.getElementById('pwdNew2').value = '';
+ const show = document.getElementById('pwdShow');
+ if (show) show.checked = false;
+ ['pwdOld', 'pwdNew', 'pwdNew2'].forEach(function (id) {
+ const el = document.getElementById(id);
+ if (el) el.type = 'password';
+ });
+ document.getElementById('pwdErr').textContent = '';
+ document.getElementById('pwdTitle').textContent = forced ? '请设置新密码' : '修改密码';
+ document.getElementById('btnClosePwd').hidden = pwdForced;
+ document.getElementById('pwdHintWrap').hidden = !pwdForced;
+ openOverlay('pwdOverlay');
+ /* 强制改密时也先聚焦原密码,引导用户从填写原密码开始 */
+ setTimeout(function () { document.getElementById('pwdOld').focus(); }, 30);
+}
+
+/* ============================================================
+ 登录态 UI
+ ============================================================ */
+function onUserChanged() {
+ if (currentUser) {
+ btnUserEl.textContent = currentUser.username + (currentUser.role === 'admin' ? ' · 管理员' : '');
+ document.getElementById('ddName').textContent =
+ currentUser.username + ' · ' + (currentUser.role === 'admin' ? '管理员' : '普通用户');
+ } else {
+ btnUserEl.textContent = '登录';
+ }
+ document.getElementById('userMenu').hidden = true;
+ updateAdminEntry();
+ updateBanner();
+ updateEditability();
+ updateSaveState();
+ updateUndoBtn();
+}
+
+function updateAdminEntry() {
+ const menu = document.getElementById('userMenu');
+ let item = document.getElementById('ddAdmin');
+ const isAdmin = currentUser && currentUser.role === 'admin';
+ if (isAdmin) {
+ if (!item) {
+ item = document.createElement('div');
+ item.className = 'dd-item';
+ item.id = 'ddAdmin';
+ item.textContent = '管理员控制台';
+ item.addEventListener('click', function () {
+ document.getElementById('userMenu').hidden = true;
+ window.location.href = 'web/admin.html';
+ });
+ menu.insertBefore(item, document.getElementById('ddPwd'));
+ }
+ item.hidden = false;
+ } else if (item) {
+ item.hidden = true;
+ }
+}
+
+function updateBanner() {
+ if (!currentUser) {
+ bannerEl.hidden = false;
+ bannerEl.className = 'banner warn';
+ bannerEl.innerHTML = '未登录模式:当前为默认演示页,编辑内容不会保存,也无法访问已存数据。' +
+ '立即登录 / 注册';
+ const l = document.getElementById('bannerLogin');
+ if (l) l.addEventListener('click', openLogin);
+ return;
+ }
+ if (readOnly) {
+ bannerEl.hidden = false;
+ bannerEl.className = 'banner info';
+ bannerEl.innerHTML = '只读:这是「' + esc(topoName) + '」的公开拓扑(非本人创建),仅可查看,无法编辑。';
+ return;
+ }
+ bannerEl.hidden = true;
+ bannerEl.innerHTML = '';
+}
+
+/* ============================================================
+ 打开 / 新建拓扑
+ ============================================================ */
+function openTopology(id) {
+ return API.getTopo(id).then(function (r) {
+ loading = true;
+ topoId = r.topology.id;
+ topoName = r.topology.name;
+ topoCanEdit = !!r.topology.canEdit;
+ readOnly = !topoCanEdit;
+ history = [];
+ dirty = false;
+ loadData(r.data, false);
+ loading = false;
+ try { localStorage.setItem(LAST_TOPO_KEY, topoId); } catch (e) {}
+ updateBanner(); updateEditability(); updateSaveState(); updateUndoBtn();
+ closeOverlay('topoOverlay');
+ requestAnimationFrame(fitView);
+ toast('已打开:' + topoName + (readOnly ? '(只读)' : ''), 'ok');
+ }).catch(function (e) { toastErr(e.message); });
+}
+
+function loadDraft() {
+ loading = true;
+ topoId = null;
+ topoName = '';
+ topoCanEdit = true;
+ readOnly = false;
+ state.nodes = [];
+ state.edges = [];
+ state.view = { tx: 0, ty: 0, s: 1 };
+ selectedId = null;
+ history = [];
+ dirty = false;
+ loadData(demoData(), false);
+ loading = false;
+ updateBanner(); updateEditability(); updateSaveState(); updateUndoBtn();
+ requestAnimationFrame(fitView);
+}
+
+function startNewTopology() {
+ loading = true;
+ topoId = null;
+ topoName = '';
+ topoCanEdit = true;
+ readOnly = false;
+ state.nodes = [];
+ state.edges = [];
+ state.view = { tx: 0, ty: 0, s: 1 };
+ selectedId = null;
+ history = [];
+ dirty = false;
+ loading = false;
+ render();
+ syncInspector();
+ updateBanner(); updateEditability(); updateSaveState(); updateUndoBtn();
+}
+
+/* ============================================================
+ 我的拓扑面板
+ ============================================================ */
+function openTopoPanel() {
+ if (!currentUser) { openLogin(); return; }
+ openOverlay('topoOverlay');
+ refreshTopoList();
+}
+
+function itemRow(name, sub, badges, actions) {
+ const item = document.createElement('div');
+ item.className = 'topo-item';
+
+ const meta = document.createElement('div');
+ meta.className = 'topo-meta';
+ const nm = document.createElement('div');
+ nm.className = 'topo-name';
+ nm.textContent = name;
+ const sb = document.createElement('div');
+ sb.className = 'topo-sub';
+ sb.textContent = sub;
+ meta.appendChild(nm);
+ meta.appendChild(sb);
+ item.appendChild(meta);
+
+ (badges || []).forEach(function (b) {
+ const sp = document.createElement('span');
+ sp.className = 'badge ' + b.cls;
+ sp.textContent = b.text;
+ item.appendChild(sp);
+ });
+
+ const row = document.createElement('div');
+ row.className = 'row-mini';
+ (actions || []).forEach(function (a) {
+ const btn = document.createElement('button');
+ btn.className = 'btn' + (a.primary ? ' primary' : '') + (a.danger ? ' danger' : '');
+ btn.textContent = a.text;
+ btn.addEventListener('click', a.fn);
+ row.appendChild(btn);
+ });
+ item.appendChild(row);
+ return item;
+}
+
+function refreshTopoList() {
+ const box = document.getElementById('topoList');
+ box.innerHTML = '加载中…
';
+ API.listTopos().then(function (r) {
+ const all = r.topologies || [];
+ const uid = currentUser ? currentUser.id : '';
+ const list = (topoScope === 'public')
+ ? all.filter(function (t) { return t.visibility === 'public'; })
+ : all.filter(function (t) { return t.ownerId === uid; });
+
+ box.innerHTML = '';
+ if (!list.length) {
+ box.innerHTML = '' +
+ (topoScope === 'public' ? '暂无公开拓扑。' : '还没有拓扑,输入名称点击「新建」开始。') +
+ '
';
+ return;
+ }
+ list.forEach(function (t) {
+ const badges = [{
+ cls: t.visibility === 'public' ? 'public' : 'private',
+ text: t.visibility === 'public' ? '公开' : '私有'
+ }];
+ if (!t.canEdit) { badges.push({ cls: 'ro', text: '只读' }); }
+ const actions = [{ text: '打开', primary: true, fn: function () { openTopology(t.id); } }];
+ if (t.canEdit) {
+ actions.push({ text: '重命名', fn: function () { renameTopo(t.id, t.name); } });
+ actions.push({ text: t.visibility === 'public' ? '设为私有' : '设为公开', fn: function () { toggleVisibility(t.id, t.visibility); } });
+ actions.push({ text: '删除', danger: true, fn: function () { deleteTopo(t.id, t.name); } });
+ }
+ box.appendChild(itemRow(
+ t.name,
+ '所有者 ' + t.ownerName + ' · 节点 ' + t.nodeCount + ' · 连线 ' + t.edgeCount + ' · 更新 ' + fmtTime(t.updatedAt),
+ badges, actions
+ ));
+ });
+ }).catch(function (e) { box.innerHTML = '加载失败:' + esc(e.message) + '
'; });
+}
+
+function toggleVisibility(id, cur, cb) {
+ const vis = (cur === 'public') ? 'private' : 'public';
+ API.setVisibility(id, vis).then(function () {
+ toast('已设为' + (vis === 'public' ? '公开' : '私有'), 'ok');
+ if (cb) cb(); else refreshTopoList();
+ }).catch(function (e) { toastErr(e.message); });
+}
+
+function deleteTopo(id, name, cb) {
+ uiConfirm('确定删除拓扑「' + name + '」?此操作不可恢复。', { title: '删除拓扑', okText: '删除', danger: true })
+ .then(function (ok) {
+ if (!ok) return;
+ return API.deleteTopo(id).then(function () {
+ toast('已删除', 'ok');
+ if (topoId === id) startNewTopology();
+ if (cb) cb(); else refreshTopoList();
+ }).catch(function (e) { toastErr(e.message); });
+ });
+}
+
+function renameTopo(id, curName, cb) {
+ uiPrompt('新名称', {
+ title: '重命名拓扑',
+ message: '为拓扑「' + curName + '」设置新名称(最多 60 个字符)。',
+ placeholder: '例如:某内网横向拓扑',
+ value: curName,
+ okText: '保存'
+ }).then(function (v) {
+ if (v === null) return;
+ v = String(v).trim();
+ if (!v) { toastErr('名称不能为空'); return; }
+ if (v === curName) return;
+ API.renameTopo(id, v).then(function () {
+ toast('已重命名', 'ok');
+ if (topoId === id) { topoName = v; updateBanner(); }
+ if (cb) cb(); else refreshTopoList();
+ }).catch(function (e) { toastErr(e.message); });
+ });
+}
+
+function createNewTopo() {
+ const inp = document.getElementById('newTopoName');
+ const name = inp.value.trim();
+ if (!name) { toastErr('请输入拓扑名称'); return; }
+ const tplSel = document.getElementById('newTopoTemplate');
+ const tpl = (tplSel && TEMPLATES[tplSel.value]) ? TEMPLATES[tplSel.value] : TEMPLATES.blank;
+ const data = tpl.data() || { nodes: [], edges: [] };
+ API.createTopo({ name: name, nodes: data.nodes || [], edges: data.edges || [] }).then(function (r) {
+ inp.value = '';
+ toast('已创建:' + name, 'ok');
+ openTopology(r.topology.id);
+ }).catch(function (e) { toastErr(e.message); });
+}
+
+/* ============================================================
+ 管理员控制台
+ 已迁移至独立页面:web/admin.html(逻辑见 web/admin.js)
+ ============================================================ */
+
+
+
+
+
+
+
+
+
+
+
+/* ============================================================
+ 数据导入 / 导出
+ ============================================================ */
+function loadData(data, pushHist) {
+ if (!data || !Array.isArray(data.nodes)) throw new Error('数据格式不正确:缺少 nodes 数组');
+ if (pushHist !== false) pushHistory(snapshot());
+
+ state.nodes = data.nodes.map(function (n) {
+ return {
+ id: String(n.id || uid()),
+ label: String(n.label == null ? '' : n.label),
+ type: TYPES.indexOf(n.type) >= 0 ? n.type : 'host',
+ x: Number(n.x) || 0,
+ y: Number(n.y) || 0,
+ parentId: n.parentId ? String(n.parentId) : null,
+ status: STATUSES[n.status] ? n.status : 'unknown',
+ ports: String(n.ports == null ? '' : n.ports),
+ note: String(n.note == null ? '' : n.note)
+ };
+ });
+
+ const ids = new Set(state.nodes.map(function (n) { return n.id; }));
+ state.nodes.forEach(function (n) { if (n.parentId && !ids.has(n.parentId)) n.parentId = null; });
+
+ state.edges = (Array.isArray(data.edges) ? data.edges : [])
+ .filter(function (e) { return e && ids.has(String(e.from)) && ids.has(String(e.to)); })
+ .map(function (e) {
+ return { id: String(e.id || uid()), from: String(e.from), to: String(e.to), label: String(e.label == null ? '' : e.label) };
+ });
+
+ selectedId = null;
+ render();
+ syncInspector();
+}
+
+function serializeData() {
+ return {
+ version: 1,
+ name: topoName || '',
+ exportedAt: new Date().toISOString(),
+ nodes: state.nodes,
+ edges: state.edges
+ };
+}
+
+function download(blobOrUrl, filename) {
+ const a = document.createElement('a');
+ if (typeof blobOrUrl === 'string') { a.href = blobOrUrl; }
+ else { a.href = URL.createObjectURL(blobOrUrl); }
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ if (typeof blobOrUrl !== 'string') {
+ setTimeout(function () { URL.revokeObjectURL(a.href); }, 8000);
+ }
+}
+
+function buildExportSvg() {
+ const b = contentBounds();
+ if (!b) return null;
+ const pad = 60;
+ const minX = b.minX - pad, minY = b.minY - pad;
+ const w = b.w + pad * 2, h = b.h + pad * 2;
+
+ const clone = svg.cloneNode(true);
+ clone.setAttribute('xmlns', NS);
+ clone.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
+ clone.setAttribute('width', w.toFixed(0));
+ clone.setAttribute('height', h.toFixed(0));
+ clone.setAttribute('viewBox', minX.toFixed(2) + ' ' + minY.toFixed(2) + ' ' + w.toFixed(2) + ' ' + h.toFixed(2));
+
+ const vp = clone.querySelector('#viewport');
+ vp.setAttribute('transform', 'translate(0,0) scale(1)');
+
+ clone.querySelectorAll('.link-handle, .add-handle, .edge-del, .edge-hit')
+ .forEach(function (node) { node.remove(); });
+
+ const bg = document.createElementNS(NS, 'rect');
+ bg.setAttribute('x', minX);
+ bg.setAttribute('y', minY);
+ bg.setAttribute('width', w);
+ bg.setAttribute('height', h);
+ bg.setAttribute('fill', '#ffffff');
+ vp.parentNode.insertBefore(bg, vp);
+
+ return { node: clone, w: w, h: h };
+}
+
+function exportJson() {
+ if (!state.nodes.length) { toastErr('当前没有内容可导出'); return; }
+ const str = JSON.stringify(serializeData(), null, 2);
+ download(new Blob([str], { type: 'application/json;charset=utf-8' }), 'route-topology-' + ts() + '.json');
+}
+
+function exportSvg() {
+ const res = buildExportSvg();
+ if (!res) { toastErr('当前没有内容可导出'); return; }
+ const str = '\n' + new XMLSerializer().serializeToString(res.node);
+ download(new Blob([str], { type: 'image/svg+xml;charset=utf-8' }), 'route-topology-' + ts() + '.svg');
+}
+
+function exportPng() {
+ const res = buildExportSvg();
+ if (!res) { toastErr('当前没有内容可导出'); return; }
+ const maxDim = 8000;
+ let scale = 2;
+ if (res.w * scale > maxDim || res.h * scale > maxDim) {
+ scale = Math.max(1, Math.min(maxDim / res.w, maxDim / res.h));
+ }
+ const str = new XMLSerializer().serializeToString(res.node);
+ const url = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(str);
+ const img = new Image();
+ img.onload = function () {
+ const canvas = document.createElement('canvas');
+ canvas.width = Math.max(1, Math.round(res.w * scale));
+ canvas.height = Math.max(1, Math.round(res.h * scale));
+ const ctx = canvas.getContext('2d');
+ ctx.fillStyle = '#ffffff';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
+ if (canvas.toBlob) {
+ canvas.toBlob(function (blob) {
+ if (blob) download(blob, 'route-topology-' + ts() + '.png');
+ else download(canvas.toDataURL('image/png'), 'route-topology-' + ts() + '.png');
+ }, 'image/png');
+ } else {
+ download(canvas.toDataURL('image/png'), 'route-topology-' + ts() + '.png');
+ }
+ };
+ img.onerror = function () { toastErr('导出图片失败,请尝试导出 SVG。'); };
+ img.src = url;
+}
+
+function importJsonFile(file) {
+ const reader = new FileReader();
+ reader.onload = function () {
+ try {
+ const data = JSON.parse(reader.result);
+ loadData(data, true);
+ if (data && typeof data.name === 'string' && data.name) { topoName = data.name; }
+ onDataChanged();
+ requestAnimationFrame(fitView);
+ toast('导入成功', 'ok');
+ } catch (err) {
+ toastErr('导入失败:' + err.message);
+ }
+ };
+ reader.onerror = function () { toastErr('读取文件失败'); };
+ reader.readAsText(file);
+}
+
+/* ============================================================
+ 导出 / 导入 格式选择
+ ============================================================ */
+const EXPORT_FORMATS = [
+ { badge: 'PNG', name: 'PNG 图片', desc: '位图输出,适合插入文档或直接分享', run: function () { exportPng(); } },
+ { badge: 'SVG', name: 'SVG 矢量图', desc: '矢量格式,可无损缩放与二次编辑', run: function () { exportSvg(); } },
+ { badge: 'JSON', name: 'JSON 数据', desc: '保存全部节点与连线,可再次导入', run: function () { exportJson(); } }
+];
+
+const IMPORT_FORMATS = [
+ { badge: 'JSON', name: 'JSON 数据', desc: '从导出的 JSON 文件恢复拓扑(将覆盖当前画布)', run: function () { fileInput.click(); } }
+];
+
+function openFormatDialog(mode) {
+ const isImport = (mode === 'import');
+ const formats = isImport ? IMPORT_FORMATS : EXPORT_FORMATS;
+
+ document.getElementById('formatTitle').textContent = isImport ? '导入拓扑' : '导出拓扑';
+ document.getElementById('formatMsg').textContent = isImport
+ ? '选择导入的文件格式。导入的节点与连线将替换当前画布内容(可撤销)。'
+ : '选择导出格式。';
+
+ const box = document.getElementById('formatList');
+ box.textContent = '';
+ formats.forEach(function (f) {
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'format-item';
+ btn.innerHTML =
+ '' + esc(f.badge) + '' +
+ '' +
+ '' + esc(f.name) + '' +
+ '' + esc(f.desc) + '' +
+ '';
+ btn.addEventListener('click', function () {
+ closeOverlay('formatOverlay');
+ f.run();
+ });
+ box.appendChild(btn);
+ });
+
+ openOverlay('formatOverlay');
+}
+
+/* ============================================================
+ 示例数据
+ ============================================================ */
+function demoData() {
+ const n1 = 'n1', n2 = 'n2', n3 = 'n3', n4 = 'n4', n5 = 'n5';
+ 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 服务器,已 getshell', 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: '' }
+ ]
+ };
+}
+
+/* ============================================================
+ 拓扑模板(新建拓扑时可选用)
+ ============================================================ */
+function tplDemoData() { return demoData(); }
+
+function tplAdData() {
+ return {
+ nodes: [
+ { id:'ta1', label:'10.0.0.0/24', type:'net', x:90, y:120, status:'confirmed', ports:'', note:'DMZ 边界网段' },
+ { id:'ta2', label:'10.0.0.21', type:'host', x:90, y:236, status:'owned', ports:'80, 3389', note:'边界 Web 机,已 getshell', parentId:'ta1' },
+ { id:'ta3', label:'172.16.1.0/24', type:'net', x:400, y:120, status:'confirmed', ports:'', note:'域内网段' },
+ { id:'ta4', label:'dc.corp.local', type:'domain', x:400, y:248, status:'pivot', ports:'88, 389, 445', note:'域控,可 DCSync' },
+ { id:'ta5', label:'172.16.1.50', type:'host', x:720, y:120, status:'unknown', ports:'445', note:'文件服务器' },
+ { id:'ta6', label:'172.16.1.60', type:'host', x:720, y:248, status:'unknown', ports:'1433', note:'数据库服务器' }
+ ],
+ edges: [
+ { id:'te1', from:'ta1', to:'ta3', label:'' },
+ { id:'te2', from:'ta2', to:'ta3', label:'' },
+ { id:'te3', from:'ta3', to:'ta4', label:'' },
+ { id:'te4', from:'ta4', to:'ta5', label:'' },
+ { id:'te5', from:'ta4', to:'ta6', label:'' }
+ ]
+ };
+}
+
+function tplWebData() {
+ return {
+ nodes: [
+ { id:'tw1', label:'www.example.com', type:'domain', x:120, y:110, status:'confirmed', ports:'', note:'对外主站' },
+ { id:'tw2', label:'10.10.10.5', type:'host', x:120, y:230, status:'owned', ports:'80, 443', note:'Web 服务器,存在上传点', parentId:'tw1' },
+ { id:'tw3', label:'10.10.10.0/24', type:'net', x:430, y:110, status:'confirmed', ports:'', note:'应用内网段' },
+ { id:'tw4', label:'10.10.10.8', type:'host', x:430, y:230, status:'pivot', ports:'3306', note:'数据库,可作跳板' },
+ { id:'tw5', label:'10.10.20.0/24', type:'net', x:740, y:170, status:'unknown', ports:'', note:'办公网段' }
+ ],
+ edges: [
+ { id:'twe1', from:'tw1', to:'tw2', label:'' },
+ { id:'twe2', from:'tw2', to:'tw3', label:'' },
+ { id:'twe3', from:'tw3', to:'tw4', label:'' },
+ { id:'twe4', from:'tw4', to:'tw5', label:'' }
+ ]
+ };
+}
+
+const TEMPLATES = {
+ blank: { label: '空白画布', data: function () { return { nodes: [], edges: [] }; } },
+ demo: { label: '示例:小型内网', data: tplDemoData },
+ ad: { label: '模板:AD 域渗透', data: tplAdData },
+ web: { label: '模板:Web 边界打点', data: tplWebData }
+};
+
+(function initTemplates() {
+ const sel = document.getElementById('newTopoTemplate');
+ if (!sel) return;
+ sel.innerHTML = Object.keys(TEMPLATES)
+ .map(k => ``).join('');
+})();
+
+/* ============================================================
+ 事件绑定
+ ============================================================ */
+document.getElementById('btnSave').addEventListener('click', function () {
+ if (!currentUser) { openLogin(); return; }
+ saveNow(false);
+});
+btnMyToposEl.addEventListener('click', openTopoPanel);
+
+document.getElementById('topoScope').addEventListener('click', function (e) {
+ const b = e.target.closest && e.target.closest('.seg-btn');
+ if (!b) return;
+ const scope = b.getAttribute('data-scope');
+ if (scope === topoScope) return;
+ topoScope = scope;
+ Array.prototype.forEach.call(this.querySelectorAll('.seg-btn'), function (x) {
+ x.classList.toggle('active', x === b);
+ });
+ refreshTopoList();
+});
+
+document.getElementById('btnDoLogin').addEventListener('click', doLoginOrRegister);
+document.getElementById('btnRegister').addEventListener('click', function () {
+ loginMode = (loginMode === 'login') ? 'register' : 'login';
+ applyLoginMode();
+ document.getElementById('loginErr').textContent = '';
+});
+document.getElementById('btnCloseLogin').addEventListener('click', function () { closeOverlay('loginOverlay'); });
+document.getElementById('loginPass').addEventListener('keydown', function (e) {
+ if (e.key === 'Enter') doLoginOrRegister();
+});
+document.getElementById('loginPass2').addEventListener('keydown', function (e) {
+ if (e.key === 'Enter') doLoginOrRegister();
+});
+document.getElementById('loginShow').addEventListener('change', function () {
+ const t = this.checked ? 'text' : 'password';
+ ['loginPass', 'loginPass2'].forEach(function (id) {
+ const el = document.getElementById(id);
+ if (el) el.type = t;
+ });
+});
+
+document.getElementById('btnCloseTopo').addEventListener('click', function () { closeOverlay('topoOverlay'); });
+document.getElementById('btnCreateTopo').addEventListener('click', createNewTopo);
+document.getElementById('newTopoName').addEventListener('keydown', function (e) {
+ if (e.key === 'Enter') createNewTopo();
+});
+
+document.getElementById('btnClosePwd').addEventListener('click', function () { if (pwdForced) return; closeOverlay('pwdOverlay'); });
+document.getElementById('btnDoPwd').addEventListener('click', function () {
+ const o = document.getElementById('pwdOld').value;
+ const n = document.getElementById('pwdNew').value;
+ const n2 = document.getElementById('pwdNew2').value;
+ const err = document.getElementById('pwdErr');
+ err.textContent = '';
+ if (!o) { err.textContent = '请输入原密码'; return; }
+ const bad = pwdStrengthError(n, o, currentUser ? currentUser.username : '');
+ if (bad) { err.textContent = bad; return; }
+ if (n !== n2) { err.textContent = '两次输入的新密码不一致'; return; }
+ API.changePassword(o, n).then(function (r) {
+ if (r && r.user) currentUser = r.user;
+ pwdForced = false;
+ toast('密码已修改', 'ok');
+ closeOverlay('pwdOverlay');
+ document.getElementById('pwdOld').value = '';
+ document.getElementById('pwdNew').value = '';
+ document.getElementById('pwdNew2').value = '';
+ onUserChanged();
+ }).catch(function (e) { err.textContent = e.message; });
+});
+
+/* 显示 / 隐藏密码:勾选后将三个密码框切换为明文 */
+document.getElementById('pwdShow').addEventListener('change', function () {
+ const t = this.checked ? 'text' : 'password';
+ ['pwdOld', 'pwdNew', 'pwdNew2'].forEach(function (id) {
+ const el = document.getElementById(id);
+ if (el) el.type = t;
+ });
+});
+['pwdNew', 'pwdNew2'].forEach(function (id) {
+ document.getElementById(id).addEventListener('keydown', function (e) {
+ if (e.key === 'Enter') { e.preventDefault(); document.getElementById('btnDoPwd').click(); }
+ });
+});
+
+/* 用户下拉菜单为 fixed 定位,按按钮实际位置动态摆放,避免被顶部栏 overflow 裁剪 */
+function positionUserMenu() {
+ const m = document.getElementById('userMenu');
+ if (!m || m.hidden) { return; }
+ const r = btnUserEl.getBoundingClientRect();
+ m.style.top = Math.round(r.bottom + 6) + 'px';
+ m.style.right = Math.round(Math.max(8, window.innerWidth - r.right)) + 'px';
+}
+btnUserEl.addEventListener('click', function (e) {
+ e.stopPropagation();
+ if (!currentUser) { openLogin(); return; }
+ const m = document.getElementById('userMenu');
+ if (m.hidden) {
+ m.hidden = false;
+ positionUserMenu();
+ } else {
+ m.hidden = true;
+ }
+});
+document.getElementById('userMenu').addEventListener('click', function (e) { e.stopPropagation(); });
+document.addEventListener('click', function () { document.getElementById('userMenu').hidden = true; });
+window.addEventListener('resize', positionUserMenu);
+const topbarEl = document.querySelector('.topbar');
+if (topbarEl) { topbarEl.addEventListener('scroll', positionUserMenu); }
+
+document.getElementById('ddPwd').addEventListener('click', function () {
+ document.getElementById('userMenu').hidden = true;
+ openPwdOverlay(false);
+});
+document.getElementById('ddLogout').addEventListener('click', function () {
+ document.getElementById('userMenu').hidden = true;
+ API.logout().then(function () {
+ currentUser = null;
+ topoId = null;
+ topoName = '';
+ topoCanEdit = false;
+ readOnly = false;
+ try { localStorage.removeItem(LAST_TOPO_KEY); } catch (e) {}
+ onUserChanged();
+ loadDraft();
+ toast('已退出登录');
+ }).catch(function (e) { toastErr(e.message); });
+});
+
+document.getElementById('btnExport').addEventListener('click', function () { openFormatDialog('export'); });
+document.getElementById('btnImport').addEventListener('click', function () { openFormatDialog('import'); });
+document.getElementById('btnCloseFormat').addEventListener('click', function () { closeOverlay('formatOverlay'); });
+fileInput.addEventListener('change', function (e) {
+ const f = e.target.files && e.target.files[0];
+ if (f) importJsonFile(f);
+ fileInput.value = '';
+});
+
+document.querySelectorAll('.overlay').forEach(function (o) {
+ o.addEventListener('click', function (e) {
+ if (e.target !== o) return;
+ if (o.id === 'loginOverlay') return; /* 登录/注册框禁止点击遮罩关闭,避免误触 */
+ if (o.id === 'pwdOverlay' && pwdForced) return;
+ if (o.id === 'confirmOverlay') { _dlgFinish(null); return; }
+ o.hidden = true;
+ });
+});
+
+/* ---------- 站内确认 / 输入弹窗 ---------- */
+document.getElementById('confirmOk').addEventListener('click', _dlgConfirmOk);
+document.getElementById('confirmCancel').addEventListener('click', function () { _dlgFinish(null); });
+document.getElementById('confirmClose').addEventListener('click', function () { _dlgFinish(null); });
+document.getElementById('confirmField').addEventListener('keydown', function (e) {
+ if (e.key === 'Enter') { e.preventDefault(); _dlgConfirmOk(); }
+});
+document.addEventListener('keydown', function (e) {
+ if (e.key === 'Escape' && !document.getElementById('confirmOverlay').hidden) { _dlgFinish(null); }
+});
+
+
+/* ============================================================
+ 启动
+ ============================================================ */
+function init() {
+ updateEditability();
+ API.site().then(function (r) { applySite(r.site); }).catch(function () { /* 忽略:回退页面默认值 */ });
+ API.me().then(function (r) {
+ if (r && r.user) {
+ currentUser = r.user;
+ onUserChanged();
+ bootAfterLogin();
+ maybeForcePwdChange();
+ } else {
+ onUserChanged();
+ loadDraft();
+ }
+ }).catch(function () {
+ onUserChanged();
+ loadDraft();
+ });
+}
+
+init();
+})();
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..c82af39
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,181 @@
+
+
+
+
+
+路由拓扑
+
+
+
+
+
+
+
+
+
+
+
+
+
拖动空白平移 · 滚轮缩放 · 拖节点右侧蓝点连线 · 拖底部绿点加子节点 · 双击空白新建
+
+
+
+
+
+
+
+
+
+
+
登录
×
+
+
+
+
+
+
+
+
+
+
默认管理员 admin / admin123
+
+
+
+
+
+
+
我的拓扑
×
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
修改密码
×
+
首次登录必须修改密码后才能继续使用
+
+
+
+
+
+
+
+
+
+
+
+
+
请确认
×
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/style.css b/web/style.css
new file mode 100644
index 0000000..0229a84
--- /dev/null
+++ b/web/style.css
@@ -0,0 +1,451 @@
+/* =========================================================================
+ 路由拓扑 · 多用户版 — 科技简洁风
+ 设计原则:科技蓝主色 / 大留白 / 扁平极简 / 单一语义色 / 淡色底 + 同色文字
+ ========================================================================= */
+
+/* -------------------------------- 设计变量 -------------------------------- */
+:root{
+ /* 主色 */
+ --blue-700:#1d4ed8; --blue-600:#2563eb; --blue-500:#3b82f6;
+ --blue-100:#dbeafe; --blue-50:#eff6ff;
+ /* 灰阶文字(统一语义色) */
+ --ink-900:#0f172a; --ink-800:#1e293b; --ink-700:#334155; --ink-600:#475569;
+ --ink-500:#64748b; --ink-400:#94a3b8; --ink-300:#cbd5e1; --ink-200:#e2e8f0; --ink-100:#f1f5f9;
+ /* 背景 / 面 / 描边 */
+ --bg:#f6f8fc; --surface:#ffffff; --border:#e6ebf3;
+ /* 语义状态色:淡色底 + 同色文字 */
+ --ok-fg:#047857; --ok-bg:#ecfdf5; --ok-bd:#a7f3d0;
+ --danger-fg:#b91c1c;--danger-bg:#fef2f2;--danger-bd:#fecaca;
+ --warn-fg:#b45309; --warn-bg:#fffbeb; --warn-bd:#fde68a;
+ --info-fg:#1d4ed8; --info-bg:#eff6ff; --info-bd:#bfdbfe;
+ /* 圆角 / 阴影 / 过渡 */
+ --r-lg:16px; --r-md:12px; --r-sm:9px;
+ --sh-1:0 1px 2px rgba(15,23,42,.04),0 1px 3px rgba(15,23,42,.06);
+ --sh-2:0 8px 24px rgba(15,23,42,.10);
+ --sh-3:0 24px 60px rgba(15,23,42,.18);
+ --t-fast:.16s cubic-bezier(.4,0,.2,1);
+ /* 后台深蓝侧栏 */
+ --nav-grad:linear-gradient(180deg,#1e3a8a 0%,#1d4ed8 100%);
+}
+
+/* -------------------------------- 基础 -------------------------------- */
+*{box-sizing:border-box;}
+html,body{height:100%;}
+body{
+ margin:0;background:var(--bg);color:var(--ink-800);overflow:hidden;
+ font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei","PingFang SC",system-ui,sans-serif;
+ font-size:13.5px;line-height:1.6;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;
+}
+[hidden]{display:none !important;}
+button{font-family:inherit;}
+.app{display:flex;flex-direction:column;height:100vh;}
+
+/* 细腻滚动条 */
+::-webkit-scrollbar{width:9px;height:9px;}
+::-webkit-scrollbar-thumb{background:var(--ink-200);border-radius:9px;border:2px solid transparent;background-clip:padding-box;}
+::-webkit-scrollbar-thumb:hover{background:var(--ink-300);background-clip:padding-box;}
+::-webkit-scrollbar-track{background:transparent;}
+
+/* -------------------------------- 按钮 -------------------------------- */
+/* 默认:圆角描边;悬停边框与文字变蓝。仅主操作实心蓝、危险操作用红。 */
+.btn{
+ display:inline-flex;align-items:center;justify-content:center;gap:6px;
+ padding:8px 14px;border-radius:var(--r-md);
+ border:1px solid var(--ink-300);background:var(--surface);color:var(--ink-700);
+ font-size:13px;font-weight:500;line-height:1.4;cursor:pointer;white-space:nowrap;
+ transition:border-color var(--t-fast),color var(--t-fast),background var(--t-fast),box-shadow var(--t-fast),transform var(--t-fast);
+ user-select:none;flex:0 0 auto;
+}
+.btn:hover{border-color:var(--blue-500);color:var(--blue-600);background:var(--blue-50);}
+.btn:active{transform:translateY(1px);}
+.btn:focus-visible{outline:none;box-shadow:0 0 0 3px var(--blue-100);}
+.btn.primary{background:var(--blue-600);border-color:var(--blue-600);color:#fff;font-weight:600;}
+.btn.primary:hover{background:var(--blue-700);border-color:var(--blue-700);color:#fff;}
+.btn.danger{border-color:var(--danger-bd);color:var(--danger-fg);background:var(--surface);}
+.btn.danger:hover{border-color:#fca5a5;color:var(--danger-fg);background:var(--danger-bg);}
+.btn:disabled,.btn:disabled:hover{
+ opacity:.55;cursor:not-allowed;transform:none;box-shadow:none;
+ border-color:var(--ink-200);color:var(--ink-400);background:var(--surface);
+}
+/* 小尺寸档 */
+.btn.sm{padding:5px 10px;font-size:12px;border-radius:var(--r-sm);}
+
+/* -------------------------------- 徽标 / 状态 -------------------------------- */
+.badge{
+ display:inline-flex;align-items:center;font-size:11.5px;font-weight:500;
+ padding:2px 9px;border-radius:999px;white-space:nowrap;
+ border:1px solid var(--ink-200);background:var(--ink-100);color:var(--ink-600);
+}
+.badge.public{background:var(--ok-bg);border-color:var(--ok-bd);color:var(--ok-fg);}
+.badge.private{background:var(--ink-100);border-color:var(--ink-200);color:var(--ink-600);}
+.badge.owner{background:var(--info-bg);border-color:var(--info-bd);color:var(--info-fg);}
+.badge.ro{background:var(--warn-bg);border-color:var(--warn-bd);color:var(--warn-fg);}
+
+/* -------------------------------- 顶部标题条 -------------------------------- */
+.topbar{
+ display:flex;align-items:center;gap:8px;padding:10px 18px;flex:0 0 auto;
+ background:rgba(255,255,255,.9);backdrop-filter:blur(10px);
+ border-bottom:1px solid var(--border);overflow-x:auto;white-space:nowrap;
+ position:relative;z-index:20;
+}
+.topbar::-webkit-scrollbar{height:0;}
+.brand{display:flex;align-items:center;gap:10px;margin-right:6px;}
+.brand .logo{
+ width:30px;height:30px;border-radius:9px;display:grid;place-items:center;flex:0 0 auto;
+ background:var(--blue-600);color:#fff;font-size:15px;font-weight:700;
+ box-shadow:0 4px 12px rgba(37,99,235,.28);
+}
+.brand h1{font-size:15px;margin:0;font-weight:600;color:var(--ink-900);letter-spacing:.2px;}
+/* 品牌 Logo:图片模式(设置自定义 Logo 后显示图片,否则回退字母 R) */
+.brand .logo,.console-brand .logo{overflow:hidden;}
+.brand .logo.has-img,.console-brand .logo.has-img{background:transparent;box-shadow:none;}
+.brand .logo img,.console-brand .logo img{width:100%;height:100%;object-fit:contain;display:block;}
+.sep{width:1px;height:20px;background:var(--border);margin:0 4px;flex:0 0 auto;}
+
+.topbar-right{margin-left:auto;display:flex;align-items:center;gap:8px;flex:0 0 auto;}
+.save-state{font-size:12px;color:var(--ink-400);min-width:64px;text-align:right;white-space:nowrap;}
+.save-state.ok{color:var(--ok-fg);}
+.save-state.err{color:var(--danger-fg);}
+.save-state.dirty{color:var(--warn-fg);}
+
+.user-wrap{position:relative;flex:0 0 auto;}
+/* 顶部栏设置了 overflow-x:auto,会连同 overflow-y 一起被计算为 auto,
+ 绝对定位的下拉菜单会被其裁剪(挤在顶部栏内),因此改用 fixed 定位,
+ 由 JS 依据按钮位置动态摆放(right/top 为兜底值) */
+.dropdown{
+ position:fixed;right:18px;top:56px;background:var(--surface);border:1px solid var(--border);
+ border-radius:var(--r-md);min-width:184px;padding:6px;z-index:60;box-shadow:var(--sh-2);
+ animation:pop var(--t-fast) both;
+}
+.dd-name{padding:8px 10px;font-size:12px;color:var(--ink-500);border-bottom:1px solid var(--border);margin-bottom:4px;word-break:break-all;}
+.dd-item{padding:8px 10px;font-size:13px;color:var(--ink-700);border-radius:var(--r-sm);cursor:pointer;transition:background var(--t-fast),color var(--t-fast);}
+.dd-item:hover{background:var(--blue-50);color:var(--blue-600);}
+
+/* -------------------------------- 横幅提示 -------------------------------- */
+.banner{flex:0 0 auto;padding:9px 18px;font-size:12.5px;display:flex;align-items:center;gap:6px;border-bottom:1px solid transparent;}
+.banner.warn{background:var(--warn-bg);color:var(--warn-fg);border-bottom-color:var(--warn-bd);}
+.banner.info{background:var(--info-bg);color:var(--info-fg);border-bottom-color:var(--info-bd);}
+.banner .lnk{color:inherit;font-weight:600;text-decoration:underline;cursor:pointer;margin-left:6px;}
+
+/* -------------------------------- 主体 / 画布 -------------------------------- */
+.main{flex:1;display:flex;min-height:0;}
+.canvas-wrap{
+ flex:1;position:relative;min-width:0;background-color:#fbfcfe;
+ background-image:radial-gradient(circle,#e3eaf5 1px,transparent 1px);
+ background-size:22px 22px;
+}
+#svg{width:100%;height:100%;display:block;touch-action:none;}
+.hint,.stats{
+ position:absolute;bottom:14px;font-size:12px;color:var(--ink-500);pointer-events:none;
+ background:rgba(255,255,255,.92);padding:6px 12px;border-radius:999px;
+ border:1px solid var(--border);box-shadow:var(--sh-1);
+}
+.hint{left:16px;}
+.stats{right:16px;font-variant-numeric:tabular-nums;}
+
+/* -------------------------------- 右侧检查器 -------------------------------- */
+.inspector{
+ flex:0 0 296px;background:var(--surface);border-left:1px solid var(--border);
+ padding:18px;overflow-y:auto;
+}
+.inspector label{display:block;margin-bottom:14px;font-size:12px;color:var(--ink-500);letter-spacing:.2px;}
+.inspector input,.inspector select,.inspector textarea{
+ width:100%;margin-top:6px;background:var(--surface);border:1px solid var(--ink-300);
+ color:var(--ink-800);border-radius:var(--r-sm);padding:8px 11px;font-size:13px;
+ font-family:inherit;outline:none;transition:border-color var(--t-fast),box-shadow var(--t-fast);
+}
+.inspector input:focus,.inspector select:focus,.inspector textarea:focus{
+ border-color:var(--blue-500);box-shadow:0 0 0 3px var(--blue-100);
+}
+.inspector textarea{resize:vertical;min-height:68px;line-height:1.55;}
+.inspector select{cursor:pointer;}
+.inspector input:disabled,.inspector select:disabled,.inspector textarea:disabled{opacity:.55;cursor:not-allowed;background:var(--ink-100);}
+.btn-row{display:flex;gap:8px;margin-top:2px;}
+.btn-row .btn{flex:1;}
+
+.insp-empty{color:var(--ink-400);text-align:center;padding:48px 10px 36px;font-size:12.5px;line-height:2;}
+.insp-empty b{color:var(--ink-600);font-weight:600;display:block;margin-bottom:2px;font-size:13.5px;}
+
+.meta{margin-top:18px;border-top:1px dashed var(--border);padding-top:14px;}
+.meta-row{display:flex;gap:8px;margin-bottom:8px;font-size:12px;line-height:1.55;}
+.meta-row span{color:var(--ink-400);flex:0 0 34px;}
+.meta-row b{color:var(--ink-700);font-weight:500;word-break:break-all;}
+
+.legend{margin-top:20px;border-top:1px dashed var(--border);padding-top:14px;}
+.legend-title{font-size:11.5px;color:var(--ink-400);margin-bottom:10px;letter-spacing:.6px;}
+.legend-items{display:flex;flex-wrap:wrap;gap:8px 14px;}
+.legend-items span{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--ink-600);}
+.legend-items i{width:9px;height:9px;border-radius:50%;display:inline-block;}
+
+/* -------------------------------- 画布 SVG 元素 -------------------------------- */
+.node{cursor:move;}
+/* 连线 / 加子节点手柄:默认半透明但仍可辨识,悬停或选中节点时完全显现并微浮起 */
+.node .link-handle,.node .add-handle{opacity:.6;transition:opacity var(--t-fast),filter var(--t-fast);}
+.node:hover .link-handle,.node:hover .add-handle,
+.node.selected .link-handle,.node.selected .add-handle{opacity:1;filter:drop-shadow(0 2px 5px rgba(15,23,42,.28));}
+.node .link-handle,.node .add-handle{cursor:crosshair;}
+.edge .edge-del{opacity:0;pointer-events:none;transition:opacity var(--t-fast);cursor:pointer;}
+.edge:hover .edge-del{opacity:1;pointer-events:auto;}
+.edge .edge-hit{cursor:pointer;}
+
+/* -------------------------------- 遮罩 / 弹窗 -------------------------------- */
+.overlay{
+ position:fixed;inset:0;background:rgba(15,23,42,.42);backdrop-filter:blur(2px);
+ display:flex;align-items:center;justify-content:center;z-index:70;padding:24px;
+ animation:fade var(--t-fast) both;
+}
+.dialog{
+ background:var(--surface);border:1px solid var(--border);border-radius:var(--r-lg);
+ padding:24px;width:364px;max-width:100%;box-shadow:var(--sh-3);
+ animation:fadeUp .2s cubic-bezier(.4,0,.2,1) both;
+}
+.dialog.wide{width:760px;max-width:94vw;height:82vh;display:flex;flex-direction:column;}
+.dialog h2{margin:0;font-size:16px;color:var(--ink-900);font-weight:600;}
+.dialog label{display:block;margin-bottom:14px;font-size:12px;color:var(--ink-500);}
+.dialog label input{display:block;width:100%;}
+.dialog input,.dialog select{
+ width:100%;margin-top:6px;background:var(--surface);border:1px solid var(--ink-300);
+ color:var(--ink-800);border-radius:var(--r-sm);padding:9px 11px;font-size:13px;
+ font-family:inherit;outline:none;transition:border-color var(--t-fast),box-shadow var(--t-fast);
+}
+.dialog input:focus,.dialog select:focus{border-color:var(--blue-500);box-shadow:0 0 0 3px var(--blue-100);}
+.dlg-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:16px;}
+.dlg-close{cursor:pointer;color:var(--ink-400);font-size:20px;line-height:1;padding:0 8px;border-radius:var(--r-sm);transition:background var(--t-fast),color var(--t-fast);}
+.dlg-close:hover{background:var(--ink-100);color:var(--ink-700);}
+.dlg-msg{font-size:13.5px;color:var(--ink-600);line-height:1.7;margin-bottom:16px;word-break:break-word;}
+.dlg-err{color:var(--danger-fg);font-size:12.5px;min-height:18px;margin-bottom:8px;}
+.dlg-tip{
+ font-size:12px;margin-bottom:14px;text-align:left;padding:9px 12px;border-radius:var(--r-sm);
+ background:var(--info-bg);border:1px solid var(--info-bd);color:var(--info-fg);
+}
+#pwdHintWrap{background:var(--warn-bg);border-color:var(--warn-bd);color:var(--warn-fg);}
+/* 修改密码:显示密码开关(覆盖 .dialog label input 的整宽样式) */
+.dialog label.pwd-show{display:flex;align-items:center;gap:7px;margin-bottom:16px;color:var(--ink-600);cursor:pointer;user-select:none;}
+.dialog label.pwd-show input[type=checkbox]{width:auto;margin:0;padding:0;flex:0 0 auto;border:none;background:none;box-shadow:none;accent-color:var(--blue-600);cursor:pointer;}
+.dlg-actions{display:flex;gap:10px;justify-content:flex-end;}
+.dlg-actions .btn{min-width:84px;}
+.dlg-actions .btn.primary,.dlg-actions .btn.danger{min-width:96px;}
+
+/* 格式选择弹框(导出 / 导入) */
+.format-list{display:flex;flex-direction:column;gap:10px;}
+.format-item{
+ display:flex;align-items:center;gap:12px;width:100%;text-align:left;
+ padding:12px 14px;border:1px solid var(--border);border-radius:var(--r-md);
+ background:var(--surface);cursor:pointer;font-family:inherit;
+ transition:border-color var(--t-fast),background var(--t-fast),box-shadow var(--t-fast),transform var(--t-fast);
+}
+.format-item:hover{border-color:var(--blue-100);background:var(--blue-50);box-shadow:var(--sh-1);transform:translateY(-1px);}
+.format-item:active{transform:translateY(0);}
+.format-item:focus-visible{outline:none;box-shadow:0 0 0 3px var(--blue-100);}
+.format-item .fi-badge{
+ flex:0 0 auto;width:46px;height:38px;border-radius:var(--r-sm);display:grid;place-items:center;
+ background:var(--blue-50);border:1px solid var(--blue-100);color:var(--blue-600);
+ font-size:11px;font-weight:700;letter-spacing:.4px;
+}
+.format-item .fi-text{display:flex;flex-direction:column;min-width:0;}
+.format-item .fi-name{font-size:13.5px;font-weight:600;color:var(--ink-900);}
+.format-item .fi-desc{font-size:12px;color:var(--ink-500);margin-top:3px;line-height:1.5;}
+
+.tabs{display:flex;gap:6px;border-bottom:1px solid var(--border);margin-bottom:16px;}
+.tab{padding:9px 13px;font-size:13px;color:var(--ink-500);cursor:pointer;border-bottom:2px solid transparent;transition:color var(--t-fast),border-color var(--t-fast);}
+.tab:hover{color:var(--ink-700);}
+.tab.active{color:var(--blue-600);border-bottom-color:var(--blue-600);font-weight:600;}
+.tab-pane{display:flex;flex-direction:column;flex:1;min-height:0;}
+
+/* -------------------------------- 列表 / 我的拓扑 -------------------------------- */
+.topo-list{overflow-y:auto;flex:1;padding-right:2px;}
+.topo-item{
+ display:flex;align-items:center;gap:10px;padding:12px 14px;
+ border:1px solid var(--border);border-radius:var(--r-md);margin-bottom:10px;background:var(--surface);
+ box-shadow:var(--sh-1);transition:transform var(--t-fast),box-shadow var(--t-fast),border-color var(--t-fast);
+}
+.topo-item:hover{transform:translateY(-2px);box-shadow:var(--sh-2);border-color:var(--blue-100);}
+.topo-meta{flex:1;min-width:0;}
+.topo-name{font-size:13.5px;font-weight:600;color:var(--ink-900);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
+.topo-sub{font-size:11.5px;color:var(--ink-500);margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
+.row-mini{display:flex;gap:6px;flex:0 0 auto;}
+.row-mini .btn{padding:5px 10px;font-size:12px;border-radius:var(--r-sm);}
+.new-row{display:flex;gap:10px;margin-bottom:16px;align-items:center;}
+.new-row input,.new-row select{margin-top:0;flex:1;min-width:0;width:auto;background:var(--surface);border:1px solid var(--ink-300);border-radius:var(--r-sm);padding:9px 11px;font-size:13px;font-family:inherit;color:var(--ink-800);outline:none;transition:border-color var(--t-fast),box-shadow var(--t-fast);}
+.new-row input:focus,.new-row select:focus{border-color:var(--blue-500);box-shadow:0 0 0 3px var(--blue-100);}
+.new-row .btn{flex:0 0 auto;}
+#newTopoTemplate{flex:0 0 auto;min-width:158px;cursor:pointer;}
+/* 我的拓扑:范围切换(我的 / 公开) */
+.seg{display:inline-flex;gap:4px;padding:4px;border:1px solid var(--border);border-radius:var(--r-md);background:var(--ink-100);margin-bottom:14px;}
+.seg-btn{
+ border:none;background:transparent;font-family:inherit;font-size:12.5px;font-weight:500;
+ color:var(--ink-500);padding:6px 14px;border-radius:var(--r-sm);cursor:pointer;
+ transition:background var(--t-fast),color var(--t-fast),box-shadow var(--t-fast);
+}
+.seg-btn:hover{color:var(--ink-700);}
+.seg-btn.active{background:var(--surface);color:var(--blue-600);font-weight:600;box-shadow:var(--sh-1);}
+.empty{color:var(--ink-400);text-align:center;padding:28px 0;font-size:12.5px;}
+
+/* -------------------------------- 右下角提示条 -------------------------------- */
+.toast{
+ position:fixed;right:24px;bottom:24px;left:auto;max-width:min(380px,86vw);
+ display:flex;align-items:center;gap:8px;
+ padding:11px 16px;border-radius:var(--r-md);font-size:13px;font-weight:500;
+ background:var(--info-bg);border:1px solid var(--info-bd);color:var(--info-fg);
+ box-shadow:var(--sh-2);z-index:90;pointer-events:none;
+ opacity:0;transform:translateY(12px);
+ transition:opacity .2s ease,transform .2s ease;
+}
+.toast.show{opacity:1;transform:none;}
+.toast.ok{background:var(--ok-bg);border-color:var(--ok-bd);color:var(--ok-fg);}
+.toast.err{background:var(--danger-bg);border-color:var(--danger-bd);color:var(--danger-fg);}
+.toast.warn{background:var(--warn-bg);border-color:var(--warn-bd);color:var(--warn-fg);}
+
+/* -------------------------------- 管理控制台 -------------------------------- */
+.console{
+ width:1040px;max-width:96vw;height:88vh;background:var(--surface);
+ border:1px solid var(--border);border-radius:var(--r-lg);display:flex;overflow:hidden;
+ box-shadow:var(--sh-3);animation:fadeUp .2s cubic-bezier(.4,0,.2,1) both;
+}
+/* 左侧深蓝可收缩侧栏 */
+.console-nav{
+ flex:0 0 210px;background:var(--nav-grad);color:#c7d7f7;
+ display:flex;flex-direction:column;padding:16px 12px;
+ transition:flex-basis .2s ease,padding .2s ease;
+}
+.console.nav-collapsed .console-nav{flex-basis:66px;padding:16px 9px;}
+.console-brand{display:flex;align-items:center;gap:9px;padding:2px 4px 16px;}
+.console-brand .logo{
+ width:28px;height:28px;border-radius:9px;display:grid;place-items:center;flex:0 0 auto;
+ background:rgba(255,255,255,.18);color:#fff;font-weight:700;font-size:14px;
+}
+.cb-text{min-width:0;overflow:hidden;}
+.console-brand b{display:block;font-size:13.5px;color:#fff;}
+.console-brand i{font-style:normal;font-size:11.5px;color:#a9c2f0;}
+.cnav-toggle{
+ margin-left:auto;flex:0 0 auto;width:28px;height:28px;border:none;border-radius:8px;cursor:pointer;
+ background:rgba(255,255,255,.14);color:#e0e9fb;font-size:13px;line-height:1;
+ transition:background var(--t-fast),color var(--t-fast);
+}
+.cnav-toggle:hover{background:rgba(255,255,255,.28);color:#fff;}
+.cnav-item{
+ display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:var(--r-sm);
+ font-size:13px;color:#c7d7f7;cursor:pointer;margin-bottom:4px;
+ transition:background var(--t-fast),color var(--t-fast);
+}
+.cnav-item .ci{font-size:15px;width:18px;text-align:center;flex:0 0 auto;}
+.cnav-item:hover{background:rgba(255,255,255,.12);color:#fff;}
+.cnav-item.active{background:rgba(255,255,255,.2);color:#fff;font-weight:600;}
+.cnave-foot,.cnav-foot{margin-top:auto;padding-top:10px;border-top:1px solid rgba(255,255,255,.16);}
+.cnav-close{
+ display:block;padding:9px 12px;border-radius:var(--r-sm);font-size:12.5px;text-align:center;cursor:pointer;
+ color:#c7d7f7;border:1px solid rgba(255,255,255,.28);
+ transition:background var(--t-fast),color var(--t-fast);
+}
+.cnav-close:hover{background:rgba(255,255,255,.16);color:#fff;}
+/* 收起态:隐藏文字,仅留图标 */
+.console.nav-collapsed .cb-text,
+.console.nav-collapsed .cl,
+.console.nav-collapsed .console-brand .logo{display:none;}
+.console.nav-collapsed .console-brand{gap:0;justify-content:center;padding-bottom:16px;}
+.console.nav-collapsed .cnav-item{justify-content:center;padding:10px 0;}
+
+.console-main{flex:1;min-width:0;display:flex;flex-direction:column;padding:24px 28px;overflow:hidden;}
+.view{display:flex;flex-direction:column;flex:1;min-height:0;animation:fadeUp .22s cubic-bezier(.4,0,.2,1) both;}
+.view-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:18px;gap:12px;flex-wrap:wrap;}
+.view-head h2{margin:0;font-size:18px;letter-spacing:.2px;color:var(--ink-900);font-weight:600;}
+.view-tools{display:flex;gap:8px;flex-wrap:wrap;}
+.search{
+ width:auto;background:var(--surface);border:1px solid var(--ink-300);color:var(--ink-800);
+ border-radius:var(--r-sm);padding:8px 11px;font-size:13px;font-family:inherit;
+ outline:none;min-width:184px;transition:border-color var(--t-fast),box-shadow var(--t-fast);
+}
+.search:focus{border-color:var(--blue-500);box-shadow:0 0 0 3px var(--blue-100);}
+
+/* -------------------------------- 网站设置 -------------------------------- */
+.settings-grid{flex:1;min-height:0;overflow-y:auto;display:grid;grid-template-columns:1fr 1fr;gap:16px;align-items:start;}
+.set-label{display:block;font-size:12.5px;color:var(--ink-500);margin-bottom:14px;letter-spacing:.2px;}
+.set-label .search{width:100%;margin-top:6px;min-width:0;}
+.set-row{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--ink-700);cursor:pointer;margin-bottom:16px;user-select:none;}
+.set-row input[type=checkbox]{width:16px;height:16px;margin:0;cursor:pointer;accent-color:var(--blue-600);}
+.logo-edit{display:flex;align-items:center;gap:14px;}
+.logo-preview{flex:0 0 auto;width:64px;height:64px;border-radius:14px;display:grid;place-items:center;background:var(--blue-600);color:#fff;font-size:26px;font-weight:700;overflow:hidden;box-shadow:0 4px 12px rgba(37,99,235,.24);}
+.logo-preview.has-img{background:#fff;border:1px solid var(--border);box-shadow:none;}
+.logo-preview img{width:100%;height:100%;object-fit:contain;display:block;}
+.logo-edit-side{min-width:0;}
+.hint-sm{font-size:11.5px;color:var(--ink-400);line-height:1.5;margin-top:8px;}
+@media (max-width:900px){.settings-grid{grid-template-columns:1fr;}}
+
+/* -------------------------------- 卡片 / 面板 -------------------------------- */
+.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:14px;margin-bottom:22px;}
+.card{
+ background:var(--surface);border:1px solid var(--border);border-radius:var(--r-md);padding:16px;
+ box-shadow:var(--sh-1);
+ transition:transform var(--t-fast),box-shadow var(--t-fast),border-color var(--t-fast);
+}
+.card:hover{transform:translateY(-3px);box-shadow:var(--sh-2);border-color:var(--blue-100);}
+.card .num{font-size:26px;font-weight:700;letter-spacing:.4px;color:var(--ink-900);font-variant-numeric:tabular-nums;}
+.card .lbl{font-size:12px;color:var(--ink-500);margin-top:6px;}
+.card.accent .num{color:var(--blue-600);}
+.card.green .num{color:var(--ok-fg);}
+.card.amber .num{color:var(--warn-fg);}
+
+.split{display:grid;grid-template-columns:1fr 1fr;gap:16px;flex:1;min-height:0;}
+.panel{
+ background:var(--surface);border:1px solid var(--border);border-radius:var(--r-md);padding:16px;
+ display:flex;flex-direction:column;min-height:0;box-shadow:var(--sh-1);
+}
+.panel-title{font-size:12.5px;color:var(--ink-500);margin-bottom:10px;letter-spacing:.4px;font-weight:600;}
+.mini-list{overflow-y:auto;flex:1;}
+.mini-row{display:flex;gap:10px;padding:9px 0;border-bottom:1px dashed var(--border);font-size:12.5px;}
+.mini-row:last-child{border-bottom:none;}
+.mini-row .t{color:var(--ink-400);flex:0 0 118px;font-variant-numeric:tabular-nums;}
+.mini-row .c{color:var(--ink-700);flex:1;min-width:0;word-break:break-all;}
+
+/* -------------------------------- 表格 -------------------------------- */
+.table-wrap{flex:1;overflow:auto;border:1px solid var(--border);border-radius:var(--r-md);background:var(--surface);}
+table.grid{width:100%;border-collapse:collapse;font-size:12.5px;}
+table.grid th{
+ position:sticky;top:0;background:var(--ink-100);text-align:left;padding:11px 14px;
+ font-size:11.5px;color:var(--ink-500);font-weight:600;border-bottom:1px solid var(--border);
+ white-space:nowrap;z-index:1;
+}
+table.grid td{padding:11px 14px;border-bottom:1px solid var(--border);color:var(--ink-700);vertical-align:middle;}
+table.grid tbody tr:last-child td{border-bottom:none;}
+table.grid tr:hover td{background:var(--blue-50);}
+table.grid td.actions{white-space:nowrap;}
+/* 拓扑管理:批量选择列 */
+table.grid th.col-chk,table.grid td.col-chk{width:40px;text-align:center;padding-left:14px;padding-right:4px;}
+table.grid th.col-chk input[type=checkbox],table.grid td.col-chk input[type=checkbox]{width:15px;height:15px;margin:0;cursor:pointer;accent-color:var(--blue-600);vertical-align:middle;}
+table.grid td.actions .btn{padding:4px 9px;font-size:11.5px;border-radius:var(--r-sm);margin:0 5px 0 0;}
+
+.pager{display:flex;align-items:center;justify-content:flex-end;gap:10px;padding-top:14px;font-size:12.5px;color:var(--ink-500);}
+.pager .btn{padding:6px 12px;}
+.empty-row{color:var(--ink-400);text-align:center;padding:28px 0;font-size:12.5px;}
+
+/* -------------------------------- 动效 -------------------------------- */
+@keyframes fade{from{opacity:0;}to{opacity:1;}}
+@keyframes fadeUp{from{opacity:0;transform:translateY(8px);}to{opacity:1;transform:none;}}
+@keyframes pop{from{opacity:0;transform:translateY(-6px) scale(.98);}to{opacity:1;transform:none;}}
+
+/* 尊重「减少动态效果」无障碍设置 */
+@media (prefers-reduced-motion:reduce){
+ *,*::before,*::after{
+ animation-duration:.001ms !important;
+ animation-iteration-count:1 !important;
+ transition-duration:.001ms !important;
+ scroll-behavior:auto !important;
+ }
+ .card:hover,.topo-item:hover{transform:none;}
+}
+
+/* -------------------------------- 独立管理后台(整页版) -------------------------------- */
+/* 主应用里的 .console 是浮层,这里改为整页铺满:无圆角 / 无描边 / 无阴影 */
+body.admin-page{overflow:hidden;}
+body.admin-page .console{
+ width:100%;max-width:none;height:100vh;
+ border:none;border-radius:0;box-shadow:none;animation:none;
+}
+body.admin-page .console-nav{flex:0 0 224px;}
+body.admin-page .console.nav-collapsed .console-nav{flex-basis:70px;}
+body.admin-page .console-main{padding:26px 32px;}
+body.admin-page .view-head h2{font-size:20px;}
+body.admin-page .view-tools select.search{cursor:pointer;min-width:172px;}
+body.admin-page .cnav-close{display:flex;align-items:center;justify-content:center;gap:8px;}
+body.admin-page .cnav-close .ci{font-size:14px;}
+body.admin-page .empty-row{width:100%;}