添加共享

This commit is contained in:
MasonLiu 2026-09-15 02:28:40 +08:00
parent c3ff86d2c1
commit aaac88f323
10 changed files with 2456 additions and 84 deletions

561
index.php
View File

@ -224,6 +224,7 @@ function public_user($u)
'username' => $u['username'], 'username' => $u['username'],
'role' => isset($u['role']) ? $u['role'] : 'user', 'role' => isset($u['role']) ? $u['role'] : 'user',
'disabled' => !empty($u['disabled']), 'disabled' => !empty($u['disabled']),
'protected' => !empty($u['protected']),
'mustChangePassword' => !empty($u['must_change_password']), 'mustChangePassword' => !empty($u['must_change_password']),
'createdAt' => isset($u['created_at']) ? $u['created_at'] : '', 'createdAt' => isset($u['created_at']) ? $u['created_at'] : '',
'lastLoginAt' => isset($u['last_login_at']) ? $u['last_login_at'] : '', 'lastLoginAt' => isset($u['last_login_at']) ? $u['last_login_at'] : '',
@ -246,33 +247,42 @@ function log_row_out($r)
} }
function can_write_topo($row, $user) function can_write_topo($row, $user)
{
$p = topo_permission_for($row, $user);
return $p === 'owner' || $p === 'edit';
}
function can_read_topo($row, $user)
{
return topo_permission_for($row, $user) !== 'none';
}
/* 共享管理(增删共享 / 开关共享模式)仅限创建者本人或管理员 */
function can_manage_shares($row, $user)
{ {
if (!$row || !$user) { if (!$row || !$user) {
return false; return false;
} }
if ($user['role'] === 'admin') { if (isset($user['role']) && $user['role'] === 'admin') {
return true; return true;
} }
return $row['owner_id'] === $user['id']; return $row['owner_id'] === $user['id'];
} }
function can_read_topo($row, $user) /**
* 受保护账号(默认管理员)不允许被任何用户改动
*/
function assert_not_protected($target, $action)
{ {
if (!$row || !$user) { if (!empty($target['protected'])) {
return false; api_error('受保护的默认管理员账号不允许' . $action, 403);
} }
if ($user['role'] === 'admin') {
return true;
}
if ($row['owner_id'] === $user['id']) {
return true;
}
return $row['visibility'] === 'public';
} }
function topo_summary($row, $user, $names) function topo_summary($row, $user, $names)
{ {
$ownerId = $row['owner_id']; $ownerId = $row['owner_id'];
$perm = topo_permission_for($row, $user);
return array( return array(
'id' => $row['id'], 'id' => $row['id'],
'name' => $row['name'], 'name' => $row['name'],
@ -283,7 +293,11 @@ function topo_summary($row, $user, $names)
'updatedAt' => isset($row['updated_at']) ? $row['updated_at'] : '', 'updatedAt' => isset($row['updated_at']) ? $row['updated_at'] : '',
'nodeCount' => (int) $row['node_count'], 'nodeCount' => (int) $row['node_count'],
'edgeCount' => (int) $row['edge_count'], 'edgeCount' => (int) $row['edge_count'],
'canEdit' => can_write_topo($row, $user), 'canEdit' => ($perm === 'owner' || $perm === 'edit'),
'permission' => $perm,
'shareMode' => !empty($row['share_mode']),
'sharedWithMe' => ($ownerId !== $user['id'] && !empty($row['share_mode']) && ($perm === 'edit' || $perm === 'view')),
'rev' => isset($row['rev']) ? (int) $row['rev'] : 0,
); );
} }
@ -488,6 +502,9 @@ function api_user_update($admin, $id, $in)
if (!$u) { if (!$u) {
api_error('用户不存在', 404); api_error('用户不存在', 404);
} }
if (!empty($u['protected']) && (isset($in['role']) || isset($in['disabled']))) {
api_error('受保护的默认管理员账号不允许修改角色或状态', 403);
}
$fields = array(); $fields = array();
$changes = array(); $changes = array();
@ -531,6 +548,7 @@ function api_user_reset_password($admin, $id, $in)
if (!$u) { if (!$u) {
api_error('用户不存在', 404); api_error('用户不存在', 404);
} }
assert_not_protected($u, '重置密码');
$adminPw = isset($in['adminPassword']) ? (string) $in['adminPassword'] : ''; $adminPw = isset($in['adminPassword']) ? (string) $in['adminPassword'] : '';
$newPw = isset($in['newPassword']) ? (string) $in['newPassword'] : ''; $newPw = isset($in['newPassword']) ? (string) $in['newPassword'] : '';
if (!password_verify($adminPw, $admin['password_hash'])) { if (!password_verify($adminPw, $admin['password_hash'])) {
@ -557,6 +575,7 @@ function api_user_delete($admin, $id)
if (!$u) { if (!$u) {
api_error('用户不存在', 404); api_error('用户不存在', 404);
} }
assert_not_protected($u, '删除');
if ($u['id'] === $admin['id']) { if ($u['id'] === $admin['id']) {
api_error('不能删除当前登录账号'); api_error('不能删除当前登录账号');
} }
@ -648,6 +667,29 @@ function api_topo_save($user, $id, $in)
if (!can_write_topo($row, $user)) { if (!can_write_topo($row, $user)) {
api_error('无权修改该拓扑', 403); api_error('无权修改该拓扑', 403);
} }
// 协同版本冲突检测:客户端携带的 baseRev 与服务端不一致时,返回最新数据供三方合并
if (isset($in['baseRev'])) {
$curRev = isset($row['rev']) ? (int) $row['rev'] : 0;
if ((int) $in['baseRev'] !== $curRev) {
$content = json_decode($row['data'], true);
if (!is_array($content)) {
$content = array();
}
json_out(array(
'ok' => false,
'error' => '版本冲突:该拓扑已被其他协作者更新',
'conflict' => true,
'rev' => $curRev,
'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(),
),
), 409);
}
}
$nodes = (isset($in['nodes']) && is_array($in['nodes'])) ? $in['nodes'] : array(); $nodes = (isset($in['nodes']) && is_array($in['nodes'])) ? $in['nodes'] : array();
$edges = (isset($in['edges']) && is_array($in['edges'])) ? $in['edges'] : array(); $edges = (isset($in['edges']) && is_array($in['edges'])) ? $in['edges'] : array();
@ -883,6 +925,434 @@ function api_logs()
api_ok(array('logs' => $list, 'total' => $total, 'page' => $page, 'pageSize' => $pageSize)); api_ok(array('logs' => $list, 'total' => $total, 'page' => $page, 'pageSize' => $pageSize));
} }
/* ============================================================
* 用户分组接口(管理员)
* ============================================================ */
function group_out($g, $count)
{
return array(
'id' => $g['id'],
'name' => $g['name'],
'description' => isset($g['description']) ? $g['description'] : '',
'memberCount' => (int) $count,
'createdAt' => isset($g['created_at']) ? $g['created_at'] : '',
'updatedAt' => isset($g['updated_at']) ? $g['updated_at'] : '',
);
}
function api_groups_list()
{
$counts = group_member_counts();
$list = array();
foreach (group_list() as $g) {
$list[] = group_out($g, isset($counts[$g['id']]) ? $counts[$g['id']] : 0);
}
api_ok(array('groups' => $list));
}
function api_group_create($admin, $in)
{
$name = isset($in['name']) ? trim((string) $in['name']) : '';
$desc = isset($in['description']) ? trim((string) $in['description']) : '';
if ($name === '') {
api_error('请输入分组名称');
}
if (str_len($name) > 40) {
$name = str_cut($name, 40);
}
if (str_len($desc) > 120) {
$desc = str_cut($desc, 120);
}
if (group_find_by_name($name)) {
api_error('分组名称已存在', 409);
}
$id = gen_id('g');
group_insert(array('id' => $id, 'name' => $name, 'description' => $desc));
log_add($admin['id'], $admin['username'], 'group_create', $id, '创建分组 ' . $name, client_ip());
api_ok(array('group' => group_out(group_find($id), 0)));
}
function api_group_update($admin, $id, $in)
{
$g = group_find($id);
if (!$g) {
api_error('分组不存在', 404);
}
$fields = array();
if (isset($in['name'])) {
$name = trim((string) $in['name']);
if ($name === '') {
api_error('分组名称不能为空');
}
if (str_len($name) > 40) {
$name = str_cut($name, 40);
}
$dup = group_find_by_name($name);
if ($dup && $dup['id'] !== $id) {
api_error('分组名称已存在', 409);
}
$fields['name'] = $name;
}
if (isset($in['description'])) {
$desc = trim((string) $in['description']);
if (str_len($desc) > 120) {
$desc = str_cut($desc, 120);
}
$fields['description'] = $desc;
}
if ($fields) {
group_update($id, $fields);
log_add($admin['id'], $admin['username'], 'group_update', $id, '更新分组 ' . $g['name'], client_ip());
}
$counts = group_member_counts();
api_ok(array('group' => group_out(group_find($id), isset($counts[$id]) ? $counts[$id] : 0)));
}
function api_group_delete($admin, $id)
{
$g = group_find($id);
if (!$g) {
api_error('分组不存在', 404);
}
group_delete($id);
log_add($admin['id'], $admin['username'], 'group_delete', $id, '删除分组 ' . $g['name'], client_ip());
api_ok();
}
function api_group_members($id)
{
if (!group_find($id)) {
api_error('分组不存在', 404);
}
api_ok(array('members' => group_members($id)));
}
function api_group_members_set($admin, $id, $in)
{
$g = group_find($id);
if (!$g) {
api_error('分组不存在', 404);
}
$ids = (isset($in['userIds']) && is_array($in['userIds'])) ? $in['userIds'] : array();
$valid = array();
foreach ($ids as $uid) {
$uid = (string) $uid;
if ($uid !== '' && user_find_by_id($uid) && !in_array($uid, $valid, true)) {
$valid[] = $uid;
}
}
group_set_members($id, $valid);
log_add($admin['id'], $admin['username'], 'group_members', $id, '更新分组成员 ' . $g['name'] . ':共 ' . count($valid) . ' 人', client_ip());
api_ok(array('members' => group_members($id)));
}
function group_name_map()
{
$map = array();
foreach (group_list() as $g) {
$map[$g['id']] = $g['name'];
}
return $map;
}
/* ============================================================
* 拓扑共享接口
* ============================================================ */
function share_out($s, $userNames, $groupNames)
{
if ($s['target_type'] === 'user') {
$name = isset($userNames[$s['target_id']]) ? $userNames[$s['target_id']] : '未知用户';
} else {
$name = isset($groupNames[$s['target_id']]) ? $groupNames[$s['target_id']] : '未知分组';
}
return array(
'id' => $s['id'],
'targetType' => $s['target_type'],
'targetId' => $s['target_id'],
'targetName' => $name,
'permission' => ($s['permission'] === 'edit') ? 'edit' : 'view',
);
}
function api_topo_shares_get($user, $id)
{
if (!valid_id($id)) {
api_error('非法的拓扑 ID');
}
$row = topo_find($id);
if (!$row) {
api_error('拓扑不存在', 404);
}
if (!can_manage_shares($row, $user)) {
api_error('仅创建者可管理该拓扑的共享', 403);
}
$userNames = user_name_map();
$groupNames = group_name_map();
$shares = array();
foreach (share_list_by_topo($id) as $s) {
$shares[] = share_out($s, $userNames, $groupNames);
}
api_ok(array(
'shareMode' => !empty($row['share_mode']),
'shares' => $shares,
));
}
function api_topo_shares_set($user, $id, $in)
{
if (!valid_id($id)) {
api_error('非法的拓扑 ID');
}
$row = topo_find($id);
if (!$row) {
api_error('拓扑不存在', 404);
}
if (!can_manage_shares($row, $user)) {
api_error('仅创建者可管理该拓扑的共享', 403);
}
$shareMode = !empty($in['shareMode']);
$rows = array();
$seen = array();
if ($shareMode && isset($in['shares']) && is_array($in['shares'])) {
foreach ($in['shares'] as $s) {
if (!is_array($s)) {
continue;
}
$tt = (isset($s['targetType']) && $s['targetType'] === 'group') ? 'group' : 'user';
$tid = isset($s['targetId']) ? (string) $s['targetId'] : '';
if ($tid === '') {
continue;
}
if ($tt === 'user') {
if ($tid === $row['owner_id'] || !user_find_by_id($tid)) {
continue;
}
} else {
if (!group_find($tid)) {
continue;
}
}
$key = $tt . ':' . $tid;
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$rows[] = array(
'target_type' => $tt,
'target_id' => $tid,
'permission' => (isset($s['permission']) && $s['permission'] === 'edit') ? 'edit' : 'view',
);
}
}
share_replace($id, $rows, $user['id']);
topo_set_share_mode($id, $shareMode, now_iso());
log_add(
$user['id'],
$user['username'],
'topo_share',
$id,
($shareMode ? '开启共享' : '关闭共享') . '(共 ' . count($rows) . ' 项):' . $row['name'],
client_ip()
);
api_topo_shares_get($user, $id);
}
/* ============================================================
* 通讯录 / 概览
* ============================================================ */
function api_directory()
{
$users = array();
foreach (db_all('SELECT id, username FROM users WHERE disabled = 0 ORDER BY username ASC') as $u) {
$users[] = array('id' => $u['id'], 'username' => $u['username']);
}
$groups = array();
foreach (group_list() as $g) {
$groups[] = array('id' => $g['id'], 'name' => $g['name']);
}
api_ok(array('users' => $users, 'groups' => $groups));
}
function api_overview($user)
{
$own = 0;
$shared = 0;
foreach (topo_list_for_user($user) as $r) {
if ($r['owner_id'] === $user['id']) {
$own++;
continue;
}
$perm = topo_permission_for($r, $user);
if (!empty($r['share_mode']) && ($perm === 'edit' || $perm === 'view')) {
$shared++;
}
}
api_ok(array(
'overview' => array(
'myTopo' => $own,
'sharedToMe' => $shared,
'myGroups' => count(group_ids_of_user($user['id'])),
'publicTopo' => (int) db_val("SELECT COUNT(*) FROM topologies WHERE visibility = 'public'"),
)
));
}
/* 当前用户所属分组(普通用户可用) */
function api_my_groups($user)
{
$out = array();
foreach (group_ids_of_user($user['id']) as $gid) {
$g = group_find($gid);
if (!$g) {
continue;
}
$out[] = array(
'id' => $g['id'],
'name' => $g['name'],
'description' => isset($g['description']) ? $g['description'] : '',
'memberCount' => count(group_member_ids($gid)),
);
}
api_ok(array('groups' => $out));
}
/* ============================================================
* 协同:在线状态 / 版本推送
* ============================================================ */
function presence_online_out($plist, $selfId)
{
$out = array();
foreach ($plist as $p) {
$out[] = array(
'id' => $p['user_id'],
'username' => $p['username'],
'self' => ($p['user_id'] === $selfId),
);
}
return $out;
}
function sse_event($event, $data)
{
echo 'event: ' . $event . "\n";
echo 'data: ' . json_encode($data, JSON_UNESCAPED_UNICODE) . "\n\n";
if (ob_get_level() > 0) {
@ob_flush();
}
@flush();
}
/**
* SSE 推送:拓扑版本变化 + 在线成员。
* 有界存活( 30s)后关闭,由前端 EventSource 自动重连。
* 注意:php -S 单进程下会占用一个 worker,建议设置 PHP_CLI_SERVER_WORKERS;
* Apache / PHP-FPM 无此限制。
*/
function api_topo_stream($user, $id, $since)
{
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);
}
@ini_set('zlib.output_compression', '0');
@ini_set('output_buffering', '0');
@set_time_limit(0);
while (ob_get_level() > 0) {
@ob_end_flush();
}
header('Content-Type: text/event-stream; charset=utf-8');
header('Cache-Control: no-cache, no-store');
header('X-Accel-Buffering: no');
$deadline = time() + 30;
$lastRev = null;
$lastPresence = null;
presence_touch($id, $user);
presence_purge(time() - 120);
while (time() < $deadline) {
if (connection_aborted()) {
return;
}
$cur = topo_find($id);
if (!$cur) {
sse_event('gone', array('reason' => 'deleted'));
return;
}
$rev = isset($cur['rev']) ? (int) $cur['rev'] : 0;
if ($rev !== $lastRev) {
$lastRev = $rev;
if ($rev !== $since) {
sse_event('revision', array('rev' => $rev));
}
}
presence_touch($id, $user);
$plist = presence_online_out(presence_list($id, 15), $user['id']);
$hash = md5(json_encode($plist));
if ($hash !== $lastPresence) {
$lastPresence = $hash;
sse_event('presence', array('users' => $plist));
}
sse_event('ping', array('t' => time()));
usleep(1500000);
}
sse_event('bye', array('t' => time()));
}
function api_topo_poll($user, $id, $since)
{
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);
}
presence_touch($id, $user);
presence_purge(time() - 120);
$rev = isset($row['rev']) ? (int) $row['rev'] : 0;
api_ok(array(
'rev' => $rev,
'changed' => ($since >= 0 && (int) $since !== $rev),
'presence' => presence_online_out(presence_list($id, 15), $user['id']),
));
}
function api_topo_presence($user, $id, $in)
{
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);
}
if (!empty($in['leave'])) {
presence_remove($id, $user['id']);
} else {
presence_touch($id, $user);
}
presence_purge(time() - 120);
api_ok(array('presence' => presence_online_out(presence_list($id, 15), $user['id'])));
}
/* ============================================================ /* ============================================================
* 路由 * 路由
* ============================================================ */ * ============================================================ */
@ -923,6 +1393,30 @@ function handle_api($method, $segments)
api_ok(array('user' => $u ? public_user($u) : null)); api_ok(array('user' => $u ? public_user($u) : null));
break; break;
case 'directory':
require_auth();
if ($method !== 'GET') {
api_error('方法不允许', 405);
}
api_directory();
break;
case 'overview':
$u = require_auth();
if ($method !== 'GET') {
api_error('方法不允许', 405);
}
api_overview($u);
break;
case 'mygroups':
$u = require_auth();
if ($method !== 'GET') {
api_error('方法不允许', 405);
}
api_my_groups($u);
break;
case 'site': case 'site':
if ($method !== 'GET') { if ($method !== 'GET') {
api_error('方法不允许', 405); api_error('方法不允许', 405);
@ -975,6 +1469,33 @@ function handle_api($method, $segments)
api_error('接口不存在', 404); api_error('接口不存在', 404);
break; break;
case 'groups':
$admin = require_admin();
if ($method === 'GET' && $id === '') {
api_groups_list();
}
if ($method === 'POST' && $id === '') {
api_group_create($admin, json_input());
}
if ($id !== '') {
if ($action === 'members') {
if ($method === 'GET') {
api_group_members($id);
}
if ($method === 'PUT') {
api_group_members_set($admin, $id, json_input());
}
}
if ($action === '' && $method === 'PUT') {
api_group_update($admin, $id, json_input());
}
if ($action === '' && $method === 'DELETE') {
api_group_delete($admin, $id);
}
}
api_error('接口不存在', 404);
break;
case 'admin': case 'admin':
$admin = require_admin(); $admin = require_admin();
if ($id === 'topologies' && $action === '' && $method === 'GET') { if ($id === 'topologies' && $action === '' && $method === 'GET') {
@ -1009,6 +1530,22 @@ function handle_api($method, $segments)
if ($action === 'rename' && $method === 'PUT') { if ($action === 'rename' && $method === 'PUT') {
api_topo_rename($user, $id, json_input()); api_topo_rename($user, $id, json_input());
} }
if ($action === 'shares' && $method === 'GET') {
api_topo_shares_get($user, $id);
}
if ($action === 'shares' && $method === 'PUT') {
api_topo_shares_set($user, $id, json_input());
}
if ($action === 'stream' && $method === 'GET') {
api_topo_stream($user, $id, isset($_GET['since']) ? (int) $_GET['since'] : -1);
exit;
}
if ($action === 'poll' && $method === 'GET') {
api_topo_poll($user, $id, isset($_GET['since']) ? (int) $_GET['since'] : -1);
}
if ($action === 'presence' && $method === 'POST') {
api_topo_presence($user, $id, json_input());
}
if ($action === '') { if ($action === '') {
if ($method === 'GET') { if ($method === 'GET') {
api_topo_get($user, $id); api_topo_get($user, $id);

View File

@ -129,6 +129,47 @@ function init_schema($pdo, $driver)
PRIMARY KEY (skey) PRIMARY KEY (skey)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$pdo->exec("CREATE TABLE IF NOT EXISTS user_groups (
id VARCHAR(32) NOT NULL,
name VARCHAR(64) NOT NULL,
description VARCHAR(255) DEFAULT NULL,
created_at VARCHAR(32) DEFAULT NULL,
updated_at VARCHAR(32) DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_groups_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$pdo->exec("CREATE TABLE IF NOT EXISTS user_group_members (
group_id VARCHAR(32) NOT NULL,
user_id VARCHAR(32) NOT NULL,
created_at VARCHAR(32) DEFAULT NULL,
PRIMARY KEY (group_id, user_id),
KEY idx_ugm_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$pdo->exec("CREATE TABLE IF NOT EXISTS topo_shares (
id VARCHAR(32) NOT NULL,
topo_id VARCHAR(32) NOT NULL,
target_type VARCHAR(8) NOT NULL,
target_id VARCHAR(32) NOT NULL,
permission VARCHAR(8) NOT NULL,
created_by VARCHAR(32) DEFAULT NULL,
created_at VARCHAR(32) DEFAULT NULL,
PRIMARY KEY (id),
KEY idx_shares_topo (topo_id),
KEY idx_shares_target (target_type, target_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$pdo->exec("CREATE TABLE IF NOT EXISTS topo_presence (
topo_id VARCHAR(32) NOT NULL,
user_id VARCHAR(32) NOT NULL,
username VARCHAR(32) DEFAULT NULL,
last_seen BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (topo_id, user_id),
KEY idx_presence_seen (last_seen)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
migrate_columns($pdo, $driver);
return; return;
} }
@ -183,7 +224,80 @@ function init_schema($pdo, $driver)
updated_at TEXT updated_at TEXT
)"); )");
$pdo->exec("CREATE TABLE IF NOT EXISTS user_groups (
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT,
created_at TEXT,
updated_at TEXT
)");
$pdo->exec("CREATE TABLE IF NOT EXISTS user_group_members (
group_id TEXT NOT NULL,
user_id TEXT NOT NULL,
created_at TEXT,
PRIMARY KEY (group_id, user_id)
)");
$pdo->exec("CREATE TABLE IF NOT EXISTS topo_shares (
id TEXT NOT NULL PRIMARY KEY,
topo_id TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
permission TEXT NOT NULL,
created_by TEXT,
created_at TEXT
)");
$pdo->exec("CREATE TABLE IF NOT EXISTS topo_presence (
topo_id TEXT NOT NULL,
user_id TEXT NOT NULL,
username TEXT,
last_seen INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (topo_id, user_id)
)");
$pdo->exec("CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)"); $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_topo_owner ON topologies(owner_id)");
$pdo->exec("CREATE INDEX IF NOT EXISTS idx_logs_created ON activity_logs(created_at)"); $pdo->exec("CREATE INDEX IF NOT EXISTS idx_logs_created ON activity_logs(created_at)");
$pdo->exec("CREATE INDEX IF NOT EXISTS idx_ugm_user ON user_group_members(user_id)");
$pdo->exec("CREATE INDEX IF NOT EXISTS idx_shares_topo ON topo_shares(topo_id)");
$pdo->exec("CREATE INDEX IF NOT EXISTS idx_shares_target ON topo_shares(target_type, target_id)");
$pdo->exec("CREATE INDEX IF NOT EXISTS idx_presence_seen ON topo_presence(last_seen)");
migrate_columns($pdo, $driver);
}
/**
* 幂等加列:已存在的库平滑升级(表名/列名均为代码内常量,无注入风险)
*/
function ensure_column($pdo, $driver, $table, $col, $ddl)
{
if ($driver === 'mysql') {
$cols = array();
foreach ($pdo->query('SHOW COLUMNS FROM ' . $table) as $r) {
$cols[] = isset($r['Field']) ? $r['Field'] : '';
}
if (!in_array($col, $cols, true)) {
$pdo->exec('ALTER TABLE ' . $table . ' ADD COLUMN ' . $ddl);
}
return;
}
$cols = array();
foreach ($pdo->query('PRAGMA table_info(' . $table . ')') as $r) {
$cols[] = $r['name'];
}
if (!in_array($col, $cols, true)) {
$pdo->exec('ALTER TABLE ' . $table . ' ADD COLUMN ' . $ddl);
}
}
/**
* 列迁移:为老库补齐新增字段
*/
function migrate_columns($pdo, $driver)
{
ensure_column($pdo, $driver, 'users', 'protected', 'protected INTEGER NOT NULL DEFAULT 0');
ensure_column($pdo, $driver, 'topologies', 'rev', 'rev INTEGER NOT NULL DEFAULT 0');
ensure_column($pdo, $driver, 'topologies', 'share_mode', 'share_mode INTEGER NOT NULL DEFAULT 0');
} }

View File

@ -99,8 +99,8 @@ function user_count($q)
function user_insert($data) function user_insert($data)
{ {
db_run( db_run(
'INSERT INTO users (id, username, password_hash, role, disabled, must_change_password, created_at, last_login_at, login_count) 'INSERT INTO users (id, username, password_hash, role, disabled, must_change_password, protected, created_at, last_login_at, login_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
array( array(
$data['id'], $data['id'],
$data['username'], $data['username'],
@ -108,6 +108,7 @@ function user_insert($data)
isset($data['role']) ? $data['role'] : 'user', isset($data['role']) ? $data['role'] : 'user',
!empty($data['disabled']) ? 1 : 0, !empty($data['disabled']) ? 1 : 0,
!empty($data['must_change_password']) ? 1 : 0, !empty($data['must_change_password']) ? 1 : 0,
!empty($data['protected']) ? 1 : 0,
isset($data['created_at']) ? $data['created_at'] : now_iso(), isset($data['created_at']) ? $data['created_at'] : now_iso(),
isset($data['last_login_at']) ? $data['last_login_at'] : null, isset($data['last_login_at']) ? $data['last_login_at'] : null,
isset($data['login_count']) ? (int) $data['login_count'] : 0, isset($data['login_count']) ? (int) $data['login_count'] : 0,
@ -146,6 +147,120 @@ function user_touch_login($id)
db_run('UPDATE users SET last_login_at = ?, login_count = login_count + 1 WHERE id = ?', array(now_iso(), $id)); db_run('UPDATE users SET last_login_at = ?, login_count = login_count + 1 WHERE id = ?', array(now_iso(), $id));
} }
/* ============================================================
* 用户分组
* ============================================================ */
function group_list()
{
return db_all('SELECT * FROM user_groups ORDER BY created_at ASC, name ASC');
}
function group_find($id)
{
return db_one('SELECT * FROM user_groups WHERE id = ?', array($id));
}
function group_find_by_name($name)
{
return db_one('SELECT * FROM user_groups WHERE name = ?', array($name));
}
function group_insert($data)
{
db_run(
'INSERT INTO user_groups (id, name, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?)',
array(
$data['id'],
$data['name'],
isset($data['description']) ? $data['description'] : '',
isset($data['created_at']) ? $data['created_at'] : now_iso(),
isset($data['updated_at']) ? $data['updated_at'] : now_iso(),
)
);
return $data['id'];
}
function group_update($id, $fields)
{
$allowed = array('name', 'description');
$sets = array();
$params = array();
foreach ($allowed as $f) {
if (array_key_exists($f, $fields)) {
$sets[] = $f . ' = ?';
$params[] = $fields[$f];
}
}
if (!$sets) {
return false;
}
$sets[] = 'updated_at = ?';
$params[] = now_iso();
$params[] = $id;
db_run('UPDATE user_groups SET ' . implode(', ', $sets) . ' WHERE id = ?', $params);
return true;
}
function group_delete($id)
{
db_run('DELETE FROM user_groups WHERE id = ?', array($id));
db_run('DELETE FROM user_group_members WHERE group_id = ?', array($id));
db_run("DELETE FROM topo_shares WHERE target_type = 'group' AND target_id = ?", array($id));
}
function group_members($gid)
{
return db_all(
'SELECT u.id, u.username FROM user_group_members m JOIN users u ON u.id = m.user_id
WHERE m.group_id = ? ORDER BY u.username ASC',
array($gid)
);
}
function group_member_ids($gid)
{
$out = array();
foreach (db_all('SELECT user_id FROM user_group_members WHERE group_id = ?', array($gid)) as $r) {
$out[] = $r['user_id'];
}
return $out;
}
function group_set_members($gid, $userIds)
{
db_run('DELETE FROM user_group_members WHERE group_id = ?', array($gid));
$seen = array();
foreach ((array) $userIds as $uid) {
$uid = (string) $uid;
if ($uid === '' || isset($seen[$uid])) {
continue;
}
$seen[$uid] = true;
db_run(
'INSERT INTO user_group_members (group_id, user_id, created_at) VALUES (?, ?, ?)',
array($gid, $uid, now_iso())
);
}
}
function group_ids_of_user($uid)
{
$out = array();
foreach (db_all('SELECT group_id FROM user_group_members WHERE user_id = ?', array($uid)) as $r) {
$out[] = $r['group_id'];
}
return $out;
}
function group_member_counts()
{
$map = array();
foreach (db_all('SELECT group_id, COUNT(*) AS c FROM user_group_members GROUP BY group_id') as $r) {
$map[$r['group_id']] = (int) $r['c'];
}
return $map;
}
/* ============================================================ /* ============================================================
* 会话 * 会话
* ============================================================ */ * ============================================================ */
@ -187,11 +302,21 @@ function topo_find($id)
function topo_list_for_user($user) function topo_list_for_user($user)
{ {
/* 主应用「我的拓扑」仅返回:当前用户创建的 + 公开的。 /* 主应用 / 用户后台返回:本人创建的 + 公开的 + 通过成员/分组共享且已开启共享模式的。
全部拓扑(含他人私有)只能在管理后台查看, topo_admin_search() */ 全部拓扑(含他人私有)只能在管理后台查看, topo_admin_search() */
return db_all( return db_all(
'SELECT * FROM topologies WHERE owner_id = ? OR visibility = ? ORDER BY updated_at DESC', "SELECT DISTINCT t.* FROM topologies t
array($user['id'], 'public') LEFT JOIN topo_shares s ON s.topo_id = t.id
WHERE t.owner_id = ?
OR t.visibility = 'public'
OR (t.share_mode = 1 AND (
(s.target_type = 'user' AND s.target_id = ?)
OR (s.target_type = 'group' AND s.target_id IN (
SELECT group_id FROM user_group_members WHERE user_id = ?
))
))
ORDER BY t.updated_at DESC",
array($user['id'], $user['id'], $user['id'])
); );
} }
@ -276,12 +401,12 @@ function topo_update($id, $data, $nodeCount, $edgeCount, $updatedAt, $name = nul
{ {
if ($name !== null && $name !== '') { if ($name !== null && $name !== '') {
db_run( db_run(
'UPDATE topologies SET data = ?, node_count = ?, edge_count = ?, updated_at = ?, name = ? WHERE id = ?', 'UPDATE topologies SET data = ?, node_count = ?, edge_count = ?, updated_at = ?, rev = rev + 1, name = ? WHERE id = ?',
array($data, (int) $nodeCount, (int) $edgeCount, $updatedAt, $name, $id) array($data, (int) $nodeCount, (int) $edgeCount, $updatedAt, $name, $id)
); );
} else { } else {
db_run( db_run(
'UPDATE topologies SET data = ?, node_count = ?, edge_count = ?, updated_at = ? WHERE id = ?', 'UPDATE topologies SET data = ?, node_count = ?, edge_count = ?, updated_at = ?, rev = rev + 1 WHERE id = ?',
array($data, (int) $nodeCount, (int) $edgeCount, $updatedAt, $id) array($data, (int) $nodeCount, (int) $edgeCount, $updatedAt, $id)
); );
} }
@ -294,12 +419,146 @@ function topo_delete($id)
function topo_set_visibility($id, $vis, $updatedAt) function topo_set_visibility($id, $vis, $updatedAt)
{ {
db_run('UPDATE topologies SET visibility = ?, updated_at = ? WHERE id = ?', array($vis, $updatedAt, $id)); db_run('UPDATE topologies SET visibility = ?, updated_at = ?, rev = rev + 1 WHERE id = ?', array($vis, $updatedAt, $id));
} }
function topo_set_name($id, $name, $updatedAt) function topo_set_name($id, $name, $updatedAt)
{ {
db_run('UPDATE topologies SET name = ?, updated_at = ? WHERE id = ?', array($name, $updatedAt, $id)); db_run('UPDATE topologies SET name = ?, updated_at = ?, rev = rev + 1 WHERE id = ?', array($name, $updatedAt, $id));
}
function topo_set_share_mode($id, $on, $updatedAt)
{
db_run('UPDATE topologies SET share_mode = ?, updated_at = ?, rev = rev + 1 WHERE id = ?', array($on ? 1 : 0, $updatedAt, $id));
}
/* ============================================================
* 拓扑共享
* ============================================================ */
function share_list_by_topo($topoId)
{
return db_all('SELECT * FROM topo_shares WHERE topo_id = ? ORDER BY created_at ASC', array($topoId));
}
/**
* 整体替换某拓扑的共享列表(事务)
*/
function share_replace($topoId, $rows, $by)
{
$pdo = db();
$pdo->beginTransaction();
try {
db_run('DELETE FROM topo_shares WHERE topo_id = ?', array($topoId));
foreach ((array) $rows as $r) {
db_run(
'INSERT INTO topo_shares (id, topo_id, target_type, target_id, permission, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)',
array(
gen_id('s'),
$topoId,
$r['target_type'],
$r['target_id'],
$r['permission'],
$by,
now_iso(),
)
);
}
$pdo->commit();
} catch (Exception $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
}
/**
* 计算用户对拓扑的有效权限:owner | edit | view | none
*/
function topo_permission_for($row, $user)
{
if (!$row || !$user) {
return 'none';
}
if (isset($user['role']) && $user['role'] === 'admin') {
return 'owner';
}
if ($row['owner_id'] === $user['id']) {
return 'owner';
}
$best = 'none';
if (!empty($row['share_mode'])) {
$gids = group_ids_of_user($user['id']);
foreach (share_list_by_topo($row['id']) as $s) {
$hit = false;
if ($s['target_type'] === 'user' && $s['target_id'] === $user['id']) {
$hit = true;
} elseif ($s['target_type'] === 'group' && in_array($s['target_id'], $gids, true)) {
$hit = true;
}
if (!$hit) {
continue;
}
if ($s['permission'] === 'edit') {
return 'edit';
}
if ($s['permission'] === 'view') {
$best = 'view';
}
}
}
if ($best === 'view') {
return 'view';
}
if ($row['visibility'] === 'public') {
return 'view';
}
return 'none';
}
/* ============================================================
* 在线状态(协同)
* ============================================================ */
function presence_touch($topoId, $user)
{
$now = time();
$exists = db_one('SELECT topo_id FROM topo_presence WHERE topo_id = ? AND user_id = ?', array($topoId, $user['id']));
if ($exists) {
db_run(
'UPDATE topo_presence SET username = ?, last_seen = ? WHERE topo_id = ? AND user_id = ?',
array($user['username'], $now, $topoId, $user['id'])
);
} else {
db_run(
'INSERT INTO topo_presence (topo_id, user_id, username, last_seen) VALUES (?, ?, ?, ?)',
array($topoId, $user['id'], $user['username'], $now)
);
}
}
function presence_list($topoId, $activeWithin)
{
$cut = time() - (int) $activeWithin;
return db_all(
'SELECT user_id, username, last_seen FROM topo_presence WHERE topo_id = ? AND last_seen >= ? ORDER BY last_seen DESC',
array($topoId, $cut)
);
}
function presence_remove($topoId, $userId)
{
db_run('DELETE FROM topo_presence WHERE topo_id = ? AND user_id = ?', array($topoId, $userId));
}
function presence_purge($beforeTs)
{
db_run('DELETE FROM topo_presence WHERE last_seen < ?', array((int) $beforeTs));
}
function topo_rev($id)
{
return (int) db_val('SELECT rev FROM topologies WHERE id = ?', array($id));
} }
/* ============================================================ /* ============================================================
@ -463,6 +722,7 @@ function ensure_seed()
migrate_legacy_json(); migrate_legacy_json();
if ((int) db_val('SELECT COUNT(*) FROM users') > 0) { if ((int) db_val('SELECT COUNT(*) FROM users') > 0) {
protect_configured_admin();
return; return;
} }
$cfg = app_config(); $cfg = app_config();
@ -475,5 +735,18 @@ function ensure_seed()
'disabled' => 0, 'disabled' => 0,
'must_change_password' => 1, 'must_change_password' => 1,
'created_at' => now_iso(), 'created_at' => now_iso(),
'protected' => 1,
)); ));
} }
/**
* 默认管理员(配置文件中的 default_admin)始终置为受保护账号,不可被其他用户改动
*/
function protect_configured_admin()
{
$cfg = app_config();
$name = isset($cfg['default_admin']['username']) ? $cfg['default_admin']['username'] : 'admin';
if ($name !== '' && $name !== null) {
db_run('UPDATE users SET protected = 1 WHERE username = ?', array($name));
}
}

View File

@ -49,22 +49,41 @@
<!-- 用户管理 --> <!-- 用户管理 -->
<section class="view" id="view-users" hidden> <section class="view" id="view-users" hidden>
<div class="view-head"> <div class="view-head"><h2>用户管理</h2></div>
<h2>用户管理</h2> <div class="tabs" id="userTabs">
<div class="view-tools"> <div class="tab active" data-tab="list">用户列表</div>
<div class="tab" data-tab="groups">分组管理</div>
</div>
<div class="tab-pane" id="userTab-list">
<div class="tools-inline">
<input class="search" id="userSearch" placeholder="搜索用户名 / 角色" spellcheck="false"> <input class="search" id="userSearch" placeholder="搜索用户名 / 角色" spellcheck="false">
<button class="btn primary" id="btnNewUser"> 新建用户</button> <button class="btn primary" id="btnNewUser"> 新建用户</button>
</div> </div>
<div class="table-wrap">
<table class="grid">
<thead><tr>
<th>用户名</th><th>角色</th><th>状态</th><th>登录次数</th><th>最近登录</th><th>创建时间</th><th>操作</th>
</tr></thead>
<tbody id="userRows"></tbody>
</table>
</div>
<div class="pager" id="userPager"></div>
</div> </div>
<div class="table-wrap">
<table class="grid"> <div class="tab-pane" id="userTab-groups" hidden>
<thead><tr> <div class="tools-inline">
<th>用户名</th><th>角色</th><th>状态</th><th>登录次数</th><th>最近登录</th><th>创建时间</th><th>操作</th> <button class="btn primary" id="btnNewGroup"> 新建分组</button>
</tr></thead> </div>
<tbody id="userRows"></tbody> <div class="table-wrap">
</table> <table class="grid">
<thead><tr>
<th>分组名称</th><th>描述</th><th>成员数</th><th>创建时间</th><th>操作</th>
</tr></thead>
<tbody id="groupRows"></tbody>
</table>
</div>
</div> </div>
<div class="pager" id="userPager"></div>
</section> </section>
<!-- 拓扑管理 --> <!-- 拓扑管理 -->
@ -199,6 +218,39 @@
</div> </div>
</div> </div>
<!-- ================= 新建 / 编辑 分组 ================= -->
<div class="overlay" id="groupOverlay" hidden>
<div class="dialog">
<div class="dlg-head"><h2 id="groupDlgTitle">新建分组</h2><span class="dlg-close" id="btnCloseGroup">×</span></div>
<label>分组名称
<input id="gName" type="text" spellcheck="false" maxlength="40" placeholder="例如:红队 / 运维组">
</label>
<label>描述
<input id="gDesc" type="text" spellcheck="false" maxlength="120" placeholder="可选">
</label>
<div class="dlg-err" id="groupErr"></div>
<div class="dlg-actions">
<button class="btn" id="btnCancelGroup" type="button">取消</button>
<button class="btn primary" id="btnSaveGroup" type="button">保存</button>
</div>
</div>
</div>
<!-- ================= 管理分组成员 ================= -->
<div class="overlay" id="memberOverlay" hidden>
<div class="dialog wide share-dialog">
<div class="dlg-head"><h2 id="memberDlgTitle">管理成员</h2><span class="dlg-close" id="btnCloseMember">×</span></div>
<div class="dlg-msg" id="memberMsg"></div>
<input class="search" id="memberFilter" placeholder="搜索用户" spellcheck="false">
<div class="share-list" id="memberList"></div>
<div class="dlg-err" id="memberErr"></div>
<div class="dlg-actions">
<button class="btn" id="btnCancelMember" type="button">取消</button>
<button class="btn primary" id="btnSaveMember" type="button">保存成员</button>
</div>
</div>
</div>
<!-- ================= 站内确认 / 输入弹窗 ================= --> <!-- ================= 站内确认 / 输入弹窗 ================= -->
<div class="overlay" id="confirmOverlay" hidden> <div class="overlay" id="confirmOverlay" hidden>
<div class="dialog"> <div class="dialog">

View File

@ -103,7 +103,13 @@
saveSiteSettings: function (p) { return this.req('PUT', '/admin/settings', p); }, saveSiteSettings: function (p) { return this.req('PUT', '/admin/settings', p); },
setVisibility: function (id, vis) { return this.req('PUT', '/topologies/' + encodeURIComponent(id) + '/permission', { visibility: vis }); }, 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 }); }, renameTopo: function (id, name) { return this.req('PUT', '/topologies/' + encodeURIComponent(id) + '/rename', { name: name }); },
deleteTopo: function (id) { return this.req('DELETE', '/topologies/' + encodeURIComponent(id)); } deleteTopo: function (id) { return this.req('DELETE', '/topologies/' + encodeURIComponent(id)); },
groups: function () { return this.req('GET', '/groups'); },
createGroup: function (p) { return this.req('POST', '/groups', p); },
updateGroup: function (id, p) { return this.req('PUT', '/groups/' + encodeURIComponent(id), p); },
deleteGroup: function (id) { return this.req('DELETE', '/groups/' + encodeURIComponent(id)); },
groupMembers: function (id) { return this.req('GET', '/groups/' + encodeURIComponent(id) + '/members'); },
setGroupMembers: function (id, ids) { return this.req('PUT', '/groups/' + encodeURIComponent(id) + '/members', { userIds: ids }); }
}; };
/* ============================================================ /* ============================================================
@ -224,6 +230,8 @@
const state = { const state = {
view: 'overview', view: 'overview',
currentUser: null, currentUser: null,
usersTab: 'list',
groups: [],
users: { page: 1, pageSize: 10, q: '', total: 0 }, users: { page: 1, pageSize: 10, q: '', total: 0 },
topos: { page: 1, pageSize: 10, q: '', owner: '', total: 0 }, topos: { page: 1, pageSize: 10, q: '', owner: '', total: 0 },
logs: { page: 1, pageSize: 12, q: '', total: 0 } logs: { page: 1, pageSize: 12, q: '', total: 0 }
@ -249,12 +257,28 @@
it.classList.toggle('active', it.getAttribute('data-view') === name); it.classList.toggle('active', it.getAttribute('data-view') === name);
}); });
if (name === 'overview') { loadOverview(); } if (name === 'overview') { loadOverview(); }
else if (name === 'users') { loadUsers(); } else if (name === 'users') { loadUserTab(); }
else if (name === 'topo') { loadTopoOwnerOptions(); loadTopoAdmin(); } else if (name === 'topo') { loadTopoOwnerOptions(); loadTopoAdmin(); }
else if (name === 'settings') { loadSettings(); } else if (name === 'settings') { loadSettings(); }
else if (name === 'logs') { loadLogs(); } else if (name === 'logs') { loadLogs(); }
} }
/* 用户管理:用户列表 / 分组管理 子页切换 */
function applyUserTab() {
const isGroups = (state.usersTab === 'groups');
$('userTab-list').hidden = isGroups;
$('userTab-groups').hidden = !isGroups;
Array.prototype.forEach.call(document.querySelectorAll('#userTabs .tab'), function (t) {
t.classList.toggle('active', t.getAttribute('data-tab') === state.usersTab);
});
}
function loadUserTab() {
applyUserTab();
if (state.usersTab === 'groups') { loadGroups(); }
else { loadUsers(); }
}
/* ============================================================ /* ============================================================
概览 概览
============================================================ */ ============================================================ */
@ -300,8 +324,9 @@
? '<span class="badge ro">已禁用</span>' ? '<span class="badge ro">已禁用</span>'
: '<span class="badge public">正常</span>'; : '<span class="badge public">正常</span>';
const pwdFlag = u.mustChangePassword ? ' <span class="badge ro">待改密</span>' : ''; const pwdFlag = u.mustChangePassword ? ' <span class="badge ro">待改密</span>' : '';
const protFlag = u.protected ? ' <span class="badge owner">受保护</span>' : '';
return '<tr>' + return '<tr>' +
'<td>' + esc(u.username) + '</td>' + '<td>' + esc(u.username) + protFlag + '</td>' +
'<td>' + roleBadge + '</td>' + '<td>' + roleBadge + '</td>' +
'<td>' + status + pwdFlag + '</td>' + '<td>' + status + pwdFlag + '</td>' +
'<td>' + esc(u.loginCount) + '</td>' + '<td>' + esc(u.loginCount) + '</td>' +
@ -316,6 +341,9 @@
function userActions(u) { function userActions(u) {
const isSelf = state.currentUser && state.currentUser.id === u.id; const isSelf = state.currentUser && state.currentUser.id === u.id;
if (u.protected) {
return '<span class="hint-sm">受保护账号,不可被修改</span>';
}
const btns = []; const btns = [];
btns.push('<button class="btn" data-act="pwd" data-id="' + esc(u.id) + '" data-name="' + esc(u.username) + '">重置密码</button>'); btns.push('<button class="btn" data-act="pwd" data-id="' + esc(u.id) + '" data-name="' + esc(u.username) + '">重置密码</button>');
btns.push('<button class="btn" data-act="role" data-id="' + esc(u.id) + '" data-role="' + btns.push('<button class="btn" data-act="role" data-id="' + esc(u.id) + '" data-role="' +
@ -435,6 +463,144 @@
}); });
} }
/* ============================================================
用户分组
============================================================ */
let editingGroupId = null;
let memberGroupId = null;
let memberSelected = {};
let memberCandidates = [];
function loadGroups() {
API.groups().then(function (d) {
state.groups = d.groups || [];
renderGroups();
}).catch(handleErr);
}
function renderGroups() {
const rows = state.groups || [];
$('groupRows').innerHTML = rows.length ? rows.map(function (g) {
return '<tr>' +
'<td>' + esc(g.name) + '</td>' +
'<td>' + esc(g.description || '—') + '</td>' +
'<td>' + esc(g.memberCount) + '</td>' +
'<td>' + esc(fmtTime(g.createdAt)) + '</td>' +
'<td class="actions">' +
'<button class="btn" data-act="members" data-id="' + esc(g.id) + '" data-name="' + esc(g.name) + '">管理成员</button>' +
'<button class="btn" data-act="edit" data-id="' + esc(g.id) + '" data-name="' + esc(g.name) + '" data-desc="' + esc(g.description || '') + '">编辑</button>' +
'<button class="btn danger" data-act="del" data-id="' + esc(g.id) + '" data-name="' + esc(g.name) + '">删除</button>' +
'</td>' +
'</tr>';
}).join('') : '<tr><td colspan="5" class="empty-row">暂无分组</td></tr>';
}
function onGroupRowClick(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 === 'edit') {
openGroupDialog(id, b.getAttribute('data-name'), b.getAttribute('data-desc'));
} else if (act === 'del') {
uiConfirm('确认删除分组「' + b.getAttribute('data-name') + '」?该分组将从所有共享设置中移除。',
{ title: '删除分组', danger: true, okText: '删除' }).then(function (ok) {
if (!ok) { return; }
API.deleteGroup(id).then(function () {
toast('分组已删除', 'ok'); loadGroups();
}).catch(handleErr);
});
} else if (act === 'members') {
openMemberDialog(id, b.getAttribute('data-name'));
}
}
function openGroupDialog(id, name, desc) {
editingGroupId = id || null;
$('groupDlgTitle').textContent = id ? '编辑分组' : '新建分组';
$('gName').value = name || '';
$('gDesc').value = desc || '';
$('groupErr').textContent = '';
openOverlay('groupOverlay');
setTimeout(function () { $('gName').focus(); $('gName').select(); }, 30);
}
function submitGroupDialog() {
const name = $('gName').value.trim();
const desc = $('gDesc').value.trim();
if (!name) { $('groupErr').textContent = '请输入分组名称'; return; }
$('btnSaveGroup').disabled = true;
const req = editingGroupId
? API.updateGroup(editingGroupId, { name: name, description: desc })
: API.createGroup({ name: name, description: desc });
req.then(function () {
$('btnSaveGroup').disabled = false;
closeOverlay('groupOverlay');
toast(editingGroupId ? '分组已更新' : '分组已创建', 'ok');
editingGroupId = null;
loadGroups();
}).catch(function (err) {
$('btnSaveGroup').disabled = false;
$('groupErr').textContent = (err && err.message) || '保存失败';
});
}
function openMemberDialog(id, name) {
memberGroupId = id;
memberSelected = {};
memberCandidates = [];
$('memberDlgTitle').textContent = '管理成员';
$('memberMsg').textContent = '为分组「' + name + '」选择成员,保存后该分组的共享将对选中成员生效。';
$('memberFilter').value = '';
$('memberErr').textContent = '';
$('memberList').innerHTML = '<div class="empty-row">加载中…</div>';
openOverlay('memberOverlay');
Promise.all([API.listUsers({ page: 1, pageSize: 100 }), API.groupMembers(id)]).then(function (res) {
memberCandidates = res[0].users || [];
(res[1].members || []).forEach(function (m) { memberSelected[m.id] = true; });
renderMemberList();
}).catch(function (err) {
$('memberList').innerHTML = '<div class="empty-row">' + esc((err && err.message) || '加载失败') + '</div>';
});
}
function renderMemberList() {
const q = ($('memberFilter').value || '').trim().toLowerCase();
const list = memberCandidates.filter(function (u) {
return !q || (u.username || '').toLowerCase().indexOf(q) >= 0;
});
$('memberList').innerHTML = list.length ? list.map(function (u) {
return '<label class="share-item">' +
'<input type="checkbox" class="member-chk" data-id="' + esc(u.id) + '"' + (memberSelected[u.id] ? ' checked' : '') + '>' +
'<span class="share-name">' + esc(u.username) + '</span>' +
(u.role === 'admin' ? '<span class="badge owner">管理员</span>' : '') +
'</label>';
}).join('') : '<div class="empty-row">无匹配用户</div>';
}
function onMemberListChange(e) {
const t = e.target;
if (!t.classList || !t.classList.contains('member-chk')) { return; }
const id = t.getAttribute('data-id');
if (t.checked) { memberSelected[id] = true; } else { delete memberSelected[id]; }
}
function submitMemberDialog() {
if (!memberGroupId) { return; }
const ids = Object.keys(memberSelected);
$('btnSaveMember').disabled = true;
API.setGroupMembers(memberGroupId, ids).then(function () {
$('btnSaveMember').disabled = false;
closeOverlay('memberOverlay');
toast('成员已保存(共 ' + ids.length + ' 人)', 'ok');
memberGroupId = null;
loadGroups();
}).catch(function (err) {
$('btnSaveMember').disabled = false;
$('memberErr').textContent = (err && err.message) || '保存失败';
});
}
/* ============================================================ /* ============================================================
拓扑管理 拓扑管理
============================================================ */ ============================================================ */
@ -725,6 +891,26 @@
$('userRows').addEventListener('click', onUserRowClick); $('userRows').addEventListener('click', onUserRowClick);
$('topoRows').addEventListener('click', onTopoRowClick); $('topoRows').addEventListener('click', onTopoRowClick);
Array.prototype.forEach.call(document.querySelectorAll('#userTabs .tab'), function (t) {
t.addEventListener('click', function () {
state.usersTab = t.getAttribute('data-tab');
loadUserTab();
});
});
$('btnNewGroup').addEventListener('click', function () { openGroupDialog(null, '', ''); });
$('btnSaveGroup').addEventListener('click', submitGroupDialog);
$('btnCancelGroup').addEventListener('click', function () { closeOverlay('groupOverlay'); });
$('btnCloseGroup').addEventListener('click', function () { closeOverlay('groupOverlay'); });
$('gName').addEventListener('keydown', function (e) {
if (e.key === 'Enter') { e.preventDefault(); submitGroupDialog(); }
});
$('groupRows').addEventListener('click', onGroupRowClick);
$('btnSaveMember').addEventListener('click', submitMemberDialog);
$('btnCancelMember').addEventListener('click', function () { closeOverlay('memberOverlay'); });
$('btnCloseMember').addEventListener('click', function () { closeOverlay('memberOverlay'); });
$('memberList').addEventListener('change', onMemberListChange);
$('memberFilter').addEventListener('input', renderMemberList);
$('btnNewUser').addEventListener('click', openUserDialog); $('btnNewUser').addEventListener('click', openUserDialog);
$('btnSaveUser').addEventListener('click', submitUserDialog); $('btnSaveUser').addEventListener('click', submitUserDialog);
$('btnCancelUser').addEventListener('click', function () { closeOverlay('userOverlay'); }); $('btnCancelUser').addEventListener('click', function () { closeOverlay('userOverlay'); });

View File

@ -39,6 +39,7 @@ let currentUser = null; // {id,username,role,...} 或 null
let topoId = null; // 当前打开的拓扑 ID let topoId = null; // 当前打开的拓扑 ID
let topoName = ''; // 当前拓扑名称 let topoName = ''; // 当前拓扑名称
let topoCanEdit = false; // 当前拓扑是否可写 let topoCanEdit = false; // 当前拓扑是否可写
let topoPermission = 'owner'; // 当前用户对当前拓扑的权限:owner|edit|view
let readOnly = false; // 登录但无写权限(他人公开拓扑) let readOnly = false; // 登录但无写权限(他人公开拓扑)
let topoScope = 'mine'; // 我的拓扑面板范围:'mine'(当前用户) | 'public'(公开) let topoScope = 'mine'; // 我的拓扑面板范围:'mine'(当前用户) | 'public'(公开)
let dirty = false; let dirty = false;
@ -49,6 +50,12 @@ let lastSavedAt = null;
let loginMode = 'login'; // 'login' | 'register' let loginMode = 'login'; // 'login' | 'register'
let siteAllowRegistration = true; // 站点是否开放注册(由后端设置控制) let siteAllowRegistration = true; // 站点是否开放注册(由后端设置控制)
/* 协同:版本号 / 基线数据 / 连接态 */
let baseRev = 0; // 已同步的服务端版本号
let baseData = null; // 最近一次同步的基线数据 {nodes,edges}
let collabStarted = false;
const collab = { es: null, pollTimer: null, errCount: 0, present: [], topoId: null };
/* ============================================================ /* ============================================================
DOM DOM
============================================================ */ ============================================================ */
@ -112,6 +119,74 @@ function fmtTime(iso) {
return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
} }
/* 稳定序列化(忽略键顺序,用于并发合并的相等比较) */
function stableStr(o) {
if (o === null || o === undefined || typeof o !== 'object') {
return JSON.stringify(o === undefined ? null : o);
}
if (Array.isArray(o)) {
return '[' + o.map(stableStr).join(',') + ']';
}
return '{' + Object.keys(o).sort().map(function (k) {
return JSON.stringify(k) + ':' + stableStr(o[k]);
}).join(',') + '}';
}
/* 当前画布数据快照(深拷贝) */
function snapData() {
return {
nodes: JSON.parse(JSON.stringify(state.nodes)),
edges: JSON.parse(JSON.stringify(state.edges))
};
}
function byId(arr) {
const m = new Map();
(arr || []).forEach(function (x) {
if (x && x.id !== undefined && x.id !== null) m.set(String(x.id), x);
});
return m;
}
/* 按 id 合并单个列表:base 基线 / local 本地 / remote 远端 */
function mergeList(baseArr, localArr, remoteArr) {
const b = byId(baseArr), r = byId(remoteArr);
const out = [];
const used = new Set();
let conflicts = 0;
(localArr || []).forEach(function (item) {
const id = String(item.id);
used.add(id);
if (!b.has(id)) { out.push(item); return; } // 本地新增
const bv = b.get(id);
const lChanged = stableStr(item) !== stableStr(bv);
if (!r.has(id)) {
if (lChanged) { out.push(item); conflicts++; } // 本地改 / 远端删 → 保留本地
return; // 否则删除
}
const rv = r.get(id);
const rChanged = stableStr(rv) !== stableStr(bv);
if (rChanged && !lChanged) { out.push(rv); return; } // 仅远端改 → 取远端
out.push(item); // 本地改或未改 → 本地优先
if (rChanged && lChanged && stableStr(item) !== stableStr(rv)) { conflicts++; }
});
(remoteArr || []).forEach(function (item) {
const id = String(item.id);
if (used.has(id) || b.has(id)) return; // 已处理 / 本地已删 (尊重本地删除)
out.push(item); // 远端新增
});
return { items: out, conflicts: conflicts };
}
function mergeThreeWay(base, local, remote) {
const nodes = mergeList(base && base.nodes, local.nodes, remote.nodes);
const edges = mergeList(base && base.edges, local.edges, remote.edges);
return { nodes: nodes.items, edges: edges.items, conflicts: nodes.conflicts + edges.conflicts };
}
const _mc = document.createElement('canvas').getContext('2d'); const _mc = document.createElement('canvas').getContext('2d');
function textWidth(text, font) { function textWidth(text, font) {
_mc.font = font; _mc.font = font;
@ -789,7 +864,7 @@ document.getElementById('btnDelete').addEventListener('click', function () {
/* 根据权限启用/禁用编辑相关控件 */ /* 根据权限启用/禁用编辑相关控件 */
function updateEditability() { function updateEditability() {
const ok = canEdit(); const ok = canEdit();
['btnAdd','btnUndo','btnAuto','btnClear','btnAddChild','btnDelete'].forEach(id => { ['btnUndo','btnAuto','btnClear','btnAddChild','btnDelete'].forEach(id => {
const b = document.getElementById(id); const b = document.getElementById(id);
if (b) b.disabled = !ok; if (b) b.disabled = !ok;
}); });
@ -801,7 +876,6 @@ function updateEditability() {
/* ============================================================ /* ============================================================
工具栏 工具栏
============================================================ */ ============================================================ */
document.getElementById('btnAdd').addEventListener('click', addNodeAtCenter);
btnUndo.addEventListener('click', undo); btnUndo.addEventListener('click', undo);
document.getElementById('btnFit').addEventListener('click', fitView); document.getElementById('btnFit').addEventListener('click', fitView);
@ -942,6 +1016,7 @@ function onDataChanged() {
} }
function updateSaveState() { function updateSaveState() {
updateTopoCurrent();
if (!saveStateEl) return; if (!saveStateEl) return;
saveStateEl.className = 'save-state'; saveStateEl.className = 'save-state';
if (!currentUser) { saveStateEl.textContent = ''; return; } if (!currentUser) { saveStateEl.textContent = ''; return; }
@ -953,6 +1028,19 @@ function updateSaveState() {
saveStateEl.classList.add('ok'); saveStateEl.classList.add('ok');
} }
/* 右下角状态区:显示当前正在编辑的拓扑名称与脏标记 */
function updateTopoCurrent() {
const box = document.getElementById('topoCurrent');
if (!box) return;
const nameEl = box.querySelector('.tc-name');
let text;
if (!currentUser) text = '未登录';
else if (topoName) text = topoName;
else text = '未保存的新拓扑';
if (nameEl) { nameEl.textContent = text; nameEl.title = text; }
box.classList.toggle('dirty', !!dirty);
}
function scheduleSave() { function scheduleSave() {
// 仅在已绑定拓扑时自动保存;未绑定拓扑需手动点「保存」触发新建 // 仅在已绑定拓扑时自动保存;未绑定拓扑需手动点「保存」触发新建
if (!canSave() || !topoId) return; if (!canSave() || !topoId) return;
@ -973,7 +1061,7 @@ function saveNow(silent) {
let p; let p;
if (topoId && topoCanEdit) { if (topoId && topoCanEdit) {
p = API.saveTopo(topoId, { nodes: state.nodes, edges: state.edges, name: topoName }); p = API.saveTopo(topoId, { nodes: state.nodes, edges: state.edges, name: topoName, baseRev: baseRev });
} else { } else {
const name = (topoName && topoName.trim()) ? topoName.trim() : defaultTopoName(); const name = (topoName && topoName.trim()) ? topoName.trim() : defaultTopoName();
p = API.createTopo({ name: name, nodes: state.nodes, edges: state.edges }); p = API.createTopo({ name: name, nodes: state.nodes, edges: state.edges });
@ -985,12 +1073,21 @@ function saveNow(silent) {
topoName = r.topology.name; topoName = r.topology.name;
topoCanEdit = true; topoCanEdit = true;
readOnly = false; readOnly = false;
topoPermission = 'owner';
lastSavedAt = r.topology.updatedAt; lastSavedAt = r.topology.updatedAt;
if (typeof r.topology.rev === 'number') baseRev = r.topology.rev;
try { localStorage.setItem(LAST_TOPO_KEY, topoId); } catch (e) {} try { localStorage.setItem(LAST_TOPO_KEY, topoId); } catch (e) {}
} }
baseData = snapData();
dirty = false; dirty = false;
if (!silent) toast('已保存:' + topoName, 'ok'); if (!silent) toast('已保存:' + topoName, 'ok');
updateShareEntry();
startCollab();
}).catch(function (e) { }).catch(function (e) {
if (e && e.conflict) {
handleConflict(e);
return;
}
saveStateEl.textContent = '保存失败'; saveStateEl.textContent = '保存失败';
saveStateEl.className = 'save-state err'; saveStateEl.className = 'save-state err';
if (!silent) toastErr('保存失败:' + e.message); if (!silent) toastErr('保存失败:' + e.message);
@ -1002,6 +1099,281 @@ function saveNow(silent) {
}); });
} }
/* ============================================================
协同:SSE 推送 + 在线成员 + 三方合并
============================================================ */
const COLLAB_MAX_ES_ERRORS = 2;
function collabActive() { return !!currentUser && !!topoId; }
function startCollab() {
if (!collabActive()) { renderPresence([]); return; }
if (collabStarted && collab.topoId === topoId) return;
stopCollab();
collab.topoId = topoId;
collabStarted = true;
collab.errCount = 0;
connectStream();
}
function stopCollab() {
collabStarted = false;
collab.topoId = null;
if (collab.es) { try { collab.es.close(); } catch (e) {} collab.es = null; }
if (collab.pollTimer) { clearInterval(collab.pollTimer); collab.pollTimer = null; }
renderPresence([]);
}
function connectStream() {
if (!collabActive()) return;
if (typeof window.EventSource !== 'function') { startPolling(); return; }
if (collab.es) { try { collab.es.close(); } catch (e) {} collab.es = null; }
if (collab.pollTimer) { clearInterval(collab.pollTimer); collab.pollTimer = null; }
const url = 'index.php?r=' + encodeURIComponent('/api/topologies/' + encodeURIComponent(topoId) + '/stream') + '&since=' + baseRev;
let es;
try { es = new EventSource(url); } catch (e) { startPolling(); return; }
collab.es = es;
es.addEventListener('revision', function (ev) {
let d = {};
try { d = JSON.parse(ev.data); } catch (e) {}
onRemoteRevision(d.rev);
});
es.addEventListener('presence', function (ev) {
let d = {};
try { d = JSON.parse(ev.data); } catch (e) {}
renderPresence(d.users || []);
});
es.addEventListener('gone', function () {
if (collab.es === es) stopCollab();
});
es.addEventListener('bye', function () {
if (collab.es === es) { try { es.close(); } catch (e) {} collab.es = null; }
setTimeout(function () { if (collabStarted) connectStream(); }, 300);
});
es.onerror = function () {
if (collab.es !== es) return; // 已被 bye / stopCollab 接管,避免重复连接
if (es.readyState === 2) { // CLOSED:无法建立连接,降级轮询
collab.es = null;
collab.errCount++;
if (collab.errCount >= COLLAB_MAX_ES_ERRORS) { startPolling(); }
else { setTimeout(function () { if (collabStarted) connectStream(); }, 1200); }
}
};
}
function startPolling() {
if (!collabActive()) return;
if (collab.es) { try { collab.es.close(); } catch (e) {} collab.es = null; }
if (collab.pollTimer) clearInterval(collab.pollTimer);
const tick = function () {
if (!collabActive()) return;
API.req('GET', '/topologies/' + encodeURIComponent(topoId) + '/poll?since=' + baseRev).then(function (d) {
if (d.presence) renderPresence(d.presence);
if (d.changed && typeof d.rev === 'number' && d.rev !== baseRev) onRemoteRevision(d.rev);
}).catch(function () {});
};
tick();
collab.pollTimer = setInterval(tick, 4000);
}
function onRemoteRevision(rev) {
if (!collabActive() || typeof rev !== 'number') return;
if (rev === baseRev) return;
if (!topoCanEdit || readOnly) { reloadRemote(); return; }
if (dirty) {
// 本地有未保存改动:先保存,由版本冲突触发合并,避免覆盖他人
saveNow(true);
} else {
reloadRemote();
}
}
function reloadRemote() {
if (!topoId) return Promise.resolve();
return API.getTopo(topoId).then(function (r) {
if (!r || !r.topology) return;
loading = true;
topoName = r.topology.name;
topoCanEdit = !!r.topology.canEdit;
readOnly = !topoCanEdit;
topoPermission = r.topology.permission || (topoCanEdit ? 'edit' : 'view');
if (typeof r.topology.rev === 'number') baseRev = r.topology.rev;
loadData(r.data, false);
loading = false;
baseData = snapData();
history = [];
dirty = false;
updateBanner(); updateEditability(); updateSaveState(); updateUndoBtn(); updateShareEntry();
toast('已同步其他协作者的改动', 'ok');
}).catch(function () {});
}
function handleConflict(e) {
if (!topoCanEdit) return;
const d = e.data || {};
const remoteData = d.data || { nodes: [], edges: [] };
const remoteRev = (typeof d.rev === 'number') ? d.rev : (baseRev + 1);
const merged = mergeThreeWay(baseData, { nodes: state.nodes, edges: state.edges }, remoteData);
loading = true;
state.nodes = merged.nodes;
state.edges = merged.edges;
loading = false;
baseRev = remoteRev;
baseData = { nodes: remoteData.nodes || [], edges: remoteData.edges || [] };
render();
syncInspector();
dirty = true;
updateSaveState();
if (merged.conflicts > 0) {
toast('检测到 ' + merged.conflicts + ' 处并发修改,已自动合并并重新保存', 'warn');
} else {
toast('已合并其他协作者的改动', 'ok');
}
setTimeout(function () { saveNow(true); }, 80);
}
function renderPresence(users) {
collab.present = users || [];
const bar = document.getElementById('collabBar');
if (!bar) return;
if (!users || !users.length) { bar.hidden = true; bar.innerHTML = ''; return; }
bar.hidden = false;
const chips = users.slice(0, 6).map(function (u) {
const initial = esc((u.username || '?').slice(0, 1).toUpperCase());
return '<span class="collab-chip' + (u.self ? ' self' : '') + '" title="' + esc(u.username) + '">' +
'<i class="av">' + initial + '</i><span class="nm">' + esc(u.username) + (u.self ? '(我)' : '') + '</span></span>';
}).join('');
const more = users.length > 6 ? '<span class="collab-more">+' + (users.length - 6) + '</span>' : '';
bar.innerHTML = '<span class="collab-title"><i class="dot"></i>' + users.length + ' 人在线</span>' + chips + more;
}
function sendPresenceLeave() {
if (!currentUser || !topoId) return;
const url = 'index.php?r=' + encodeURIComponent('/api/topologies/' + encodeURIComponent(topoId) + '/presence');
const body = JSON.stringify({ leave: true });
try {
if (navigator.sendBeacon) {
navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }));
return;
}
} catch (e) {}
try {
fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: body, keepalive: true });
} catch (e) {}
}
/* ============================================================
共享与协作弹窗
============================================================ */
let shareCandidates = { users: [], groups: [] };
let shareSelected = {};
function updateShareEntry() {
const b = document.getElementById('btnShare');
if (!b) return;
b.hidden = !(currentUser && topoId && topoPermission === 'owner');
}
function shareRow(key, name, sel) {
return '<label class="share-item">' +
'<input type="checkbox" class="share-chk" data-key="' + esc(key) + '"' + (sel ? ' checked' : '') + '>' +
'<span class="share-name">' + esc(name) + '</span>' +
'<select class="share-perm" data-key="' + esc(key) + '">' +
'<option value="edit"' + (sel && sel.permission === 'edit' ? ' selected' : '') + '>可编辑</option>' +
'<option value="view"' + (sel && sel.permission === 'view' ? ' selected' : '') + '>只读</option>' +
'</select></label>';
}
function renderShareLists() {
const uf = (document.getElementById('shareUserFilter').value || '').trim().toLowerCase();
const gf = (document.getElementById('shareGroupFilter').value || '').trim().toLowerCase();
const myId = currentUser ? currentUser.id : '';
const ul = document.getElementById('shareUserList');
const gl = document.getElementById('shareGroupList');
const users = shareCandidates.users.filter(function (u) {
if (u.id === myId) return false;
return !uf || (u.username || '').toLowerCase().indexOf(uf) >= 0;
});
ul.innerHTML = users.length ? users.map(function (u) {
return shareRow('user:' + u.id, u.username, shareSelected['user:' + u.id]);
}).join('') : '<div class="empty">无匹配成员</div>';
const groups = shareCandidates.groups.filter(function (g) {
return !gf || (g.name || '').toLowerCase().indexOf(gf) >= 0;
});
gl.innerHTML = groups.length ? groups.map(function (g) {
return shareRow('group:' + g.id, g.name, shareSelected['group:' + g.id]);
}).join('') : '<div class="empty">暂无分组</div>';
}
function openShareDialog() {
if (!currentUser) { openLogin(); return; }
if (!topoId) { toast('请先保存拓扑后再共享', 'warn'); return; }
if (topoPermission !== 'owner') { toast('仅创建者可管理共享', 'warn'); return; }
document.getElementById('shareErr').textContent = '';
document.getElementById('shareUserFilter').value = '';
document.getElementById('shareGroupFilter').value = '';
Promise.all([API.directory(), API.topoShares(topoId)]).then(function (res) {
const dir = res[0] || {};
const cur = res[1] || {};
shareCandidates = { users: dir.users || [], groups: dir.groups || [] };
shareSelected = {};
(cur.shares || []).forEach(function (s) {
shareSelected[s.targetType + ':' + s.targetId] = { type: s.targetType, id: s.targetId, permission: s.permission };
});
document.getElementById('shareMode').checked = !!cur.shareMode;
document.getElementById('shareMsg').textContent =
'开启共享模式后,选中的成员 / 分组可访问该拓扑,并可选择「可编辑」进行协同修改。';
renderShareLists();
openOverlay('shareOverlay');
}).catch(function (e) { toastErr(e.message); });
}
function onShareListChange(e) {
const t = e.target;
const key = t.getAttribute && t.getAttribute('data-key');
if (!key) return;
const parts = key.split(':');
if (t.classList.contains('share-chk')) {
if (t.checked) {
const sel = t.parentNode.querySelector('.share-perm');
shareSelected[key] = { type: parts[0], id: parts[1], permission: sel ? sel.value : 'edit' };
} else {
delete shareSelected[key];
}
} else if (t.classList.contains('share-perm')) {
if (shareSelected[key]) {
shareSelected[key].permission = t.value;
} else {
const chk = t.parentNode.querySelector('.share-chk');
if (chk) chk.checked = true;
shareSelected[key] = { type: parts[0], id: parts[1], permission: t.value };
}
}
}
function saveShare() {
if (!topoId) return;
const shareMode = document.getElementById('shareMode').checked;
const shares = Object.keys(shareSelected).map(function (k) {
const s = shareSelected[k];
return { targetType: s.type, targetId: s.id, permission: s.permission };
});
const btn = document.getElementById('btnSaveShare');
btn.disabled = true;
API.setShares(topoId, { shareMode: shareMode, shares: shares }).then(function () {
btn.disabled = false;
closeOverlay('shareOverlay');
toast(shareMode ? ('已开启共享,共 ' + shares.length + ' 项') : '已关闭共享模式', 'ok');
}).catch(function (e) {
btn.disabled = false;
document.getElementById('shareErr').textContent = e.message;
});
}
/* ============================================================ /* ============================================================
轻提示 轻提示
============================================================ */ ============================================================ */
@ -1048,6 +1420,8 @@ const API = {
const msg = (data && data.error) ? data.error : ('请求失败 (' + r.status + ')'); const msg = (data && data.error) ? data.error : ('请求失败 (' + r.status + ')');
const err = new Error(msg); const err = new Error(msg);
err.status = r.status; err.status = r.status;
err.data = data;
err.conflict = !!(data && data.conflict);
throw err; throw err;
} }
return data || {}; return data || {};
@ -1073,7 +1447,11 @@ const API = {
deleteUser: function (id) { return this.req('DELETE', '/users/' + encodeURIComponent(id)); }, deleteUser: function (id) { return this.req('DELETE', '/users/' + encodeURIComponent(id)); },
dashboard: function () { return this.req('GET', '/dashboard'); }, dashboard: function () { return this.req('GET', '/dashboard'); },
logs: function (p) { return this.req('GET', '/logs' + qs(p)); }, logs: function (p) { return this.req('GET', '/logs' + qs(p)); },
adminTopologies: function (p) { return this.req('GET', '/admin/topologies' + qs(p)); } adminTopologies: function (p) { return this.req('GET', '/admin/topologies' + qs(p)); },
directory: function () { return this.req('GET', '/directory'); },
overview: function () { return this.req('GET', '/overview'); },
topoShares: function (id) { return this.req('GET', '/topologies/' + encodeURIComponent(id) + '/shares'); },
setShares: function (id, p) { return this.req('PUT', '/topologies/' + encodeURIComponent(id) + '/shares', p); }
}; };
/* ============================================================ /* ============================================================
@ -1304,6 +1682,7 @@ function onUserChanged() {
currentUser.username + ' · ' + (currentUser.role === 'admin' ? '管理员' : '普通用户'); currentUser.username + ' · ' + (currentUser.role === 'admin' ? '管理员' : '普通用户');
} else { } else {
btnUserEl.textContent = '登录'; btnUserEl.textContent = '登录';
stopCollab();
} }
document.getElementById('userMenu').hidden = true; document.getElementById('userMenu').hidden = true;
updateAdminEntry(); updateAdminEntry();
@ -1311,6 +1690,7 @@ function onUserChanged() {
updateEditability(); updateEditability();
updateSaveState(); updateSaveState();
updateUndoBtn(); updateUndoBtn();
updateShareEntry();
} }
function updateAdminEntry() { function updateAdminEntry() {
@ -1365,24 +1745,32 @@ function openTopology(id) {
topoName = r.topology.name; topoName = r.topology.name;
topoCanEdit = !!r.topology.canEdit; topoCanEdit = !!r.topology.canEdit;
readOnly = !topoCanEdit; readOnly = !topoCanEdit;
topoPermission = r.topology.permission || (topoCanEdit ? 'edit' : 'view');
baseRev = (typeof r.topology.rev === 'number') ? r.topology.rev : 0;
history = []; history = [];
dirty = false; dirty = false;
loadData(r.data, false); loadData(r.data, false);
baseData = snapData();
loading = false; loading = false;
try { localStorage.setItem(LAST_TOPO_KEY, topoId); } catch (e) {} try { localStorage.setItem(LAST_TOPO_KEY, topoId); } catch (e) {}
updateBanner(); updateEditability(); updateSaveState(); updateUndoBtn(); updateBanner(); updateEditability(); updateSaveState(); updateUndoBtn(); updateShareEntry();
closeOverlay('topoOverlay'); closeOverlay('topoOverlay');
requestAnimationFrame(fitView); requestAnimationFrame(fitView);
startCollab();
toast('已打开:' + topoName + (readOnly ? '(只读)' : ''), 'ok'); toast('已打开:' + topoName + (readOnly ? '(只读)' : ''), 'ok');
}).catch(function (e) { toastErr(e.message); }); }).catch(function (e) { toastErr(e.message); });
} }
function loadDraft() { function loadDraft() {
stopCollab();
loading = true; loading = true;
topoId = null; topoId = null;
topoName = ''; topoName = '';
topoCanEdit = true; topoCanEdit = true;
readOnly = false; readOnly = false;
topoPermission = 'owner';
baseRev = 0;
baseData = null;
state.nodes = []; state.nodes = [];
state.edges = []; state.edges = [];
state.view = { tx: 0, ty: 0, s: 1 }; state.view = { tx: 0, ty: 0, s: 1 };
@ -1391,16 +1779,20 @@ function loadDraft() {
dirty = false; dirty = false;
loadData(demoData(), false); loadData(demoData(), false);
loading = false; loading = false;
updateBanner(); updateEditability(); updateSaveState(); updateUndoBtn(); updateBanner(); updateEditability(); updateSaveState(); updateUndoBtn(); updateShareEntry();
requestAnimationFrame(fitView); requestAnimationFrame(fitView);
} }
function startNewTopology() { function startNewTopology() {
stopCollab();
loading = true; loading = true;
topoId = null; topoId = null;
topoName = ''; topoName = '';
topoCanEdit = true; topoCanEdit = true;
readOnly = false; readOnly = false;
topoPermission = 'owner';
baseRev = 0;
baseData = null;
state.nodes = []; state.nodes = [];
state.edges = []; state.edges = [];
state.view = { tx: 0, ty: 0, s: 1 }; state.view = { tx: 0, ty: 0, s: 1 };
@ -1410,7 +1802,7 @@ function startNewTopology() {
loading = false; loading = false;
render(); render();
syncInspector(); syncInspector();
updateBanner(); updateEditability(); updateSaveState(); updateUndoBtn(); updateBanner(); updateEditability(); updateSaveState(); updateUndoBtn(); updateShareEntry();
} }
/* ============================================================ /* ============================================================
@ -1464,14 +1856,18 @@ function refreshTopoList() {
API.listTopos().then(function (r) { API.listTopos().then(function (r) {
const all = r.topologies || []; const all = r.topologies || [];
const uid = currentUser ? currentUser.id : ''; const uid = currentUser ? currentUser.id : '';
const list = (topoScope === 'public') const list = (topoScope === 'shared')
? all.filter(function (t) { return t.visibility === 'public'; }) ? all.filter(function (t) { return t.sharedWithMe; })
: all.filter(function (t) { return t.ownerId === uid; }); : (topoScope === 'public')
? all.filter(function (t) { return t.visibility === 'public'; })
: all.filter(function (t) { return t.ownerId === uid; });
box.innerHTML = ''; box.innerHTML = '';
if (!list.length) { if (!list.length) {
box.innerHTML = '<div class="empty">' + box.innerHTML = '<div class="empty">' +
(topoScope === 'public' ? '暂无公开拓扑。' : '还没有拓扑,输入名称点击「新建」开始。') + (topoScope === 'shared' ? '暂无共享给你的拓扑。'
: topoScope === 'public' ? '暂无公开拓扑。'
: '还没有拓扑,输入名称点击「新建」开始。') +
'</div>'; '</div>';
return; return;
} }
@ -1480,9 +1876,13 @@ function refreshTopoList() {
cls: t.visibility === 'public' ? 'public' : 'private', cls: t.visibility === 'public' ? 'public' : 'private',
text: t.visibility === 'public' ? '公开' : '私有' text: t.visibility === 'public' ? '公开' : '私有'
}]; }];
if (!t.canEdit) { badges.push({ cls: 'ro', text: '只读' }); } if (t.sharedWithMe) {
badges.push({ cls: 'owner', text: t.permission === 'edit' ? '共享·可编辑' : '共享·只读' });
} else if (!t.canEdit) {
badges.push({ cls: 'ro', text: '只读' });
}
const actions = [{ text: '打开', primary: true, fn: function () { openTopology(t.id); } }]; const actions = [{ text: '打开', primary: true, fn: function () { openTopology(t.id); } }];
if (t.canEdit) { if (t.ownerId === uid) {
actions.push({ text: '重命名', fn: function () { renameTopo(t.id, t.name); } }); 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: t.visibility === 'public' ? '设为私有' : '设为公开', fn: function () { toggleVisibility(t.id, t.visibility); } });
actions.push({ text: '删除', danger: true, fn: function () { deleteTopo(t.id, t.name); } }); actions.push({ text: '删除', danger: true, fn: function () { deleteTopo(t.id, t.name); } });
@ -1530,7 +1930,7 @@ function renameTopo(id, curName, cb) {
if (v === curName) return; if (v === curName) return;
API.renameTopo(id, v).then(function () { API.renameTopo(id, v).then(function () {
toast('已重命名', 'ok'); toast('已重命名', 'ok');
if (topoId === id) { topoName = v; updateBanner(); } if (topoId === id) { topoName = v; updateBanner(); updateTopoCurrent(); }
if (cb) cb(); else refreshTopoList(); if (cb) cb(); else refreshTopoList();
}).catch(function (e) { toastErr(e.message); }); }).catch(function (e) { toastErr(e.message); });
}); });
@ -1988,6 +2388,30 @@ document.querySelectorAll('.overlay').forEach(function (o) {
}); });
}); });
/* ---------- 共享与协作弹窗 ---------- */
document.getElementById('btnShare').addEventListener('click', openShareDialog);
document.getElementById('btnCloseShare').addEventListener('click', function () { closeOverlay('shareOverlay'); });
document.getElementById('btnCancelShare').addEventListener('click', function () { closeOverlay('shareOverlay'); });
document.getElementById('btnSaveShare').addEventListener('click', saveShare);
document.getElementById('shareUserList').addEventListener('change', onShareListChange);
document.getElementById('shareGroupList').addEventListener('change', onShareListChange);
document.getElementById('shareUserFilter').addEventListener('input', function () { renderShareLists(); });
document.getElementById('shareGroupFilter').addEventListener('input', function () { renderShareLists(); });
/* ---------- 检查器分区折叠 ---------- */
Array.prototype.forEach.call(document.querySelectorAll('#inspBody .insp-sec-head'), function (h) {
h.addEventListener('click', function () { h.parentNode.classList.toggle('collapsed'); });
});
/* ---------- 用户菜单:我的工作台 ---------- */
document.getElementById('ddWorkspace').addEventListener('click', function () {
document.getElementById('userMenu').hidden = true;
window.location.href = 'web/user.html';
});
/* 离开页面时告知服务端下线 */
window.addEventListener('beforeunload', function () { sendPresenceLeave(); });
/* ---------- 站内确认 / 输入弹窗 ---------- */ /* ---------- 站内确认 / 输入弹窗 ---------- */
document.getElementById('confirmOk').addEventListener('click', _dlgConfirmOk); document.getElementById('confirmOk').addEventListener('click', _dlgConfirmOk);
document.getElementById('confirmCancel').addEventListener('click', function () { _dlgFinish(null); }); document.getElementById('confirmCancel').addEventListener('click', function () { _dlgFinish(null); });
@ -2003,6 +2427,23 @@ document.addEventListener('keydown', function (e) {
/* ============================================================ /* ============================================================
启动 启动
============================================================ */ ============================================================ */
function pendingOpenId() {
try {
const p = new URLSearchParams(window.location.search);
const id = p.get('topo');
if (id) { return id; }
} catch (e) {}
return null;
}
function pendingShare() {
try {
const p = new URLSearchParams(window.location.search);
return p.get('share') === '1';
} catch (e) {}
return false;
}
function init() { function init() {
updateEditability(); updateEditability();
API.site().then(function (r) { applySite(r.site); }).catch(function () { /* 忽略:回退页面默认值 */ }); API.site().then(function (r) { applySite(r.site); }).catch(function () { /* 忽略:回退页面默认值 */ });
@ -2010,8 +2451,16 @@ function init() {
if (r && r.user) { if (r && r.user) {
currentUser = r.user; currentUser = r.user;
onUserChanged(); onUserChanged();
bootAfterLogin(); const openId = pendingOpenId();
maybeForcePwdChange(); if (openId) {
openTopology(openId).then(function () {
const forced = maybeForcePwdChange();
if (!forced && pendingShare()) { openShareDialog(); }
});
} else {
bootAfterLogin();
maybeForcePwdChange();
}
} else { } else {
onUserChanged(); onUserChanged();
loadDraft(); loadDraft();

View File

@ -11,18 +11,20 @@
<header class="topbar"> <header class="topbar">
<div class="brand"><div class="logo" id="brandLogo">R</div><h1 id="brandName">路由拓扑</h1></div> <div class="brand"><div class="logo" id="brandLogo">R</div><h1 id="brandName">路由拓扑</h1></div>
<div class="tb-group">
<button class="btn" id="btnUndo" disabled title="撤销上一次操作"><span class="lbl">撤销</span></button>
<button class="btn" id="btnAuto" title="自动整理布局"><span class="lbl">自动整理</span></button>
<button class="btn" id="btnFit" title="适应窗口"><span class="lbl">适应窗口</span></button>
</div>
<span class="sep"></span> <span class="sep"></span>
<button class="btn primary" id="btnAdd"> 添加节点</button> <div class="tb-group">
<button class="btn" id="btnUndo" disabled>↶ 撤销</button> <button class="btn" id="btnImport" title="导入拓扑"><span class="lbl">导入</span></button>
<button class="btn" id="btnAuto">⌗ 自动整理</button> <button class="btn" id="btnExport" title="导出拓扑"><span class="lbl">导出</span></button>
<button class="btn" id="btnFit">⤢ 适应窗口</button> <button class="btn danger" id="btnClear" title="清空画布"><span class="lbl">清空</span></button>
<span class="sep"></span> </div>
<button class="btn" id="btnImport">⇩ 导入</button>
<button class="btn" id="btnExport">⇧ 导出</button>
<span class="sep"></span>
<button class="btn danger" id="btnClear">清空</button>
<div class="topbar-right"> <div class="topbar-right">
<span class="save-state" id="saveState"></span> <span class="save-state" id="saveState"></span>
<button class="btn" id="btnShare" title="共享与协作" hidden><span class="lbl">共享</span></button>
<button class="btn primary" id="btnSave">保存</button> <button class="btn primary" id="btnSave">保存</button>
<button class="btn" id="btnMyTopos">我的拓扑</button> <button class="btn" id="btnMyTopos">我的拓扑</button>
<div class="user-wrap"> <div class="user-wrap">
@ -55,29 +57,44 @@
点击图中节点进行编辑 点击图中节点进行编辑
</div> </div>
<div id="inspBody" hidden> <div id="inspBody" hidden>
<label>名称 / 地址 <div class="insp-section" data-sec="basic">
<input id="fLabel" type="text" placeholder="192.168.1.10 / dc.corp.local" spellcheck="false"> <div class="insp-sec-head"><span>基本信息</span><span class="insp-chevron"></span></div>
</label> <div class="insp-sec-body">
<label>类型 <label>名称 / 地址
<select id="fType"> <input id="fLabel" type="text" placeholder="192.168.1.10 / dc.corp.local" spellcheck="false">
<option value="net">网段 (CIDR)</option> </label>
<option value="host">主机 (IP)</option> <label>类型
<option value="domain">域名</option> <select id="fType">
<option value="other">其他</option> <option value="net">网段 (CIDR)</option>
</select> <option value="host">主机 (IP)</option>
</label> <option value="domain">域名</option>
<label>状态 <option value="other">其他</option>
<select id="fStatus"></select> </select>
</label> </label>
<label>端口 / 服务 <label>状态
<input id="fPorts" type="text" placeholder="22, 80, 445 / ssh、smb" spellcheck="false"> <select id="fStatus"></select>
</label> </label>
<label>备注 <label>端口 / 服务
<textarea id="fNote" placeholder="凭据、漏洞、横向思路…"></textarea> <input id="fPorts" type="text" placeholder="22, 80, 445 / ssh、smb" spellcheck="false">
</label> </label>
<div class="btn-row"> </div>
<button class="btn" id="btnAddChild"> 子节点</button> </div>
<button class="btn danger" id="btnDelete">删除节点</button> <div class="insp-section" data-sec="note">
<div class="insp-sec-head"><span>备注</span><span class="insp-chevron"></span></div>
<div class="insp-sec-body">
<label>备注
<textarea id="fNote" placeholder="凭据、漏洞、横向思路…"></textarea>
</label>
</div>
</div>
<div class="insp-section" data-sec="actions">
<div class="insp-sec-head"><span>操作</span><span class="insp-chevron"></span></div>
<div class="insp-sec-body">
<div class="btn-row">
<button class="btn" id="btnAddChild"> 子节点</button>
<button class="btn danger" id="btnDelete">删除节点</button>
</div>
</div>
</div> </div>
<div class="meta" id="inspMeta"></div> <div class="meta" id="inspMeta"></div>
</div> </div>
@ -120,12 +137,41 @@
</div> </div>
<div class="seg" id="topoScope"> <div class="seg" id="topoScope">
<button class="seg-btn active" type="button" data-scope="mine">我的拓扑</button> <button class="seg-btn active" type="button" data-scope="mine">我的拓扑</button>
<button class="seg-btn" type="button" data-scope="shared">共享拓扑</button>
<button class="seg-btn" type="button" data-scope="public">公开拓扑</button> <button class="seg-btn" type="button" data-scope="public">公开拓扑</button>
</div> </div>
<div class="topo-list" id="topoList"></div> <div class="topo-list" id="topoList"></div>
</div> </div>
</div> </div>
<!-- ================= 共享与协作 ================= -->
<div class="overlay" id="shareOverlay" hidden>
<div class="dialog wide share-dialog">
<div class="dlg-head"><h2>共享与协作</h2><span class="dlg-close" id="btnCloseShare">×</span></div>
<div class="dlg-msg" id="shareMsg"></div>
<label class="set-row">
<input type="checkbox" id="shareMode"> <span>开启共享模式(允许以下成员 / 分组访问与协同编辑)</span>
</label>
<div class="share-cols">
<div class="share-col">
<div class="share-col-title">成员</div>
<input class="search" id="shareUserFilter" placeholder="搜索成员" spellcheck="false">
<div class="share-list" id="shareUserList"></div>
</div>
<div class="share-col">
<div class="share-col-title">分组</div>
<input class="search" id="shareGroupFilter" placeholder="搜索分组" spellcheck="false">
<div class="share-list" id="shareGroupList"></div>
</div>
</div>
<div class="dlg-err" id="shareErr"></div>
<div class="dlg-actions">
<button class="btn" id="btnCancelShare" type="button">取消</button>
<button class="btn primary" id="btnSaveShare" type="button">保存共享</button>
</div>
</div>
</div>
<!-- 管理员控制台已拆分为独立页面:web/admin.html --> <!-- 管理员控制台已拆分为独立页面:web/admin.html -->
<!-- ================= 修改密码 ================= --> <!-- ================= 修改密码 ================= -->
@ -170,10 +216,19 @@
<!-- 用户菜单置于 body 直属并使用 fixed 定位,避免被顶部栏的 overflow / backdrop-filter 裁剪 --> <!-- 用户菜单置于 body 直属并使用 fixed 定位,避免被顶部栏的 overflow / backdrop-filter 裁剪 -->
<div class="dropdown" id="userMenu" hidden> <div class="dropdown" id="userMenu" hidden>
<div class="dd-name" id="ddName"></div> <div class="dd-name" id="ddName"></div>
<div class="dd-item" id="ddWorkspace">我的工作台</div>
<div class="dd-item" id="ddPwd">修改密码</div> <div class="dd-item" id="ddPwd">修改密码</div>
<div class="dd-item" id="ddLogout">退出登录</div> <div class="dd-item" id="ddLogout">退出登录</div>
</div> </div>
<!-- 右下角状态区:当前正在编辑的拓扑 + 在线协作(始终固定右下角) -->
<div class="corner-status" id="cornerStatus">
<div class="topo-current" id="topoCurrent">
<i class="tc-dot"></i><span class="tc-label">当前拓扑</span><span class="tc-name"></span>
</div>
<div class="collab-bar" id="collabBar" hidden></div>
</div>
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script src="web/app.js"></script> <script src="web/app.js"></script>

View File

@ -146,7 +146,7 @@ button{font-family:inherit;}
/* -------------------------------- 右侧检查器 -------------------------------- */ /* -------------------------------- 右侧检查器 -------------------------------- */
.inspector{ .inspector{
flex:0 0 296px;background:var(--surface);border-left:1px solid var(--border); flex:0 0 296px;background:var(--surface);border-left:1px solid var(--border);
padding:18px;overflow-y:auto; padding:18px 18px 118px;overflow-y:auto;
} }
.inspector label{display:block;margin-bottom:14px;font-size:12px;color:var(--ink-500);letter-spacing:.2px;} .inspector label{display:block;margin-bottom:14px;font-size:12px;color:var(--ink-500);letter-spacing:.2px;}
.inspector input,.inspector select,.inspector textarea{ .inspector input,.inspector select,.inspector textarea{
@ -449,3 +449,74 @@ 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{display:flex;align-items:center;justify-content:center;gap:8px;}
body.admin-page .cnav-close .ci{font-size:14px;} body.admin-page .cnav-close .ci{font-size:14px;}
body.admin-page .empty-row{width:100%;} body.admin-page .empty-row{width:100%;}
/* -------------------------------- 顶部工具栏分组 -------------------------------- */
.tb-group{display:flex;align-items:center;gap:8px;flex:0 0 auto;}
/* -------------------------------- 协作状态条 -------------------------------- */
.collab-bar{
display:flex;align-items:center;gap:8px;flex:0 0 auto;
padding:3px 10px 3px 6px;border:1px solid var(--border);border-radius:999px;background:var(--ink-100);
}
.collab-title{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--ink-500);white-space:nowrap;}
.collab-title .dot{width:7px;height:7px;border-radius:50%;background:var(--ok-fg);box-shadow:0 0 0 3px var(--ok-bg);}
.collab-chip{display:inline-flex;align-items:center;gap:6px;padding:2px 9px 2px 2px;border-radius:999px;background:var(--surface);border:1px solid var(--border);font-size:12px;color:var(--ink-600);white-space:nowrap;}
.collab-chip.self{border-color:var(--info-bd);background:var(--info-bg);color:var(--info-fg);}
.collab-chip .av{width:20px;height:20px;border-radius:50%;display:grid;place-items:center;background:var(--blue-600);color:#fff;font-size:11px;font-weight:700;font-style:normal;}
.collab-chip .nm{max-width:84px;overflow:hidden;text-overflow:ellipsis;}
.collab-more{font-size:12px;color:var(--ink-500);}
/* 右下角状态区:当前拓扑 + 在线协作(始终固定右下角) */
.corner-status{
position:fixed;right:16px;bottom:16px;z-index:35;
display:flex;flex-direction:column;align-items:flex-end;gap:8px;
max-width:calc(100% - 32px);pointer-events:none;
}
.corner-status > *{pointer-events:auto;}
.topo-current{
display:inline-flex;align-items:center;gap:8px;max-width:360px;
padding:7px 13px;border:1px solid var(--border);border-radius:999px;
background:rgba(255,255,255,.94);backdrop-filter:blur(8px);box-shadow:var(--sh-1);
font-size:12.5px;color:var(--ink-700);
}
.topo-current .tc-dot{width:7px;height:7px;border-radius:50%;background:var(--blue-500);box-shadow:0 0 0 3px var(--blue-50);flex:0 0 auto;}
.topo-current.dirty .tc-dot{background:var(--warn-fg);box-shadow:0 0 0 3px var(--warn-bg);}
.topo-current .tc-label{color:var(--ink-400);flex:0 0 auto;}
.topo-current .tc-name{font-weight:600;color:var(--ink-800);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:230px;}
.corner-status .collab-bar{background:rgba(255,255,255,.94);backdrop-filter:blur(8px);box-shadow:var(--sh-1);}
/* -------------------------------- 检查器分区 -------------------------------- */
.insp-section{border:1px solid var(--border);border-radius:var(--r-md);margin-bottom:12px;overflow:hidden;background:var(--surface);}
.insp-sec-head{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;cursor:pointer;font-size:12.5px;font-weight:600;color:var(--ink-600);background:var(--ink-100);user-select:none;transition:background var(--t-fast),color var(--t-fast);}
.insp-sec-head:hover{color:var(--blue-600);}
.insp-chevron{color:var(--ink-400);font-size:11px;transition:transform var(--t-fast);}
.insp-section.collapsed .insp-chevron{transform:rotate(-90deg);}
.insp-sec-body{padding:14px 12px 2px;}
.insp-section.collapsed .insp-sec-body{display:none;}
/* -------------------------------- 共享弹窗 -------------------------------- */
.dialog.wide.share-dialog{height:auto;max-height:86vh;}
.share-cols{display:grid;grid-template-columns:1fr 1fr;gap:14px;flex:1;min-height:0;margin-bottom:8px;}
.share-col{display:flex;flex-direction:column;min-height:0;border:1px solid var(--border);border-radius:var(--r-md);padding:12px;background:var(--surface);}
.share-col-title{font-size:12.5px;font-weight:600;color:var(--ink-600);margin-bottom:8px;}
.share-col .search{width:100%;min-width:0;margin-bottom:10px;}
.share-list{flex:1;min-height:160px;max-height:44vh;overflow-y:auto;}
.share-item{display:flex;align-items:center;gap:10px;padding:7px 8px;border-radius:var(--r-sm);cursor:pointer;font-size:13px;color:var(--ink-700);margin:0 0 2px;}
.share-item:hover{background:var(--blue-50);}
.share-item input[type=checkbox]{flex:0 0 auto;width:15px;height:15px;margin:0;accent-color:var(--blue-600);cursor:pointer;}
.share-item .share-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.share-item .share-perm{flex:0 0 auto;width:auto;margin:0;padding:4px 8px;font-size:12px;border:1px solid var(--ink-300);border-radius:var(--r-sm);background:var(--surface);color:var(--ink-700);cursor:pointer;font-family:inherit;outline:none;}
.share-item .share-perm:focus{border-color:var(--blue-500);box-shadow:0 0 0 3px var(--blue-100);}
.share-dialog .set-row{margin-bottom:14px;}
@media (max-width:1240px){
.btn .lbl{display:none;}
.collab-chip .nm{display:none;}
}
@media (max-width:900px){
.share-cols{grid-template-columns:1fr;}
}
/* -------------------------------- 通用工具条 -------------------------------- */
.tools-inline{display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap;margin-bottom:12px;}
.tab-pane .table-wrap{margin-bottom:0;}

123
web/user.html Normal file
View File

@ -0,0 +1,123 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>我的工作台 · 路由拓扑</title>
<link rel="stylesheet" href="style.css">
</head>
<body class="admin-page">
<div class="console" id="console">
<!-- ================= 左侧深蓝可收缩侧栏 ================= -->
<aside class="console-nav">
<div class="console-brand">
<div class="logo" id="navLogo">R</div>
<div class="cb-text"><b>我的工作台</b><i id="navUser"></i></div>
<button class="cnav-toggle" id="btnCollapseNav" title="收起 / 展开侧栏"></button>
</div>
<div class="cnav-item active" data-view="overview"><span class="ci">📊</span><span class="cl">概览</span></div>
<div class="cnav-item" data-view="mine"><span class="ci">🗺️</span><span class="cl">我的拓扑</span></div>
<div class="cnav-item" data-view="shared"><span class="ci">🤝</span><span class="cl">共享给我</span></div>
<div class="cnav-item" data-view="groups"><span class="ci">👥</span><span class="cl">我的分组</span></div>
<div class="cnav-foot">
<div class="cnav-close" id="btnGotoAdmin" hidden><span class="ci">🛠️</span><span class="cl">管理控制台</span></div>
<div class="cnav-close" id="btnBackEditor"><span class="ci"></span><span class="cl">返回拓扑</span></div>
</div>
</aside>
<!-- ================= 右侧主区域 ================= -->
<main class="console-main">
<!-- 概览 -->
<section class="view" id="view-overview">
<div class="view-head"><h2>概览</h2></div>
<div class="cards" id="statCards"></div>
<div class="split">
<div class="panel">
<div class="panel-title">我的拓扑 · 最近更新</div>
<div class="mini-list" id="recentMine"></div>
</div>
<div class="panel">
<div class="panel-title">共享给我</div>
<div class="mini-list" id="recentShared"></div>
</div>
</div>
</section>
<!-- 我的拓扑 -->
<section class="view" id="view-mine" hidden>
<div class="view-head">
<h2>我的拓扑</h2>
<div class="view-tools">
<input class="search" id="mineSearch" placeholder="搜索拓扑名称" spellcheck="false">
<input class="search" id="newTopoName" placeholder="新建拓扑名称" spellcheck="false">
<select class="search" id="newTopoTemplate"></select>
<button class="btn primary" id="btnCreateTopo"> 新建</button>
</div>
</div>
<div class="table-wrap">
<table class="grid">
<thead><tr>
<th>名称</th><th>可见性</th><th>共享</th><th>节点 / 连线</th><th>更新时间</th><th>操作</th>
</tr></thead>
<tbody id="mineRows"></tbody>
</table>
</div>
</section>
<!-- 共享给我 -->
<section class="view" id="view-shared" hidden>
<div class="view-head">
<h2>共享给我</h2>
<div class="view-tools">
<input class="search" id="sharedSearch" placeholder="搜索拓扑名称" spellcheck="false">
</div>
</div>
<div class="table-wrap">
<table class="grid">
<thead><tr>
<th>名称</th><th>所有者</th><th>我的权限</th><th>节点 / 连线</th><th>更新时间</th><th>操作</th>
</tr></thead>
<tbody id="sharedRows"></tbody>
</table>
</div>
</section>
<!-- 我的分组 -->
<section class="view" id="view-groups" hidden>
<div class="view-head"><h2>我的分组</h2></div>
<div class="table-wrap">
<table class="grid">
<thead><tr><th>分组名称</th><th>描述</th><th>成员数</th></tr></thead>
<tbody id="groupRows"></tbody>
</table>
</div>
</section>
</main>
</div>
<!-- ================= 站内确认 / 输入弹窗 ================= -->
<div class="overlay" id="confirmOverlay" hidden>
<div class="dialog">
<div class="dlg-head"><h2 id="confirmTitle">请确认</h2><span class="dlg-close" id="confirmClose">×</span></div>
<div class="dlg-msg" id="confirmMsg"></div>
<label id="confirmFieldWrap" hidden><span id="confirmFieldLabel"></span>
<input id="confirmField" type="text" spellcheck="false" autocomplete="off">
</label>
<div class="dlg-err" id="confirmErr"></div>
<div class="dlg-actions">
<button class="btn" id="confirmCancel" type="button">取消</button>
<button class="btn primary" id="confirmOk" type="button">确定</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script src="user.js"></script>
</body>
</html>

512
web/user.js Normal file
View File

@ -0,0 +1,512 @@
/* =========================================================================
路由拓扑 · 我的工作台(普通用户后台)
页面地址:web/user.html,资源相对 web/,API 相对站点根(../index.php)
========================================================================= */
(function () {
'use strict';
/* ============================================================
基础工具
============================================================ */
function $(id) { return document.getElementById(id); }
function esc(s) {
return String(s === null || s === undefined ? '' : s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function qs(obj) {
const parts = [];
for (const k in obj) {
if (Object.prototype.hasOwnProperty.call(obj, k) && obj[k] !== '' && obj[k] !== null && obj[k] !== undefined) {
parts.push(encodeURIComponent(k) + '=' + encodeURIComponent(obj[k]));
}
}
return parts.length ? ('?' + parts.join('&')) : '';
}
function fmtTime(iso) {
if (!iso) { return '—'; }
const d = new Date(iso);
if (isNaN(d.getTime())) { return String(iso); }
const p = function (n) { return n < 10 ? '0' + n : '' + n; };
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) +
' ' + p(d.getHours()) + ':' + p(d.getMinutes());
}
function debounce(fn, ms) {
let t = null;
return function () {
const self = this, args = arguments;
clearTimeout(t);
t = setTimeout(function () { fn.apply(self, args); }, ms || 260);
};
}
/* ============================================================
API 客户端(独立页,base = ../index.php)
============================================================ */
const API = {
req: function (method, path, body) {
const opt = { method: method, credentials: 'same-origin', headers: {} };
if (body !== undefined) {
opt.headers['Content-Type'] = 'application/json';
opt.body = JSON.stringify(body);
}
return fetch('../index.php?r=' + encodeURIComponent('/api' + path), opt).then(function (r) {
return r.text().then(function (txt) {
let data = null;
try { data = txt ? JSON.parse(txt) : null; } catch (e) { data = null; }
if (!r.ok || (data && data.ok === false)) {
const msg = (data && data.error) ? data.error : ('请求失败 (' + r.status + ')');
const err = new Error(msg);
err.status = r.status;
throw err;
}
return data || {};
});
});
},
me: function () { return this.req('GET', '/me'); },
site: function () { return this.req('GET', '/site'); },
overview: function () { return this.req('GET', '/overview'); },
mygroups: function () { return this.req('GET', '/mygroups'); },
listTopos: function () { return this.req('GET', '/topologies'); },
createTopo: function (p) { return this.req('POST', '/topologies', p); },
renameTopo: function (id, name) { return this.req('PUT', '/topologies/' + encodeURIComponent(id) + '/rename', { name: name }); },
setVisibility: function (id, vis) { return this.req('PUT', '/topologies/' + encodeURIComponent(id) + '/permission', { visibility: vis }); },
deleteTopo: function (id) { return this.req('DELETE', '/topologies/' + encodeURIComponent(id)); }
};
/* ============================================================
提示条 / 站内弹窗
============================================================ */
let toastTimer = null;
function toast(msg, type) {
const el = $('toast');
el.textContent = msg;
el.className = 'toast' + (type ? (' ' + type) : '');
el.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { el.classList.remove('show'); }, 2600);
}
function openOverlay(id) { const o = $(id); if (o) { o.hidden = false; } }
function closeOverlay(id) { const o = $(id); if (o) { o.hidden = true; } }
let _dlgResolve = null;
let _dlgInput = false;
function _dlgFinish(result) {
closeOverlay('confirmOverlay');
const r = _dlgResolve;
_dlgResolve = null;
if (r) { r(result); }
}
function _dlgConfirmOk() {
if (_dlgInput) {
const v = $('confirmField').value.trim();
if (!v) { $('confirmErr').textContent = '请输入内容'; return; }
_dlgFinish(v);
} else {
_dlgFinish(true);
}
}
function _uiDialog(o) {
return new Promise(function (resolve) {
_dlgResolve = resolve;
_dlgInput = !!o.input;
$('confirmTitle').textContent = o.title || '请确认';
const msg = $('confirmMsg');
msg.textContent = o.message || '';
msg.hidden = !o.message;
const okBtn = $('confirmOk');
okBtn.textContent = o.okText || '确定';
okBtn.className = 'btn ' + (o.danger ? 'danger' : 'primary');
const wrap = $('confirmFieldWrap');
const field = $('confirmField');
if (o.input) {
wrap.hidden = false;
$('confirmFieldLabel').textContent = o.input.label || '';
field.type = o.input.type || 'text';
field.placeholder = o.input.placeholder || '';
field.value = o.input.value || '';
} else {
wrap.hidden = true;
}
$('confirmErr').textContent = '';
openOverlay('confirmOverlay');
setTimeout(function () {
if (o.input) { field.focus(); field.select(); } else { okBtn.focus(); }
}, 30);
});
}
function uiConfirm(message, opts) {
opts = opts || {};
return _uiDialog({
title: opts.title || '请确认', message: message,
okText: opts.okText || '确定', danger: !!opts.danger
});
}
function uiPrompt(label, opts) {
opts = opts || {};
return _uiDialog({
title: opts.title || '请输入', message: opts.message || '',
okText: opts.okText || '确定',
input: { label: label, placeholder: opts.placeholder || '', type: opts.type || 'text', value: opts.value || '' }
});
}
/* ============================================================
渲染小工具
============================================================ */
function statCard(label, num, cls) {
return '<div class="card ' + (cls || '') + '">' +
'<div class="num">' + esc(num === null || num === undefined ? 0 : num) + '</div>' +
'<div class="lbl">' + esc(label) + '</div></div>';
}
function miniRow(time, content) {
return '<div class="mini-row"><span class="t">' + esc(time) + '</span><span class="c">' + content + '</span></div>';
}
function emptyRow(msg) { return '<div class="empty-row">' + esc(msg) + '</div>'; }
/* ============================================================
状态
============================================================ */
const state = {
view: 'overview',
currentUser: null,
all: [],
mygroups: [],
mine: { q: '' },
shared: { q: '' }
};
function isMine(t) { return state.currentUser && t.ownerId === state.currentUser.id; }
function isShared(t) { return !!t.sharedWithMe; }
function handleErr(err) {
toast((err && err.message) || '操作失败', 'err');
if (err && (err.status === 401 || err.status === 403)) {
setTimeout(function () { window.location.href = '../index.php'; }, 1200);
}
}
/* ============================================================
拓扑模板
============================================================ */
function tplDemo() {
return {
nodes: [
{ id: 'n1', label: '192.168.1.0/24', type: 'net', x: 120, y: 130, status: 'confirmed', ports: '', note: '办公网段' },
{ id: 'n2', label: '192.168.1.10', type: 'host', x: 120, y: 232, status: 'owned', ports: '80, 443', note: 'Web 服务器', parentId: 'n1' },
{ id: 'n3', label: '10.10.10.0/24', type: 'net', x: 430, y: 100, status: 'confirmed', ports: '', note: '内网核心区' },
{ id: 'n4', label: 'dc.corp.local', type: 'domain', x: 430, y: 226, status: 'unknown', ports: '389, 445', note: '域控' },
{ id: 'n5', label: '172.16.0.0/16', type: 'net', x: 740, y: 168, status: 'unknown', ports: '', note: '未知区域' }
],
edges: [
{ id: 'e1', from: 'n1', to: 'n3', label: '' },
{ id: 'e2', from: 'n1', to: 'n4', label: '' },
{ id: 'e3', from: 'n3', to: 'n5', label: '' },
{ id: 'e4', from: 'n2', to: 'n3', label: '' }
]
};
}
const TEMPLATES = {
blank: { label: '空白画布', data: function () { return { nodes: [], edges: [] }; } },
demo: { label: '示例:小型内网', data: tplDemo }
};
function initTemplates() {
const sel = $('newTopoTemplate');
if (!sel) { return; }
sel.innerHTML = Object.keys(TEMPLATES).map(function (k) {
return '<option value="' + k + '">' + esc(TEMPLATES[k].label) + '</option>';
}).join('');
}
/* ============================================================
视图切换
============================================================ */
function switchView(name) {
state.view = name;
['overview', 'mine', 'shared', 'groups'].forEach(function (v) {
const el = $('view-' + v);
if (el) { el.hidden = (v !== name); }
});
Array.prototype.forEach.call(document.querySelectorAll('.cnav-item'), function (it) {
it.classList.toggle('active', it.getAttribute('data-view') === name);
});
if (name === 'overview') { loadOverview(); }
else if (name === 'mine') { fetchTopos().then(renderMine).catch(handleErr); }
else if (name === 'shared') { fetchTopos().then(renderShared).catch(handleErr); }
else if (name === 'groups') { loadGroups(); }
}
function fetchTopos() {
return API.listTopos().then(function (r) {
state.all = r.topologies || [];
return state.all;
});
}
/* ============================================================
概览
============================================================ */
function loadOverview() {
Promise.all([API.overview(), fetchTopos(), API.mygroups()]).then(function (res) {
const ov = res[0].overview || {};
state.mygroups = (res[2] && res[2].groups) || [];
$('statCards').innerHTML = [
statCard('我的拓扑', ov.myTopo, ''),
statCard('共享给我', ov.sharedToMe, 'accent'),
statCard('我的分组', ov.myGroups, ''),
statCard('公开拓扑', ov.publicTopo, 'green')
].join('');
const mine = state.all.filter(isMine).slice(0, 6);
$('recentMine').innerHTML = mine.length ? mine.map(function (t) {
return miniRow(fmtTime(t.updatedAt), esc(t.name));
}).join('') : emptyRow('暂无拓扑');
const shared = state.all.filter(isShared).slice(0, 6);
$('recentShared').innerHTML = shared.length ? shared.map(function (t) {
return miniRow(fmtTime(t.updatedAt), esc(t.name + ' · ' + (t.ownerName || '')));
}).join('') : emptyRow('暂无共享');
}).catch(handleErr);
}
/* ============================================================
我的拓扑
============================================================ */
function mineActions(t) {
return '<button class="btn" data-act="open" data-id="' + esc(t.id) + '">打开</button>' +
'<button class="btn" data-act="share" data-id="' + esc(t.id) + '">共享</button>' +
'<button class="btn" data-act="rename" data-id="' + esc(t.id) + '" data-name="' + esc(t.name) + '">重命名</button>' +
'<button class="btn" data-act="vis" data-id="' + esc(t.id) + '" data-vis="' +
(t.visibility === 'public' ? 'private' : 'public') + '">' +
(t.visibility === 'public' ? '设为私有' : '设为公开') + '</button>' +
'<button class="btn danger" data-act="del" data-id="' + esc(t.id) + '" data-name="' + esc(t.name) + '">删除</button>';
}
function renderMine() {
const q = state.mine.q.toLowerCase();
const rows = state.all.filter(isMine).filter(function (t) {
return !q || (t.name || '').toLowerCase().indexOf(q) >= 0;
});
$('mineRows').innerHTML = rows.length ? rows.map(function (t) {
const vis = t.visibility === 'public'
? '<span class="badge public">公开</span>'
: '<span class="badge private">私有</span>';
const share = t.shareMode
? '<span class="badge owner">已共享</span>'
: '<span class="badge">未共享</span>';
return '<tr>' +
'<td>' + esc(t.name) + '</td>' +
'<td>' + vis + '</td>' +
'<td>' + share + '</td>' +
'<td>' + esc(t.nodeCount) + ' / ' + esc(t.edgeCount) + '</td>' +
'<td>' + esc(fmtTime(t.updatedAt)) + '</td>' +
'<td class="actions">' + mineActions(t) + '</td>' +
'</tr>';
}).join('') : '<tr><td colspan="6" class="empty-row">暂无拓扑</td></tr>';
}
function onMineRowClick(e) {
const b = e.target.closest('button[data-act]');
if (!b) { return; }
const act = b.getAttribute('data-act');
const id = b.getAttribute('data-id');
if (act === 'open') {
window.location.href = '../index.php?topo=' + encodeURIComponent(id);
} else if (act === 'share') {
window.location.href = '../index.php?topo=' + encodeURIComponent(id) + '&share=1';
} else if (act === 'rename') {
const curName = b.getAttribute('data-name');
uiPrompt('新名称', {
title: '重命名拓扑', message: '为拓扑「' + curName + '」设置新名称(最多 60 个字符)。',
placeholder: '例如:某内网横向拓扑', value: curName, okText: '保存'
}).then(function (v) {
if (v === null) { return; }
v = String(v).trim();
if (!v || v === curName) { return; }
API.renameTopo(id, v).then(function () {
toast('已重命名', 'ok');
fetchTopos().then(renderMine).catch(handleErr);
}).catch(handleErr);
});
} else if (act === 'vis') {
const vis = b.getAttribute('data-vis');
const label = (vis === 'public' ? '公开' : '私有');
uiConfirm('确认将该拓扑设为「' + label + '」?', { title: '修改可见性' }).then(function (ok) {
if (!ok) { return; }
API.setVisibility(id, vis).then(function () {
toast('可见性已更新', 'ok');
fetchTopos().then(renderMine).catch(handleErr);
}).catch(handleErr);
});
} else if (act === 'del') {
uiConfirm('确认删除拓扑「' + b.getAttribute('data-name') + '」?该操作不可恢复。',
{ title: '删除拓扑', danger: true, okText: '删除' }).then(function (ok) {
if (!ok) { return; }
API.deleteTopo(id).then(function () {
toast('拓扑已删除', 'ok');
fetchTopos().then(renderMine).catch(handleErr);
}).catch(handleErr);
});
}
}
function createNewTopo() {
const name = $('newTopoName').value.trim();
if (!name) { toast('请输入拓扑名称', 'warn'); return; }
const sel = $('newTopoTemplate');
const tpl = (sel && TEMPLATES[sel.value]) ? TEMPLATES[sel.value] : TEMPLATES.blank;
const data = tpl.data() || { nodes: [], edges: [] };
API.createTopo({ name: name, nodes: data.nodes || [], edges: data.edges || [] }).then(function (r) {
$('newTopoName').value = '';
toast('已创建:' + name, 'ok');
window.location.href = '../index.php?topo=' + encodeURIComponent(r.topology.id);
}).catch(handleErr);
}
/* ============================================================
共享给我
============================================================ */
function renderShared() {
const q = state.shared.q.toLowerCase();
const rows = state.all.filter(isShared).filter(function (t) {
return !q || (t.name || '').toLowerCase().indexOf(q) >= 0;
});
$('sharedRows').innerHTML = rows.length ? rows.map(function (t) {
const perm = t.permission === 'edit'
? '<span class="badge owner">可编辑</span>'
: '<span class="badge ro">只读</span>';
return '<tr>' +
'<td>' + esc(t.name) + '</td>' +
'<td>' + esc(t.ownerName || '—') + '</td>' +
'<td>' + perm + '</td>' +
'<td>' + esc(t.nodeCount) + ' / ' + esc(t.edgeCount) + '</td>' +
'<td>' + esc(fmtTime(t.updatedAt)) + '</td>' +
'<td class="actions"><button class="btn" data-act="open" data-id="' + esc(t.id) + '">打开</button></td>' +
'</tr>';
}).join('') : '<tr><td colspan="6" class="empty-row">暂无共享拓扑</td></tr>';
}
function onSharedRowClick(e) {
const b = e.target.closest('button[data-act]');
if (!b) { return; }
if (b.getAttribute('data-act') === 'open') {
window.location.href = '../index.php?topo=' + encodeURIComponent(b.getAttribute('data-id'));
}
}
/* ============================================================
我的分组
============================================================ */
function loadGroups() {
API.mygroups().then(function (r) {
state.mygroups = r.groups || [];
renderGroups();
}).catch(handleErr);
}
function renderGroups() {
const gs = state.mygroups || [];
$('groupRows').innerHTML = gs.length ? gs.map(function (g) {
return '<tr><td>' + esc(g.name) + '</td><td>' + esc(g.description || '—') +
'</td><td>' + esc(g.memberCount) + '</td></tr>';
}).join('') : '<tr><td colspan="3" class="empty-row">你还没有加入任何分组</td></tr>';
}
/* ============================================================
品牌
============================================================ */
function applyBrand(site) {
if (!site) { return; }
if (site.name) { document.title = site.name + ' · 我的工作台'; }
const el = $('navLogo');
if (el && site.logo) {
el.innerHTML = '';
const img = document.createElement('img');
img.src = site.logo;
img.alt = '';
el.appendChild(img);
el.classList.add('has-img');
}
}
/* ============================================================
事件绑定
============================================================ */
function bindEvents() {
Array.prototype.forEach.call(document.querySelectorAll('.cnav-item'), function (it) {
it.addEventListener('click', function () { switchView(it.getAttribute('data-view')); });
});
$('btnCollapseNav').addEventListener('click', function () {
$('console').classList.toggle('nav-collapsed');
});
$('btnBackEditor').addEventListener('click', function () {
window.location.href = '../index.php';
});
$('btnGotoAdmin').addEventListener('click', function () {
window.location.href = 'admin.html';
});
$('mineSearch').addEventListener('input', debounce(function () {
state.mine.q = this.value.trim(); renderMine();
}));
$('sharedSearch').addEventListener('input', debounce(function () {
state.shared.q = this.value.trim(); renderShared();
}));
$('btnCreateTopo').addEventListener('click', createNewTopo);
$('newTopoName').addEventListener('keydown', function (e) {
if (e.key === 'Enter') { e.preventDefault(); createNewTopo(); }
});
$('mineRows').addEventListener('click', onMineRowClick);
$('sharedRows').addEventListener('click', onSharedRowClick);
$('confirmOk').addEventListener('click', _dlgConfirmOk);
$('confirmCancel').addEventListener('click', function () { _dlgFinish(false); });
$('confirmClose').addEventListener('click', function () { _dlgFinish(false); });
$('confirmField').addEventListener('keydown', function (e) {
if (e.key === 'Enter') { e.preventDefault(); _dlgConfirmOk(); }
});
$('confirmOverlay').addEventListener('click', function (e) {
if (e.target === this) { _dlgFinish(false); }
});
}
/* ============================================================
启动
============================================================ */
function boot() {
bindEvents();
API.me().then(function (d) {
const u = d.user;
if (!u) {
window.location.href = '../index.php';
return;
}
state.currentUser = u;
$('navUser').textContent = u.username + ' · ' + (u.role === 'admin' ? '管理员' : '普通用户');
if (u.role === 'admin') { $('btnGotoAdmin').hidden = false; }
initTemplates();
API.site().then(applyBrand).catch(function () { /* 忽略:回退默认品牌 */ });
switchView('overview');
}).catch(function () {
window.location.href = '../index.php';
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();