feat: Token 支持多项目关联且全部为 MCP 令牌;配置操作全量审计,支持按项目/键查询
build-and-deploy / build-and-deploy (push) Successful in 40s
build-and-deploy / build-and-deploy (push) Successful in 40s
This commit is contained in:
+69
-39
@@ -117,7 +117,7 @@
|
||||
<table class="data-table" id="token-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th><th>关联项目</th><th>权限</th><th>MCP 访问</th><th>Token</th>
|
||||
<th>名称</th><th>关联项目</th><th>权限</th><th>Token</th>
|
||||
<th>状态</th><th>过期时间</th><th>创建时间</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -211,8 +211,13 @@
|
||||
<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>
|
||||
<label>关联项目 <span class="hint">(可勾选全部或多个已存在项目)</span></label>
|
||||
<label class="checkbox-label" style="display:flex;align-items:center;gap:8px;cursor:pointer;margin-bottom:6px">
|
||||
<input type="checkbox" id="input-token-all" style="width:16px;height:16px">
|
||||
<span>全部项目</span>
|
||||
</label>
|
||||
<div id="token-project-list" style="display:flex;flex-direction:column;gap:6px;max-height:160px;overflow:auto">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group form-half">
|
||||
@@ -229,13 +234,7 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label" style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="input-token-mcp" style="width:16px;height:16px">
|
||||
<span>作为 MCP 访问令牌(用于 /mcp 端点鉴权)</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="hint">生成后 Token 明文仅展示一次,请及时复制并妥善保管。</p>
|
||||
<p class="hint">所有 Token 均可作为 MCP 访问令牌用于 /mcp 端点鉴权。</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal('modal-token')">取消</button>
|
||||
@@ -480,19 +479,61 @@
|
||||
}
|
||||
}
|
||||
|
||||
var tokenProjects = [];
|
||||
|
||||
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("");
|
||||
tokenProjects = await API.getProjects();
|
||||
var list = document.getElementById("token-project-list");
|
||||
list.innerHTML = tokenProjects.map(function (p) {
|
||||
return '<label class="checkbox-label" style="display:flex;align-items:center;gap:8px;cursor:pointer">' +
|
||||
'<input type="checkbox" class="token-proj-check" value="' + p.id + '" style="width:14px;height:14px"> ' +
|
||||
'<span>' + esc(p.name) + "</span></label>";
|
||||
}).join("");
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function getSelectedProjectIds() {
|
||||
if (document.getElementById("input-token-all").checked) return ["all"];
|
||||
var ids = [];
|
||||
document.querySelectorAll(".token-proj-check:checked").forEach(function (c) { ids.push(c.value); });
|
||||
return ids.length ? ids : ["all"];
|
||||
}
|
||||
|
||||
function setSelectedProjectIds(ids) {
|
||||
var allCheck = document.getElementById("input-token-all");
|
||||
if (!ids || !ids.length || ids.indexOf("all") !== -1) {
|
||||
allCheck.checked = true;
|
||||
document.querySelectorAll(".token-proj-check").forEach(function (c) { c.checked = false; c.disabled = true; });
|
||||
} else {
|
||||
allCheck.checked = false;
|
||||
document.querySelectorAll(".token-proj-check").forEach(function (c) {
|
||||
c.checked = ids.indexOf(c.value) !== -1;
|
||||
c.disabled = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("input-token-all").addEventListener("change", function () {
|
||||
var checked = this.checked;
|
||||
document.querySelectorAll(".token-proj-check").forEach(function (c) {
|
||||
c.checked = checked ? false : c.checked;
|
||||
c.disabled = checked;
|
||||
});
|
||||
});
|
||||
|
||||
function projectNames(projectIds) {
|
||||
if (!projectIds || !projectIds.length || projectIds.indexOf("all") !== -1) return "全部项目";
|
||||
return projectIds.map(function (id) {
|
||||
var p = tokenProjects.find(function (x) { return x.id === id; });
|
||||
return p ? p.name : id;
|
||||
}).join("、");
|
||||
}
|
||||
|
||||
function renderTokens(tokens) {
|
||||
var tbody = document.getElementById("token-tbody");
|
||||
if (!tokens.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;color:#999;padding:30px">暂无 Token</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:#999;padding:30px">暂无 Token</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = tokens.map(function (t) {
|
||||
@@ -503,11 +544,8 @@
|
||||
var masked = t.value.substring(0, 6) + "••••••••••••";
|
||||
return "<tr>" +
|
||||
"<td>" + esc(t.name) + "</td>" +
|
||||
"<td>" + esc(t.projectId === "all" ? "全部项目" : t.projectId) + "</td>" +
|
||||
"<td>" + esc(projectNames(t.projectIds)) + "</td>" +
|
||||
'<td><span class="dim-chip">' + (t.permission === "readwrite" ? "读写" : "只读") + "</span></td>" +
|
||||
'<td>' + (t.mcpEnabled
|
||||
? '<span class="dim-chip" style="color:#0a7a3c;border-color:#0a7a3c">MCP</span>'
|
||||
: '<span style="color:#aaa">—</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>' +
|
||||
@@ -517,7 +555,7 @@
|
||||
"<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, mcpEnabled: !!t.mcpEnabled }).replace(/'/g, "'") + ")'>编辑</button> " +
|
||||
'<button class="row-btn" onclick=\'editToken(' + JSON.stringify({ id: t.id, name: t.name, projectIds: t.projectIds || ["all"], permission: t.permission }).replace(/'/g, "'") + ")'>编辑</button> " +
|
||||
(t.enabled
|
||||
? '<button class="row-btn danger" onclick="revokeToken(\'' + t.id + "')\">吊销</button>"
|
||||
: "") +
|
||||
@@ -565,12 +603,10 @@
|
||||
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("input-token-mcp").checked = false;
|
||||
document.getElementById("btn-save-token").textContent = "生成";
|
||||
loadProjectsForSelect();
|
||||
loadProjectsForSelect().then(function () { setSelectedProjectIds(["all"]); });
|
||||
openModal("modal-token");
|
||||
});
|
||||
|
||||
@@ -578,11 +614,10 @@
|
||||
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("input-token-mcp").checked = !!(t.mcpEnabled);
|
||||
document.getElementById("btn-save-token").textContent = "保存";
|
||||
loadProjectsForSelect();
|
||||
var ids = t.projectIds;
|
||||
loadProjectsForSelect().then(function () { setSelectedProjectIds(ids); });
|
||||
openModal("modal-token");
|
||||
};
|
||||
|
||||
@@ -590,25 +625,20 @@
|
||||
var id = document.getElementById("edit-token-id").value;
|
||||
var name = document.getElementById("input-token-name").value.trim();
|
||||
if (!name) { toast("请输入 Token 名称", "error"); return; }
|
||||
var payload = {
|
||||
name: name,
|
||||
projectIds: getSelectedProjectIds(),
|
||||
permission: document.getElementById("input-token-permission").value,
|
||||
};
|
||||
try {
|
||||
if (id) {
|
||||
await API.updateToken(id, {
|
||||
name: name,
|
||||
projectId: document.getElementById("input-token-project").value,
|
||||
permission: document.getElementById("input-token-permission").value,
|
||||
mcpEnabled: document.getElementById("input-token-mcp").checked,
|
||||
});
|
||||
await API.updateToken(id, payload);
|
||||
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,
|
||||
mcpEnabled: document.getElementById("input-token-mcp").checked,
|
||||
expiresInDays: parseInt(document.getElementById("input-token-expiry").value) || 0,
|
||||
});
|
||||
payload.expiresInDays = parseInt(document.getElementById("input-token-expiry").value) || 0;
|
||||
var data = await API.createToken(payload);
|
||||
closeModal("modal-token");
|
||||
document.getElementById("reveal-token-value").textContent = data.value;
|
||||
openModal("modal-token-reveal");
|
||||
|
||||
+28
-3
@@ -45,8 +45,9 @@
|
||||
|
||||
<div class="tool-group" title="数据操作">
|
||||
<span class="group-label">数据</span>
|
||||
<button id="btn-export-config" class="toolbar-btn" title="导出配置">📥 导出</button>
|
||||
<button id="btn-import-config" class="toolbar-btn" title="导入配置">📤 导入</button>
|
||||
<button id="btn-export-config" class="toolbar-btn" title="导出配置">📥 导出</button>
|
||||
<button id="btn-import-config" class="toolbar-btn" title="导入配置">📤 导入</button>
|
||||
<button id="btn-audit" class="toolbar-btn" title="查看当前项目操作记录">📜 操作记录</button>
|
||||
<input type="file" id="import-file-input" accept=".json" style="display:none">
|
||||
</div>
|
||||
</div>
|
||||
@@ -303,7 +304,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模态框:确认删除 -->
|
||||
<div class="modal-overlay" id="modal-audit" style="display:none">
|
||||
<div class="modal" style="max-width:920px">
|
||||
<div class="modal-header">
|
||||
<h3>操作记录 <span id="audit-title" style="font-weight:normal;font-size:12px;color:#999"></span></h3>
|
||||
<button class="modal-close" onclick="closeModal('modal-audit')">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div style="overflow:auto;max-height:480px">
|
||||
<table class="config-table" id="audit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th><th>方式</th><th>操作者</th><th>IP</th><th>操作</th><th>键</th><th>值</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="audit-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal('modal-audit')">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模态框:确认删除 -->
|
||||
<div class="modal-overlay" id="modal-confirm" style="display:none">
|
||||
<div class="modal modal-sm">
|
||||
<div class="modal-header">
|
||||
|
||||
@@ -93,6 +93,11 @@ const API = {
|
||||
return this.request(`/api/projects/${projectId}/configs/${configId}`, { method: "DELETE" });
|
||||
},
|
||||
|
||||
getAudit(projectId, key) {
|
||||
const qs = key ? `?key=${encodeURIComponent(key)}` : "";
|
||||
return this.request(`/api/projects/${projectId}/configs/audit${qs}`);
|
||||
},
|
||||
|
||||
// ========== 模式 ==========
|
||||
|
||||
getMode() {
|
||||
|
||||
+102
-26
@@ -274,10 +274,11 @@ function renderConfigTable(configs, path) {
|
||||
<td><span class="type-badge">${escapeHtml(cfg.type)}</span></td>
|
||||
<td class="config-status-cell">${statusHtml.join("")}</td>
|
||||
<td>${escapeHtml(cfg.description)}</td>
|
||||
<td>
|
||||
<button class="row-btn view" data-action="view" data-id="${cfg.id}">查看</button>
|
||||
<button class="row-btn" data-action="edit" data-id="${cfg.id}">编辑</button>
|
||||
<button class="row-btn danger" data-action="delete" data-id="${cfg.id}">删除</button>
|
||||
<td>
|
||||
<button class="row-btn view" data-action="view" data-id="${cfg.id}">查看</button>
|
||||
<button class="row-btn" data-action="edit" data-id="${cfg.id}">编辑</button>
|
||||
<button class="row-btn" data-action="delete" data-id="${cfg.id}">删除</button>
|
||||
<button class="row-btn" data-action="audit" data-id="${cfg.id}">记录</button>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
@@ -670,9 +671,79 @@ async function copyAddress() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定所有事件监听
|
||||
* @returns {void}
|
||||
/**
|
||||
* 打开操作记录弹窗
|
||||
* @param {string|null} key - 为空时查当前项目全部记录;否则查指定配置键的记录
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function openAudit(key) {
|
||||
if (!state.currentProject) {
|
||||
toast("请先选择项目", "error");
|
||||
return;
|
||||
}
|
||||
document.getElementById("audit-title").textContent = key
|
||||
? `(${state.currentProject.name} · ${key})`
|
||||
: `(${state.currentProject.name})`;
|
||||
openModal("modal-audit");
|
||||
try {
|
||||
const logs = await API.getAudit(state.currentProject.id, key || "");
|
||||
renderAudit(logs);
|
||||
} catch (err) {
|
||||
toast("加载操作记录失败: " + err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染操作记录表格
|
||||
* @param {Array} logs - 操作记录列表
|
||||
* @returns {void}
|
||||
*/
|
||||
function renderAudit(logs) {
|
||||
const tbody = document.getElementById("audit-tbody");
|
||||
if (!logs || !logs.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#999;padding:24px">暂无操作记录</td></tr>';
|
||||
return;
|
||||
}
|
||||
const ACTION_NAMES = { read: "读取", create: "新增", update: "更新", delete: "删除" };
|
||||
const SOURCE_NAMES = { web: "网页", mcp: "MCP" };
|
||||
tbody.innerHTML = logs
|
||||
.map((l) => {
|
||||
const act = ACTION_NAMES[l.action] || l.action;
|
||||
const src = SOURCE_NAMES[l.source] || l.source;
|
||||
const val = l.value !== undefined && l.value !== null && l.value !== "" ? String(l.value) : "—";
|
||||
return (
|
||||
"<tr>" +
|
||||
`<td>${escapeHtml(fmtTime(l.createdAt))}</td>` +
|
||||
`<td>${escapeHtml(src)}</td>` +
|
||||
`<td>${escapeHtml(l.actor || "")}</td>` +
|
||||
`<td>${l.ip ? escapeHtml(l.ip) : "—"}</td>` +
|
||||
`<td>${escapeHtml(act)}</td>` +
|
||||
`<td>${escapeHtml(l.key || "")}</td>` +
|
||||
`<td class="config-value-cell">${escapeHtml(val.length > 80 ? val.slice(0, 80) + "…" : val)}</td>` +
|
||||
"</tr>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间:YYYY-MM-DD HH:mm:ss
|
||||
* @param {string} iso - ISO 时间
|
||||
* @returns {string}
|
||||
*/
|
||||
function fmtTime(iso) {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
const pad = (n) => (n < 10 ? "0" + n : n);
|
||||
return (
|
||||
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
|
||||
`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定所有事件监听
|
||||
* @returns {void}
|
||||
*/
|
||||
function bindEvents() {
|
||||
// 项目选择
|
||||
@@ -784,17 +855,20 @@ function bindEvents() {
|
||||
if (e.key === "Enter") navigateToPath();
|
||||
});
|
||||
|
||||
// 导出/导入
|
||||
document.getElementById("btn-export-config").addEventListener("click", exportConfig);
|
||||
document.getElementById("btn-import-config").addEventListener("click", () => {
|
||||
document.getElementById("import-file-input").click();
|
||||
});
|
||||
document.getElementById("import-file-input").addEventListener("change", (e) => {
|
||||
if (e.target.files[0]) {
|
||||
importConfig(e.target.files[0]);
|
||||
e.target.value = "";
|
||||
}
|
||||
});
|
||||
// 导出/导入
|
||||
document.getElementById("btn-export-config").addEventListener("click", exportConfig);
|
||||
document.getElementById("btn-import-config").addEventListener("click", () => {
|
||||
document.getElementById("import-file-input").click();
|
||||
});
|
||||
document.getElementById("import-file-input").addEventListener("change", (e) => {
|
||||
if (e.target.files[0]) {
|
||||
importConfig(e.target.files[0]);
|
||||
e.target.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
// 操作记录
|
||||
document.getElementById("btn-audit").addEventListener("click", () => openAudit(null));
|
||||
|
||||
// 表格操作(事件委托)
|
||||
document.getElementById("config-tbody").addEventListener("click", (e) => {
|
||||
@@ -805,14 +879,16 @@ function bindEvents() {
|
||||
const config = state.configs.find((c) => c.id === id);
|
||||
if (!config) return;
|
||||
|
||||
if (action === "view") {
|
||||
openConfigModal(config, true);
|
||||
} else if (action === "edit") {
|
||||
if (state.mode === "readonly") {
|
||||
toast("只读模式下不允许编辑", "error");
|
||||
return;
|
||||
}
|
||||
openConfigModal(config, false);
|
||||
if (action === "view") {
|
||||
openConfigModal(config, true);
|
||||
} else if (action === "edit") {
|
||||
if (state.mode === "readonly") {
|
||||
toast("只读模式下不允许编辑", "error");
|
||||
return;
|
||||
}
|
||||
openConfigModal(config, false);
|
||||
} else if (action === "audit") {
|
||||
openAudit(config.key);
|
||||
} else if (action === "delete") {
|
||||
if (state.mode === "readonly") {
|
||||
toast("只读模式下不允许删除", "error");
|
||||
|
||||
Reference in New Issue
Block a user