feat: 账号认证、用户管理与 API Token 管理
build-and-deploy / build-and-deploy (push) Successful in 21s

- 后端: Auth(登录/登出/me + session 中间件)、Users、Tokens 路由与 Service,
  scrypt 哈希+timingSafeEqual 校验、requireAdmin 保护、admin 用户自动初始化
- 前端: login.html(登录页, 无账号密码提示)、admin.html(用户+Token 管理,
  令牌明文显隐/复制/吊销/重置密码)、标题栏用户信息+系统管理入口
- 持久化: jsonAdapter 支持 users/api-tokens,运行时数据忽略入库
- 文档: 需求.md/原型设计.md 同步
This commit is contained in:
jif2.zhang
2026-09-11 17:12:37 +08:00
parent 963046edd9
commit 1765caba50
16 changed files with 2132 additions and 141 deletions
+667
View File
@@ -0,0 +1,667 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>系统管理 - Registry 配置管理中心</title>
<link rel="stylesheet" href="/css/style.css">
<style>
.titlebar-right { margin-left: auto; display: flex; align-items: center; gap: 14px; font-size: 12px; }
.titlebar-user { color: #e8f1fb; display: flex; align-items: center; gap: 6px; }
.role-badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 11px; background: rgba(255,255,255,0.18); border: 1px solid rgba(255,255,255,0.35); }
.role-badge.admin { background: #ffb900; border-color: #ffd666; color: #4a3200; font-weight: 600; }
.titlebar-link { color: #cfe3f7; text-decoration: none; cursor: pointer; padding-left: 14px; border-left: 1px solid rgba(255,255,255,0.3); }
.titlebar-link:hover { color: #fff; text-decoration: underline; }
.admin-container { flex: 1; display: flex; overflow: hidden; }
.admin-nav { width: 200px; background: #fff; border-right: 1px solid #c0c0c0; padding: 10px 0; flex-shrink: 0; }
.admin-nav-title { padding: 4px 18px 10px; font-size: 11px; color: #999; letter-spacing: 1px; }
.admin-nav-item { display: flex; align-items: center; gap: 8px; height: 38px; padding: 0 18px; cursor: pointer; color: #333; font-size: 13px; border-left: 3px solid transparent; }
.admin-nav-item:hover { background: #e5f1fb; }
.admin-nav-item.active { background: #cce8ff; color: #003a70; font-weight: 600; border-left-color: #0078d4; }
.admin-content { flex: 1; overflow: auto; padding: 18px; }
.admin-panel { background: #fff; border: 1px solid #e0e0e0; border-radius: 6px; overflow: hidden; }
.admin-panel-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: #f5f5f5; border-bottom: 1px solid #e0e0e0; flex-wrap: wrap; gap: 8px; }
.admin-panel-header h2 { font-size: 14px; color: #003a70; }
.admin-panel-header .panel-sub { font-weight: normal; font-size: 11px; color: #999; margin-left: 8px; }
.admin-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.search-input { height: 30px; width: 200px; padding: 0 10px; border: 1px solid #adadad; border-radius: 3px; font-size: 13px; font-family: inherit; }
.filter-select { height: 30px; padding: 0 8px; border: 1px solid #adadad; border-radius: 3px; background: #fff; font-size: 12px; }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
.data-table { width: 100%; border-collapse: collapse; }
.data-table th { background: #f0f0f0; border-bottom: 1px solid #c0c0c0; border-right: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; font-weight: 600; color: #555; font-size: 12px; white-space: nowrap; }
.data-table td { padding: 8px 12px; border-bottom: 1px solid #f0f0f0; font-size: 12px; vertical-align: middle; }
.data-table tr:hover td { background: #f8fbff; }
.data-table .mono { font-family: Consolas, monospace; color: #0451a5; }
.role-tag { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 11px; border: 1px solid transparent; }
.role-tag.admin { background: #fff3e0; color: #b45309; border-color: #f0c98f; }
.role-tag.user { background: #e5f1fb; color: #0078d4; border-color: #bcdcf7; }
.status-badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 11px; border: 1px solid transparent; }
.badge-active { background: #e8f5e9; color: #2e7d32; border-color: #b5dfb8; }
.badge-disabled { background: #fdecea; color: #c62828; border-color: #f2b8b5; }
.badge-expired { background: #f0f0f0; color: #777; border-color: #d5d5d5; }
.dim-chip { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 11px; background: #eef4fb; color: #0a4a86; border: 1px solid #d0dced; }
.token-reveal { display: flex; align-items: center; gap: 6px; }
.token-value { font-family: Consolas, monospace; color: #1e1e1e; letter-spacing: 1px; }
.token-mask { color: #999; letter-spacing: 2px; }
.token-btn { border: 1px solid #adadad; background: #fff; border-radius: 3px; padding: 1px 6px; font-size: 11px; cursor: pointer; white-space: nowrap; }
.token-btn:hover { background: #e5f1fb; border-color: #5b9bd5; }
.form-row { display: flex; gap: 12px; }
.form-half { flex: 1; }
.form-group label.hint { font-weight: normal; color: #999; font-size: 11px; }
.hint { font-weight: normal; color: #999; font-size: 11px; }
</style>
</head>
<body>
<header class="titlebar">
<span class="app-title">📋 Registry 配置管理中心</span>
<span class="current-project">系统管理</span>
<div class="titlebar-right">
<span class="titlebar-user">
👤 <span id="user-name"></span>
<span class="role-badge admin">管理员</span>
</span>
<a class="titlebar-link" href="/projects.html">← 返回项目列表</a>
<a class="titlebar-link" id="logout-link" href="#">退出登录</a>
</div>
</header>
<div class="admin-container">
<aside class="admin-nav">
<div class="admin-nav-title">系统设置</div>
<div class="admin-nav-item active" data-tab="users" onclick="switchTab('users')">👥 用户管理</div>
<div class="admin-nav-item" data-tab="tokens" onclick="switchTab('tokens')">🔑 Token 管理</div>
</aside>
<div class="admin-content">
<!-- 用户管理 -->
<div class="tab-panel active" id="panel-users">
<div class="admin-panel">
<div class="admin-panel-header">
<h2>用户管理 <span class="panel-sub">管理系统账号与角色权限</span></h2>
<div class="admin-toolbar">
<input type="text" class="search-input" id="user-search" placeholder="🔍 搜索用户名">
<button class="btn btn-primary" style="height:30px" id="btn-add-user">➕ 新增用户</button>
</div>
</div>
<div style="overflow:auto">
<table class="data-table" id="user-table">
<thead>
<tr>
<th>用户名</th><th>显示名称</th><th>角色</th><th>状态</th>
<th>创建时间</th><th>最后登录</th><th>操作</th>
</tr>
</thead>
<tbody id="user-tbody"></tbody>
</table>
</div>
</div>
</div>
<!-- Token 管理 -->
<div class="tab-panel" id="panel-tokens">
<div class="admin-panel">
<div class="admin-panel-header">
<h2>Token 管理 <span class="panel-sub">MCP / API 访问令牌</span></h2>
<div class="admin-toolbar">
<input type="text" class="search-input" id="token-search" placeholder="🔍 搜索 Token 名称">
<button class="btn btn-primary" style="height:30px" id="btn-add-token">🔑 生成 Token</button>
</div>
</div>
<div style="overflow:auto">
<table class="data-table" id="token-table">
<thead>
<tr>
<th>名称</th><th>关联项目</th><th>权限</th><th>Token</th>
<th>状态</th><th>过期时间</th><th>创建时间</th><th>操作</th>
</tr>
</thead>
<tbody id="token-tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<footer class="statusbar">
<span id="status-text">系统管理 · 用户管理</span>
<span id="status-user"></span>
</footer>
<!-- 新增/编辑用户模态框 -->
<div class="modal-overlay" id="modal-user" style="display:none">
<div class="modal">
<div class="modal-header">
<h3 id="modal-user-title">新增用户</h3>
<button class="modal-close" onclick="closeModal('modal-user')">✕</button>
</div>
<div class="modal-body">
<input type="hidden" id="edit-user-id">
<div class="form-group">
<label>用户名 <span class="required">*</span></label>
<input type="text" id="input-username" placeholder="登录账号,例如 zhangsan">
</div>
<div class="form-group">
<label>显示名称</label>
<input type="text" id="input-display-name" placeholder="用于界面展示的名称">
</div>
<div class="form-row">
<div class="form-group form-half">
<label>角色</label>
<select id="input-role"><option value="user">普通用户</option><option value="admin">管理员</option></select>
</div>
<div class="form-group form-half">
<label>状态</label>
<select id="input-enabled"><option value="true">启用</option><option value="false">停用</option></select>
</div>
</div>
<div class="form-group" id="password-group">
<label>密码 <span class="required">*</span></label>
<input type="password" id="input-password" placeholder="首次登录后建议修改">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('modal-user')">取消</button>
<button class="btn btn-primary" id="btn-save-user">保存</button>
</div>
</div>
</div>
<!-- 重置密码模态框 -->
<div class="modal-overlay" id="modal-reset-pw" style="display:none">
<div class="modal" style="width:360px">
<div class="modal-header">
<h3>重置密码</h3>
<button class="modal-close" onclick="closeModal('modal-reset-pw')">✕</button>
</div>
<div class="modal-body">
<input type="hidden" id="reset-user-id">
<div class="form-group">
<label>新密码 <span class="required">*</span></label>
<input type="password" id="input-new-password" placeholder="请输入新密码">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('modal-reset-pw')">取消</button>
<button class="btn btn-primary" id="btn-save-reset-pw">确认重置</button>
</div>
</div>
</div>
<!-- 生成/编辑 Token 模态框 -->
<div class="modal-overlay" id="modal-token" style="display:none">
<div class="modal">
<div class="modal-header">
<h3 id="modal-token-title">生成 Token</h3>
<button class="modal-close" onclick="closeModal('modal-token')">✕</button>
</div>
<div class="modal-body">
<input type="hidden" id="edit-token-id">
<div class="form-group">
<label>名称 <span class="required">*</span></label>
<input type="text" id="input-token-name" placeholder="例如:CI 自动发布">
</div>
<div class="form-group">
<label>关联项目</label>
<select id="input-token-project"><option value="all">全部项目</option></select>
</div>
<div class="form-row">
<div class="form-group form-half">
<label>权限范围</label>
<select id="input-token-permission"><option value="readonly">只读</option><option value="readwrite">读写</option></select>
</div>
<div class="form-group form-half">
<label>过期时间</label>
<select id="input-token-expiry">
<option value="30">30 天</option>
<option value="90" selected>90 天</option>
<option value="365">1 年</option>
<option value="0">永不过期</option>
</select>
</div>
</div>
<p class="hint">生成后 Token 明文仅展示一次,请及时复制并妥善保管。</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('modal-token')">取消</button>
<button class="btn btn-primary" id="btn-save-token">生成</button>
</div>
</div>
</div>
<!-- Token 明文展示模态框 -->
<div class="modal-overlay" id="modal-token-reveal" style="display:none">
<div class="modal" style="width:480px">
<div class="modal-header">
<h3>Token 已生成</h3>
<button class="modal-close" onclick="closeModal('modal-token-reveal')">✕</button>
</div>
<div class="modal-body">
<p style="margin-bottom:12px;color:#666;font-size:12px">请立即复制保存,关闭后将无法再查看完整 Token。</p>
<div style="display:flex;gap:8px;align-items:center;background:#f5f5f5;padding:10px 12px;border-radius:4px;border:1px solid #e0e0e0">
<code id="reveal-token-value" style="flex:1;word-break:break-all;font-family:Consolas,monospace;font-size:13px;color:#0451a5"></code>
<button class="token-btn" id="btn-copy-reveal">复制</button>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-primary" onclick="closeModal('modal-token-reveal')">我已保存</button>
</div>
</div>
</div>
<!-- 确认删除模态框 -->
<div class="modal-overlay" id="modal-confirm" style="display:none">
<div class="modal" style="width:360px">
<div class="modal-header">
<h3>确认操作</h3>
<button class="modal-close" onclick="closeModal('modal-confirm')">✕</button>
</div>
<div class="modal-body"><p id="confirm-message"></p></div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('modal-confirm')">取消</button>
<button class="btn btn-danger" id="btn-confirm-ok">确认</button>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script>
var currentUser = null;
var confirmCallback = null;
function toast(message, type) {
var el = document.createElement("div");
el.className = "toast toast-" + (type || "info");
el.textContent = message;
document.body.appendChild(el);
setTimeout(function () { el.remove(); }, 2500);
}
function openModal(id) { document.getElementById(id).style.display = "flex"; }
window.closeModal = function (id) { document.getElementById(id).style.display = "none"; };
function openConfirm(message, callback) {
document.getElementById("confirm-message").textContent = message;
confirmCallback = callback;
openModal("modal-confirm");
}
document.getElementById("btn-confirm-ok").addEventListener("click", function () {
closeModal("modal-confirm");
if (confirmCallback) confirmCallback();
confirmCallback = null;
});
function esc(s) { var d = document.createElement("div"); d.textContent = s; return d.innerHTML; }
function fmtDate(iso) {
if (!iso) return "—";
var d = new Date(iso);
var pad = function (n) { return n < 10 ? "0" + n : n; };
return d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) + " " + pad(d.getHours()) + ":" + pad(d.getMinutes());
}
window.switchTab = function (tab) {
document.querySelectorAll(".admin-nav-item").forEach(function (el) { el.classList.toggle("active", el.dataset.tab === tab); });
document.querySelectorAll(".tab-panel").forEach(function (el) { el.classList.remove("active"); });
document.getElementById("panel-" + tab).classList.add("active");
document.getElementById("status-text").textContent = "系统管理 · " + (tab === "users" ? "用户管理" : "Token 管理");
}
// ========== 用户管理 ==========
var usersCache = [];
async function loadUsers() {
try {
usersCache = await API.getUsers();
renderUsers(usersCache);
} catch (err) {
toast("加载用户失败: " + err.message, "error");
}
}
function renderUsers(users) {
var tbody = document.getElementById("user-tbody");
if (!users.length) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#999;padding:30px">暂无用户</td></tr>';
return;
}
tbody.innerHTML = users.map(function (u) {
return "<tr>" +
'<td class="mono">' + esc(u.username) + "</td>" +
"<td>" + esc(u.displayName || "") + "</td>" +
'<td><span class="role-tag ' + u.role + '">' + (u.role === "admin" ? "管理员" : "普通用户") + "</span></td>" +
'<td><span class="status-badge ' + (u.enabled ? "badge-active" : "badge-disabled") + '">' + (u.enabled ? "启用" : "停用") + "</span></td>" +
"<td>" + fmtDate(u.createdAt) + "</td>" +
"<td>" + fmtDate(u.lastLoginAt) + "</td>" +
'<td style="white-space:nowrap">' +
'<button class="row-btn" onclick="editUserById(\'' + u.id + '\')">编辑</button> ' +
'<button class="row-btn" onclick="resetPassword(\'' + u.id + '\')">重置密码</button> ' +
(u.enabled
? '<button class="row-btn danger" onclick="toggleUser(\'' + u.id + "', false)\">停用</button>"
: '<button class="row-btn" onclick="toggleUser(\'' + u.id + "', true)\">启用</button>") +
(u.username !== "admin"
? ' <button class="row-btn danger" onclick="confirmDeleteUser(\'' + u.id + '\')">删除</button>'
: "") +
"</td>" +
"</tr>";
}).join("");
}
document.getElementById("user-search").addEventListener("input", function () {
var kw = this.value.trim().toLowerCase();
document.querySelectorAll("#user-tbody tr").forEach(function (tr) {
tr.style.display = kw && tr.textContent.toLowerCase().indexOf(kw) === -1 ? "none" : "";
});
});
document.getElementById("btn-add-user").addEventListener("click", function () {
document.getElementById("modal-user-title").textContent = "新增用户";
document.getElementById("edit-user-id").value = "";
document.getElementById("input-username").value = "";
document.getElementById("input-username").disabled = false;
document.getElementById("input-display-name").value = "";
document.getElementById("input-role").value = "user";
document.getElementById("input-enabled").value = "true";
document.getElementById("input-password").value = "";
document.getElementById("password-group").style.display = "";
openModal("modal-user");
});
window.editUserById = function (id) {
var u = usersCache.find(function (x) { return x.id === id; });
if (!u) return;
document.getElementById("modal-user-title").textContent = "编辑用户";
document.getElementById("edit-user-id").value = u.id;
document.getElementById("input-username").value = u.username;
document.getElementById("input-username").disabled = true;
document.getElementById("input-display-name").value = u.displayName || "";
document.getElementById("input-role").value = u.role;
document.getElementById("input-enabled").value = String(u.enabled);
document.getElementById("password-group").style.display = "none";
openModal("modal-user");
};
document.getElementById("btn-save-user").addEventListener("click", async function () {
var id = document.getElementById("edit-user-id").value;
var isNew = !id;
try {
if (isNew) {
var data = {
username: document.getElementById("input-username").value,
displayName: document.getElementById("input-display-name").value,
password: document.getElementById("input-password").value,
role: document.getElementById("input-role").value,
enabled: document.getElementById("input-enabled").value === "true",
};
await API.createUser(data);
toast("用户创建成功", "success");
} else {
await API.updateUser(id, {
displayName: document.getElementById("input-display-name").value,
role: document.getElementById("input-role").value,
enabled: document.getElementById("input-enabled").value === "true",
});
toast("用户已更新", "success");
}
closeModal("modal-user");
loadUsers();
} catch (err) {
toast(err.message, "error");
}
});
window.resetPassword = function (id) {
document.getElementById("reset-user-id").value = id;
document.getElementById("input-new-password").value = "";
openModal("modal-reset-pw");
};
document.getElementById("btn-save-reset-pw").addEventListener("click", async function () {
var id = document.getElementById("reset-user-id").value;
var pw = document.getElementById("input-new-password").value;
try {
await API.resetPassword(id, pw);
closeModal("modal-reset-pw");
toast("密码已重置", "success");
} catch (err) {
toast(err.message, "error");
}
});
window.toggleUser = async function (id, enabled) {
try {
await API.updateUser(id, { enabled: enabled });
toast(enabled ? "用户已启用" : "用户已停用", "success");
loadUsers();
} catch (err) {
toast(err.message, "error");
}
};
window.confirmDeleteUser = function (id) {
var u = usersCache.find(function (x) { return x.id === id; });
var name = u ? u.username : id;
openConfirm("确认删除用户「" + name + "」?", async function () {
try {
await API.deleteUser(id);
toast("用户已删除", "success");
loadUsers();
} catch (err) {
toast(err.message, "error");
}
});
}
// ========== Token 管理 ==========
async function loadTokens() {
try {
var tokens = await API.getTokens();
renderTokens(tokens);
} catch (err) {
toast("加载 Token 失败: " + err.message, "error");
}
}
async function loadProjectsForSelect() {
try {
var projects = await API.getProjects();
var sel = document.getElementById("input-token-project");
sel.innerHTML = '<option value="all">全部项目</option>' +
projects.map(function (p) { return '<option value="' + p.id + '">' + esc(p.name) + "</option>"; }).join("");
} catch (e) {}
}
function renderTokens(tokens) {
var tbody = document.getElementById("token-tbody");
if (!tokens.length) {
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:#999;padding:30px">暂无 Token</td></tr>';
return;
}
tbody.innerHTML = tokens.map(function (t) {
var status = "badge-active";
var statusText = "有效";
if (!t.enabled) { status = "badge-disabled"; statusText = "已吊销"; }
else if (t.expiresAt && new Date(t.expiresAt) < new Date()) { status = "badge-expired"; statusText = "已过期"; }
var masked = t.value.substring(0, 6) + "••••••••••••";
return "<tr>" +
"<td>" + esc(t.name) + "</td>" +
"<td>" + esc(t.projectId === "all" ? "全部项目" : t.projectId) + "</td>" +
'<td><span class="dim-chip">' + (t.permission === "readwrite" ? "读写" : "只读") + "</span></td>" +
'<td><div class="token-reveal">' +
'<span class="token-value token-mask" data-id="' + t.id + '">' + masked + "</span>" +
'<button class="token-btn" onclick="toggleTokenVis(this)">显示</button>' +
'<button class="token-btn" onclick="copyTokenVal(this, \'' + t.value + "')\">复制</button>" +
"</div></td>" +
'<td><span class="status-badge ' + status + '">' + statusText + "</span></td>" +
"<td>" + fmtDate(t.expiresAt) + "</td>" +
"<td>" + fmtDate(t.createdAt) + "</td>" +
'<td style="white-space:nowrap">' +
'<button class="row-btn" onclick=\'editToken(' + JSON.stringify({ id: t.id, name: t.name, projectId: t.projectId, permission: t.permission }).replace(/'/g, "&#39;") + ")'>编辑</button> " +
(t.enabled
? '<button class="row-btn danger" onclick="revokeToken(\'' + t.id + "')\">吊销</button>"
: "") +
' <button class="row-btn danger" onclick="confirmDeleteToken(\'' + t.id + "', '" + esc(t.name) + "')\">删除</button>" +
"</td>" +
"</tr>";
}).join("");
}
window.toggleTokenVis = function (btn) {
var span = btn.parentElement.querySelector(".token-value");
if (btn.textContent === "显示") {
btn.dataset.masked = span.textContent;
// 显示完整值需要从 tbody 原始数据中获取,这里通过 API 重新加载
span.classList.remove("token-mask");
span.textContent = span.dataset.full || btn.dataset.masked;
btn.textContent = "隐藏";
// 尝试从隐藏的 data 属性获取完整值
var id = span.dataset.id;
API.getTokens().then(function (tokens) {
var t = tokens.find(function (tk) { return tk.id === id; });
if (t) { span.dataset.full = t.value; span.textContent = t.value; }
});
} else {
span.classList.add("token-mask");
span.textContent = btn.dataset.masked;
btn.textContent = "显示";
}
};
window.copyTokenVal = function (btn, val) {
if (navigator.clipboard) navigator.clipboard.writeText(val);
btn.textContent = "已复制";
setTimeout(function () { btn.textContent = "复制"; }, 1200);
};
document.getElementById("token-search").addEventListener("input", function () {
var kw = this.value.trim().toLowerCase();
document.querySelectorAll("#token-tbody tr").forEach(function (tr) {
tr.style.display = kw && tr.textContent.toLowerCase().indexOf(kw) === -1 ? "none" : "";
});
});
document.getElementById("btn-add-token").addEventListener("click", function () {
document.getElementById("modal-token-title").textContent = "生成 Token";
document.getElementById("edit-token-id").value = "";
document.getElementById("input-token-name").value = "";
document.getElementById("input-token-project").value = "all";
document.getElementById("input-token-permission").value = "readonly";
document.getElementById("input-token-expiry").value = "90";
document.getElementById("btn-save-token").textContent = "生成";
loadProjectsForSelect();
openModal("modal-token");
});
window.editToken = function (t) {
document.getElementById("modal-token-title").textContent = "编辑 Token";
document.getElementById("edit-token-id").value = t.id;
document.getElementById("input-token-name").value = t.name;
document.getElementById("input-token-project").value = t.projectId;
document.getElementById("input-token-permission").value = t.permission;
document.getElementById("btn-save-token").textContent = "保存";
loadProjectsForSelect();
openModal("modal-token");
};
document.getElementById("btn-save-token").addEventListener("click", async function () {
var id = document.getElementById("edit-token-id").value;
var name = document.getElementById("input-token-name").value.trim();
if (!name) { toast("请输入 Token 名称", "error"); return; }
try {
if (id) {
await API.updateToken(id, {
name: name,
projectId: document.getElementById("input-token-project").value,
permission: document.getElementById("input-token-permission").value,
});
closeModal("modal-token");
toast("Token 已更新", "success");
loadTokens();
} else {
var data = await API.createToken({
name: name,
projectId: document.getElementById("input-token-project").value,
permission: document.getElementById("input-token-permission").value,
expiresInDays: parseInt(document.getElementById("input-token-expiry").value) || 0,
});
closeModal("modal-token");
document.getElementById("reveal-token-value").textContent = data.value;
openModal("modal-token-reveal");
toast("Token 已生成", "success");
loadTokens();
}
} catch (err) {
toast(err.message, "error");
}
});
document.getElementById("btn-copy-reveal").addEventListener("click", function () {
var val = document.getElementById("reveal-token-value").textContent;
if (navigator.clipboard) navigator.clipboard.writeText(val);
this.textContent = "已复制";
setTimeout(function () { document.getElementById("btn-copy-reveal").textContent = "复制"; }, 1200);
});
window.revokeToken = function (id) {
openConfirm("确认吊销此 Token?吊销后使用该 Token 的服务将无法继续访问。", async function () {
try {
await API.revokeToken(id);
toast("Token 已吊销", "success");
loadTokens();
} catch (err) {
toast(err.message, "error");
}
});
};
window.confirmDeleteToken = function (id, name) {
openConfirm("确认删除 Token「" + name + "」?", async function () {
try {
await API.deleteToken(id);
toast("Token 已删除", "success");
loadTokens();
} catch (err) {
toast(err.message, "error");
}
});
}
// ========== 初始化 ==========
async function init() {
try {
var data = await API.getMe();
currentUser = data;
} catch (e) {
window.location.href = "/login.html";
return;
}
if (currentUser.role !== "admin") {
window.location.href = "/projects.html";
return;
}
document.getElementById("user-name").textContent = currentUser.displayName || currentUser.username;
document.getElementById("status-user").textContent = currentUser.username;
await Promise.all([loadUsers(), loadTokens()]);
}
document.getElementById("logout-link").addEventListener("click", async function (e) {
e.preventDefault();
try { await API.logout(); } catch (e) {}
localStorage.removeItem("registry_token");
localStorage.removeItem("registry_user");
window.location.href = "/login.html";
});
init();
</script>
</body>
</html>
+50 -5
View File
@@ -632,11 +632,56 @@ body.mode-readonly .row-btn:not(.view) {
color: #fff;
}
.titlebar .current-project {
font-size: 13px;
color: #cfe3f7;
padding-left: 14px;
border-left: 1px solid rgba(255, 255, 255, 0.3);
.titlebar .current-project {
font-size: 13px;
color: #cfe3f7;
padding-left: 14px;
border-left: 1px solid rgba(255, 255, 255, 0.3);
}
/* ===== 标题栏右侧:用户信息与入口 ===== */
.titlebar-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 14px;
font-size: 12px;
}
.titlebar-user {
color: #e8f1fb;
display: flex;
align-items: center;
gap: 6px;
}
.role-badge {
display: inline-block;
padding: 1px 8px;
border-radius: 10px;
font-size: 11px;
background: rgba(255, 255, 255, 0.18);
border: 1px solid rgba(255, 255, 255, 0.35);
}
.role-badge.admin {
background: #ffb900;
border-color: #ffd666;
color: #4a3200;
font-weight: 600;
}
.titlebar-link {
color: #cfe3f7;
text-decoration: none;
cursor: pointer;
padding-left: 14px;
border-left: 1px solid rgba(255, 255, 255, 0.3);
}
.titlebar-link:hover {
color: #fff;
text-decoration: underline;
}
/* ===== 工具栏(分组处理) ===== */
+40 -6
View File
@@ -7,10 +7,19 @@
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<!-- 标题栏:仅展示应用名与当前项目 -->
<header class="titlebar">
<span class="app-title">📋 Registry 配置管理中心</span>
<span class="current-project" id="current-project-name">未选择项目</span>
<!-- 标题栏:仅展示应用名与当前项目 -->
<header class="titlebar">
<span class="app-title">📋 Registry 配置管理中心</span>
<span class="current-project" id="current-project-name">未选择项目</span>
<div class="titlebar-right">
<span class="titlebar-user">
👤 <span id="user-name"></span>
<span class="role-badge" id="user-role"></span>
</span>
<a class="titlebar-link" id="admin-entry" href="/admin.html" style="display:none">⚙ 系统管理</a>
<a class="titlebar-link" href="/projects.html">项目列表</a>
<a class="titlebar-link" id="logout-link" href="#">退出登录</a>
</div>
</header>
<!-- 工具栏:分组处理(项目 / 模式 / 配置 / 数据) -->
@@ -312,8 +321,33 @@
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/tree.js"></script>
<script src="/js/api.js"></script>
<script src="/js/tree.js"></script>
<script>
// 认证初始化:检查登录状态,填充标题栏用户信息
(async function initAuth() {
try {
var user = await API.getMe();
document.getElementById("user-name").textContent = user.displayName || user.username;
var roleEl = document.getElementById("user-role");
if (user.role === "admin") {
roleEl.textContent = "管理员";
roleEl.className = "role-badge admin";
document.getElementById("admin-entry").style.display = "";
}
} catch (e) {
window.location.href = "/login.html";
return;
}
})();
document.getElementById("logout-link").addEventListener("click", async function (e) {
e.preventDefault();
try { await API.logout(); } catch (e) {}
localStorage.removeItem("registry_token");
localStorage.removeItem("registry_user");
window.location.href = "/login.html";
});
</script>
<script src="/js/app.js"></script>
</body>
</html>
+145 -129
View File
@@ -1,129 +1,145 @@
/**
* API 调用封装
* 统一处理与后端 REST 接口的交互,返回解析后的数据或抛出错误
*/
const API = {
/**
* 通用请求方法
* @param {string} url - 请求路径
* @param {Object} options - fetch 选项
* @returns {Promise<any>} 响应数据
*/
async request(url, options = {}) {
const opts = {
headers: { "Content-Type": "application/json" },
...options,
};
if (opts.body && typeof opts.body !== "string") {
opts.body = JSON.stringify(opts.body);
}
const res = await fetch(url, opts);
const json = await res.json();
if (!json.success) {
throw new Error(json.message || "请求失败");
}
return json.data;
},
/**
* 获取所有项目
* @returns {Promise<Array>} 项目列表
*/
getProjects() {
return this.request("/api/projects");
},
/**
* 获取单个项目
* @param {string} id - 项目 ID
* @returns {Promise<Object>} 项目对象
*/
getProject(id) {
return this.request(`/api/projects/${id}`);
},
/**
* 创建项目
* @param {Object} data - 项目数据
* @returns {Promise<Object>} 创建的项目
*/
createProject(data) {
return this.request("/api/projects", { method: "POST", body: data });
},
/**
* 删除项目
* @param {string} id - 项目 ID
* @returns {Promise<void>}
*/
deleteProject(id) {
return this.request(`/api/projects/${id}`, { method: "DELETE" });
},
/**
* 更新项目维度映射方式
* @param {string} id - 项目 ID
* @param {Object} mappings - { idcMapping, environmentMapping, groupMapping }
* @returns {Promise<Object>} 更新后的项目
*/
updateMappings(id, mappings) {
return this.request(`/api/projects/${id}/mappings`, { method: "PUT", body: mappings });
},
/**
* 获取项目下所有配置
* @param {string} projectId - 项目 ID
* @returns {Promise<Array>} 配置项列表
*/
getConfigs(projectId) {
return this.request(`/api/projects/${projectId}/configs`);
},
/**
* 新增配置项
* @param {string} projectId - 项目 ID
* @param {Object} data - 配置项数据
* @returns {Promise<Object>} 创建的配置项
*/
createConfig(projectId, data) {
return this.request(`/api/projects/${projectId}/configs`, { method: "POST", body: data });
},
/**
* 更新配置项
* @param {string} projectId - 项目 ID
* @param {string} configId - 配置项 ID
* @param {Object} data - 要更新的字段
* @returns {Promise<Object>} 更新后的配置项
*/
updateConfig(projectId, configId, data) {
return this.request(`/api/projects/${projectId}/configs/${configId}`, { method: "PUT", body: data });
},
/**
* 删除配置项
* @param {string} projectId - 项目 ID
* @param {string} configId - 配置项 ID
* @returns {Promise<void>}
*/
deleteConfig(projectId, configId) {
return this.request(`/api/projects/${projectId}/configs/${configId}`, { method: "DELETE" });
},
/**
* 获取当前模式
* @returns {Promise<Object>} { mode }
*/
getMode() {
return this.request("/api/mode");
},
/**
* 切换模式
* @param {string} mode - 目标模式(plan/edit/readonly)
* @returns {Promise<Object>} { mode }
*/
setMode(mode) {
return this.request("/api/mode", { method: "PUT", body: { mode } });
},
};
/**
* API 调用封装
* 统一处理与后端 REST 接口的交互,返回解析后的数据或抛出错误
*/
const API = {
/**
* 通用请求方法
* @param {string} url - 请求路径
* @param {Object} options - fetch 选项
* @returns {Promise<any>} 响应数据
*/
async request(url, options = {}) {
const headers = { "Content-Type": "application/json" };
// 自动携带认证 token
const token = localStorage.getItem("registry_token");
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const opts = { headers, ...options };
if (opts.body && typeof opts.body !== "string") {
opts.body = JSON.stringify(opts.body);
}
const res = await fetch(url, opts);
// 401 未登录,跳转登录页
if (res.status === 401 && !url.includes("/api/auth/")) {
localStorage.removeItem("registry_token");
localStorage.removeItem("registry_user");
window.location.href = "/login.html";
throw new Error("登录已过期,请重新登录");
}
const json = await res.json();
if (!json.success) {
throw new Error(json.message || "请求失败");
}
return json.data;
},
// ========== 认证 ==========
login(username, password) {
return this.request("/api/auth/login", { method: "POST", body: { username, password } });
},
logout() {
return this.request("/api/auth/logout", { method: "POST" });
},
getMe() {
return this.request("/api/auth/me");
},
// ========== 项目 ==========
getProjects() {
return this.request("/api/projects");
},
getProject(id) {
return this.request(`/api/projects/${id}`);
},
createProject(data) {
return this.request("/api/projects", { method: "POST", body: data });
},
deleteProject(id) {
return this.request(`/api/projects/${id}`, { method: "DELETE" });
},
updateMappings(id, mappings) {
return this.request(`/api/projects/${id}/mappings`, { method: "PUT", body: mappings });
},
// ========== 配置 ==========
getConfigs(projectId) {
return this.request(`/api/projects/${projectId}/configs`);
},
createConfig(projectId, data) {
return this.request(`/api/projects/${projectId}/configs`, { method: "POST", body: data });
},
updateConfig(projectId, configId, data) {
return this.request(`/api/projects/${projectId}/configs/${configId}`, { method: "PUT", body: data });
},
deleteConfig(projectId, configId) {
return this.request(`/api/projects/${projectId}/configs/${configId}`, { method: "DELETE" });
},
// ========== 模式 ==========
getMode() {
return this.request("/api/mode");
},
setMode(mode) {
return this.request("/api/mode", { method: "PUT", body: { mode } });
},
// ========== 用户管理 ==========
getUsers() {
return this.request("/api/users");
},
createUser(data) {
return this.request("/api/users", { method: "POST", body: data });
},
updateUser(id, data) {
return this.request(`/api/users/${id}`, { method: "PUT", body: data });
},
resetPassword(id, password) {
return this.request(`/api/users/${id}/password`, { method: "PUT", body: { password } });
},
deleteUser(id) {
return this.request(`/api/users/${id}`, { method: "DELETE" });
},
// ========== Token 管理 ==========
getTokens() {
return this.request("/api/tokens");
},
createToken(data) {
return this.request("/api/tokens", { method: "POST", body: data });
},
updateToken(id, data) {
return this.request(`/api/tokens/${id}`, { method: "PUT", body: data });
},
revokeToken(id) {
return this.request(`/api/tokens/${id}/revoke`, { method: "PUT" });
},
deleteToken(id) {
return this.request(`/api/tokens/${id}`, { method: "DELETE" });
},
};
+95
View File
@@ -0,0 +1,95 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 - Registry 配置管理中心</title>
<link rel="stylesheet" href="/css/style.css">
<style>
body.login-page {
background: linear-gradient(135deg, #003a70 0%, #0a4a86 48%, #0b63a8 100%);
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
}
.login-wrap { width: 400px; max-width: calc(100vw - 32px); }
.login-card { background: #fff; border-radius: 8px; box-shadow: 0 18px 50px rgba(0,0,0,0.35); overflow: hidden; }
.login-card-header { background: linear-gradient(to bottom, #0a4a86, #003a70); color: #fff; padding: 28px 24px 22px; text-align: center; }
.login-logo { font-size: 36px; line-height: 1; }
.login-card-header h1 { font-size: 18px; font-weight: 600; margin-top: 10px; }
.login-card-header p { font-size: 12px; color: #cfe3f7; margin-top: 4px; }
.login-card-body { padding: 24px; }
.login-card-body .form-group { margin-bottom: 16px; }
.login-card-body .form-group input { height: 36px; }
.login-error { display: none; margin-bottom: 14px; padding: 8px 12px; border: 1px solid #f2b8b5; background: #fdecea; color: #c62828; border-radius: 3px; font-size: 12px; }
.login-error.show { display: block; }
.login-btn { width: 100%; height: 38px; font-size: 14px; font-weight: 600; }
.login-footer { margin-top: 16px; text-align: center; font-size: 11px; color: rgba(255,255,255,0.65); }
</style>
</head>
<body class="login-page">
<div class="login-wrap">
<div class="login-card">
<div class="login-card-header">
<div class="login-logo">📋</div>
<h1>Registry 配置管理中心</h1>
<p>微服务配置集中化 · 可视化 · 规范化管理</p>
</div>
<div class="login-card-body">
<div class="login-error" id="login-error"></div>
<form id="login-form" autocomplete="off">
<div class="form-group">
<label>用户名 <span class="required">*</span></label>
<input type="text" id="username" placeholder="请输入用户名" autofocus>
</div>
<div class="form-group">
<label>密码 <span class="required">*</span></label>
<input type="password" id="password" placeholder="请输入密码">
</div>
<button type="submit" class="btn btn-primary login-btn" id="btn-login">登 录</button>
</form>
</div>
</div>
<div class="login-footer">Registry Config Center</div>
</div>
<script>
// 已登录则直接跳转
if (localStorage.getItem("registry_token")) {
window.location.href = "/projects.html";
}
document.getElementById("login-form").addEventListener("submit", async function (e) {
e.preventDefault();
var error = document.getElementById("login-error");
var btn = document.getElementById("btn-login");
var username = document.getElementById("username").value.trim();
var password = document.getElementById("password").value;
error.classList.remove("show");
if (!username || !password) {
error.textContent = "请输入用户名和密码";
error.classList.add("show");
return;
}
btn.disabled = true;
btn.textContent = "登录中...";
try {
var data = await API.login(username, password);
localStorage.setItem("registry_token", data.token);
localStorage.setItem("registry_user", JSON.stringify(data.user));
window.location.href = "/projects.html";
} catch (err) {
error.textContent = err.message || "登录失败";
error.classList.add("show");
btn.disabled = false;
btn.textContent = "登 录";
}
});
</script>
<script src="/js/api.js"></script>
</body>
</html>
+315
View File
@@ -0,0 +1,315 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>项目管理 - Registry 配置管理中心</title>
<link rel="stylesheet" href="/css/style.css">
<style>
/* 标题栏右侧 */
.titlebar-right { margin-left: auto; display: flex; align-items: center; gap: 14px; font-size: 12px; }
.titlebar-user { color: #e8f1fb; display: flex; align-items: center; gap: 6px; }
.role-badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 11px; background: rgba(255,255,255,0.18); border: 1px solid rgba(255,255,255,0.35); }
.role-badge.admin { background: #ffb900; border-color: #ffd666; color: #4a3200; font-weight: 600; }
.titlebar-link { color: #cfe3f7; text-decoration: none; cursor: pointer; padding-left: 14px; border-left: 1px solid rgba(255,255,255,0.3); }
.titlebar-link:hover { color: #fff; text-decoration: underline; }
/* 内容区 */
.page-content { flex: 1; overflow: auto; padding: 18px; }
.page-heading { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 14px; }
.page-heading h2 { font-size: 16px; color: #003a70; }
.page-heading .sub { font-size: 12px; color: #888; }
.content-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; flex-wrap: wrap; }
.content-toolbar .spacer { flex: 1; }
.search-input { height: 30px; width: 240px; padding: 0 10px; border: 1px solid #adadad; border-radius: 3px; font-size: 13px; font-family: inherit; }
.filter-select { height: 30px; padding: 0 8px; border: 1px solid #adadad; border-radius: 3px; background: #fff; font-size: 12px; }
/* 项目卡片 */
.projects-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 14px; }
.project-card { background: #fff; border: 1px solid #dcdcdc; border-radius: 6px; padding: 14px 16px 12px; cursor: pointer; transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s; display: flex; flex-direction: column; }
.project-card:hover { border-color: #5b9bd5; box-shadow: 0 4px 14px rgba(0,120,212,0.15); transform: translateY(-1px); }
.project-card-top { display: flex; align-items: flex-start; gap: 10px; }
.project-icon { width: 34px; height: 34px; border-radius: 6px; background: #e5f1fb; color: #0078d4; display: flex; align-items: center; justify-content: center; font-size: 17px; flex-shrink: 0; }
.project-card-title { font-size: 15px; font-weight: 600; color: #003a70; line-height: 1.3; }
.project-card-id { font-family: Consolas, monospace; font-size: 11px; color: #999; margin-top: 2px; }
.project-card-desc { margin-top: 10px; font-size: 12px; color: #666; line-height: 1.5; min-height: 36px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.project-card-badges { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
.dim-chip { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 11px; background: #eef4fb; color: #0a4a86; border: 1px solid #d0dced; }
.project-card-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 12px; padding-top: 10px; border-top: 1px solid #f0f0f0; font-size: 11px; color: #999; }
.project-card-actions { display: flex; gap: 6px; opacity: 0; transition: opacity 0.15s; }
.project-card:hover .project-card-actions { opacity: 1; }
.empty-state { text-align: center; padding: 60px 20px; color: #999; }
.empty-state .empty-icon { font-size: 36px; opacity: 0.4; }
.empty-state p { margin-top: 8px; font-size: 13px; }
</style>
</head>
<body>
<header class="titlebar">
<span class="app-title">📋 Registry 配置管理中心</span>
<span class="current-project">项目管理</span>
<div class="titlebar-right">
<span class="titlebar-user">
👤 <span id="user-name"></span>
<span class="role-badge" id="user-role"></span>
</span>
<a class="titlebar-link" id="admin-entry" href="/admin.html" style="display:none">⚙ 系统管理</a>
<a class="titlebar-link" id="logout-link" href="#">退出登录</a>
</div>
</header>
<div class="toolbar">
<div class="tool-group">
<span class="group-label">项目</span>
<button class="toolbar-btn" id="btn-refresh" title="刷新列表">🔄 刷新</button>
<button class="toolbar-btn" id="btn-new-project" title="新建项目">📁 新建</button>
</div>
<div class="tool-group">
<span class="group-label" id="project-count">共 0 个项目</span>
</div>
</div>
<div class="page-content">
<div class="page-heading">
<h2>我的项目</h2>
<span class="sub">选择一个项目进入配置管理</span>
</div>
<div class="content-toolbar">
<input type="text" class="search-input" id="project-search" placeholder="🔍 搜索项目名称或描述">
<span class="spacer"></span>
<button class="btn btn-primary" style="height:30px" id="btn-new-project-2">➕ 新建项目</button>
</div>
<div class="projects-grid" id="projects-grid"></div>
</div>
<footer class="statusbar">
<span id="status-text">加载中...</span>
<span id="status-user"></span>
</footer>
<!-- 新建项目模态框(复用 index.html 中的结构) -->
<div class="modal-overlay" id="modal-project" style="display:none">
<div class="modal">
<div class="modal-header">
<h3>新建项目</h3>
<button class="modal-close" onclick="closeModal('modal-project')">✕</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>项目名称 <span class="required">*</span></label>
<input type="text" id="project-name" placeholder="例如:用户服务">
</div>
<div class="form-group">
<label>项目描述</label>
<textarea id="project-description" rows="2" placeholder="项目配置说明"></textarea>
</div>
<div class="form-group">
<label>维度配置 <span class="hint">(创建后不可修改)</span></label>
<div class="dim-config-row">
<label class="checkbox-label"><input type="checkbox" id="project-enable-idc" checked> 机房</label>
<select id="project-idc-mapping" class="mapping-select"><option value="path">映射为路径</option><option value="label">仅作标签</option></select>
</div>
<div class="dim-config-row">
<label class="checkbox-label"><input type="checkbox" id="project-enable-env" checked> 环境</label>
<select id="project-env-mapping" class="mapping-select"><option value="path">映射为路径</option><option value="label">仅作标签</option></select>
</div>
<div class="dim-config-row">
<label class="checkbox-label"><input type="checkbox" id="project-enable-group" checked> 分组</label>
<select id="project-group-mapping" class="mapping-select"><option value="path">映射为路径</option><option value="label">仅作标签</option></select>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('modal-project')">取消</button>
<button class="btn btn-primary" id="btn-save-project">保存</button>
</div>
</div>
</div>
<!-- 确认删除模态框 -->
<div class="modal-overlay" id="modal-confirm" style="display:none">
<div class="modal" style="width:360px">
<div class="modal-header">
<h3>确认操作</h3>
<button class="modal-close" onclick="closeModal('modal-confirm')">✕</button>
</div>
<div class="modal-body"><p id="confirm-message"></p></div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('modal-confirm')">取消</button>
<button class="btn btn-danger" id="btn-confirm-ok">确认</button>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script>
var currentUser = null;
var confirmCallback = null;
function toast(message, type) {
var el = document.createElement("div");
el.className = "toast toast-" + (type || "info");
el.textContent = message;
document.body.appendChild(el);
setTimeout(function () { el.remove(); }, 2500);
}
function openModal(id) { document.getElementById(id).style.display = "flex"; }
function closeModal(id) { document.getElementById(id).style.display = "none"; }
function openConfirm(message, callback) {
document.getElementById("confirm-message").textContent = message;
confirmCallback = callback;
openModal("modal-confirm");
}
document.getElementById("btn-confirm-ok").addEventListener("click", function () {
closeModal("modal-confirm");
if (confirmCallback) confirmCallback();
confirmCallback = null;
});
// 初始化
async function init() {
try {
var data = await API.getMe();
currentUser = data;
} catch (e) {
window.location.href = "/login.html";
return;
}
document.getElementById("user-name").textContent = currentUser.displayName || currentUser.username;
var roleEl = document.getElementById("user-role");
if (currentUser.role === "admin") {
roleEl.textContent = "管理员";
roleEl.className = "role-badge admin";
document.getElementById("admin-entry").style.display = "";
}
document.getElementById("status-user").textContent = currentUser.username;
await loadProjects();
}
async function loadProjects() {
try {
var projects = await API.getProjects();
renderProjects(projects);
} catch (err) {
toast("加载项目失败: " + err.message, "error");
}
}
function renderProjects(projects) {
var grid = document.getElementById("projects-grid");
if (!projects.length) {
grid.innerHTML = '<div class="empty-state"><div class="empty-icon">📁</div><p>暂无项目,点击「新建项目」创建第一个</p></div>';
document.getElementById("project-count").textContent = "共 0 个项目";
document.getElementById("status-text").textContent = "已加载 0 个项目";
return;
}
var dims = [];
grid.innerHTML = projects.map(function (p) {
dims = [];
if (p.enableIdc) dims.push("🏢 机房");
if (p.enableEnvironment) dims.push("🌐 环境");
if (p.enableGroup) dims.push("🗂 分组");
var badges = dims.map(function (d) { return '<span class="dim-chip">' + d + "</span>"; }).join("");
var time = p.updatedAt ? new Date(p.updatedAt).toLocaleDateString("zh-CN") : "";
return '<div class="project-card" data-id="' + p.id + '" onclick="openProject(\'' + p.id + "')\">" +
'<div class="project-card-top">' +
'<div class="project-icon">📦</div>' +
'<div><div class="project-card-title">' + esc(p.name) + '</div>' +
'<div class="project-card-id">' + esc(p.id) + "</div></div>" +
"</div>" +
'<div class="project-card-desc">' + esc(p.description || "") + "</div>" +
'<div class="project-card-badges">' + badges + "</div>" +
'<div class="project-card-footer">' +
"<span>" + time + "</span>" +
'<div class="project-card-actions">' +
'<button class="row-btn" onclick="event.stopPropagation();openProject(\'' + p.id + "')\">进入</button>" +
'<button class="row-btn danger" onclick="event.stopPropagation();confirmDelete(\'' + p.id + "', '" + esc(p.name) + "')\">删除</button>" +
"</div>" +
"</div>" +
"</div>";
}).join("");
document.getElementById("project-count").textContent = "共 " + projects.length + " 个项目";
document.getElementById("status-text").textContent = "已加载 " + projects.length + " 个项目";
}
function esc(s) {
var d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function openProject(id) {
window.location.href = "/index.html?project=" + encodeURIComponent(id);
}
function confirmDelete(id, name) {
openConfirm("确认删除项目「" + name + "」?此操作将同时删除该项目下的所有配置,且不可恢复。", async function () {
try {
await API.deleteProject(id);
toast("项目已删除", "success");
loadProjects();
} catch (err) {
toast("删除失败: " + err.message, "error");
}
});
}
// 新建项目
document.getElementById("btn-new-project").addEventListener("click", function () { openModal("modal-project"); });
document.getElementById("btn-new-project-2").addEventListener("click", function () { openModal("modal-project"); });
document.getElementById("btn-save-project").addEventListener("click", async function () {
var name = document.getElementById("project-name").value.trim();
var desc = document.getElementById("project-description").value.trim();
if (!name) { toast("请输入项目名称", "error"); return; }
try {
await API.createProject({
name: name,
description: desc,
enableIdc: document.getElementById("project-enable-idc").checked,
enableEnvironment: document.getElementById("project-enable-env").checked,
enableGroup: document.getElementById("project-enable-group").checked,
idcMapping: document.getElementById("project-idc-mapping").value,
environmentMapping: document.getElementById("project-env-mapping").value,
groupMapping: document.getElementById("project-group-mapping").value,
});
closeModal("modal-project");
document.getElementById("project-name").value = "";
document.getElementById("project-description").value = "";
toast("项目创建成功", "success");
loadProjects();
} catch (err) {
toast("创建失败: " + err.message, "error");
}
});
// 搜索
document.getElementById("project-search").addEventListener("input", function () {
var kw = this.value.trim().toLowerCase();
var cards = document.querySelectorAll(".project-card");
var visible = 0;
cards.forEach(function (card) {
var text = card.textContent.toLowerCase();
var show = !kw || text.indexOf(kw) !== -1;
card.style.display = show ? "" : "none";
if (show) visible++;
});
document.getElementById("project-count").textContent = "共 " + visible + " 个项目";
});
// 退出登录
document.getElementById("logout-link").addEventListener("click", async function (e) {
e.preventDefault();
try { await API.logout(); } catch (e) {}
localStorage.removeItem("registry_token");
localStorage.removeItem("registry_user");
window.location.href = "/login.html";
});
document.getElementById("btn-refresh").addEventListener("click", loadProjects);
init();
</script>
</body>
</html>