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 @@ + + + + + +管理控制台 · 路由拓扑 + + + + +
+ + + + +
+ + +
+

概览

+
+
+
+
最近操作
+
+
+
+
最近拓扑
+
+
+
+
+ + + + + + + + + + + + + +
+
+ + + + + + + + + + +
+ + + + 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 @@ + + + + + +路由拓扑 + + + +
+ +
+

路由拓扑

+ + + + + + + + + + +
+ + + +
+ +
+
+ +
+ + + +
+
+ + + + + + + + +
拖动空白平移 · 滚轮缩放 · 拖节点右侧蓝点连线 · 拖底部绿点加子节点 · 双击空白新建
+
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + 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%;}