feat: 访问记录后台分页,支持时间范围/操作/键过滤
build-and-deploy / build-and-deploy (push) Successful in 43s

This commit is contained in:
jif2.zhang
2026-09-21 10:49:55 +08:00
parent ed1457e582
commit dc4c67e386
8 changed files with 304 additions and 42 deletions
+49
View File
@@ -494,6 +494,55 @@ body {
max-height: 70vh;
}
/* 访问记录:过滤栏与分页 */
.audit-filters {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 12px;
}
.audit-filters input,
.audit-filters select {
height: 28px;
padding: 0 8px;
border: 1px solid #adadad;
border-radius: 3px;
font-size: 13px;
background: #fff;
}
.audit-filters #audit-filter-key {
min-width: 180px;
}
.audit-filters input[type="datetime-local"] {
padding: 0 4px;
font-size: 12px;
}
.audit-pagination {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
margin-top: 12px;
font-size: 12px;
color: #555;
}
.audit-pagination .btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.audit-pagination select {
height: 26px;
border: 1px solid #adadad;
border-radius: 3px;
}
.modal-header {
display: flex;
align-items: center;
+26 -1
View File
@@ -311,7 +311,22 @@
<button class="modal-close" onclick="closeModal('modal-audit')">✕</button>
</div>
<div class="modal-body">
<div style="overflow:auto;max-height:480px">
<div class="audit-filters">
<input type="text" id="audit-filter-key" placeholder="键名(模糊匹配)">
<select id="audit-filter-action">
<option value="">全部操作</option>
<option value="read">读取</option>
<option value="create">新增</option>
<option value="update">更新</option>
<option value="delete">删除</option>
</select>
<input type="datetime-local" id="audit-filter-start">
<span style="color:#999">至</span>
<input type="datetime-local" id="audit-filter-end">
<button class="btn btn-primary" id="btn-audit-query">查询</button>
<button class="btn btn-secondary" id="btn-audit-reset">重置</button>
</div>
<div style="overflow:auto;max-height:52vh">
<table class="config-table" id="audit-table">
<thead>
<tr>
@@ -321,6 +336,16 @@
<tbody id="audit-tbody"></tbody>
</table>
</div>
<div class="audit-pagination">
<button class="btn btn-secondary" id="btn-audit-prev">上一页</button>
<span id="audit-page-info"></span>
<button class="btn btn-secondary" id="btn-audit-next">下一页</button>
<select id="audit-page-size" style="width:auto">
<option value="20">20 条/页</option>
<option value="50">50 条/页</option>
<option value="100">100 条/页</option>
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('modal-audit')">关闭</button>
+9 -3
View File
@@ -93,9 +93,15 @@ 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}`);
getAudit(projectId, filters = {}) {
const qs = new URLSearchParams();
for (const k of ["key", "action", "start", "end", "page", "pageSize"]) {
if (filters[k] !== undefined && filters[k] !== null && filters[k] !== "") {
qs.append(k, filters[k]);
}
}
const str = qs.toString();
return this.request(`/api/projects/${projectId}/configs/audit${str ? "?" + str : ""}`);
},
// ========== 模式 ==========
+107 -12
View File
@@ -671,9 +671,22 @@ async function copyAddress() {
}
}
/**
* 访问记录查询状态
*/
const auditState = {
key: "",
action: "",
start: "",
end: "",
page: 1,
pageSize: 20,
total: 0,
};
/**
* 打开访问记录弹窗
* @param {string|null} key - 为空时查当前项目全部记录;否则查指定配置键的记录
* @param {string|null} key - 传入配置键时作为过滤条件预填;为空时按当前过滤条件查全部记录
* @returns {Promise<void>}
*/
async function openAudit(key) {
@@ -681,32 +694,80 @@ async function openAudit(key) {
toast("请先选择项目", "error");
return;
}
document.getElementById("audit-title").textContent = key
? `(${state.currentProject.name} · ${key})`
: `(${state.currentProject.name})`;
document.getElementById("audit-title").textContent = `(${state.currentProject.name})`;
if (key) {
auditState.key = key;
document.getElementById("audit-filter-key").value = key;
}
openModal("modal-audit");
await loadAudit();
}
/**
* 读取当前过滤条件并分页加载访问记录
* @returns {Promise<void>}
*/
async function loadAudit() {
if (!state.currentProject) return;
auditState.key = document.getElementById("audit-filter-key").value.trim();
auditState.action = document.getElementById("audit-filter-action").value;
auditState.start = toUtcIso(document.getElementById("audit-filter-start").value);
auditState.end = toUtcIso(document.getElementById("audit-filter-end").value);
auditState.pageSize = parseInt(document.getElementById("audit-page-size").value, 10) || 20;
try {
const logs = await API.getAudit(state.currentProject.id, key || "");
renderAudit(logs);
const data = await API.getAudit(state.currentProject.id, {
key: auditState.key,
action: auditState.action,
start: auditState.start,
end: auditState.end,
page: auditState.page,
pageSize: auditState.pageSize,
});
auditState.total = data.total || 0;
renderAudit(data.items || []);
renderAuditPagination(data);
} catch (err) {
toast("加载访问记录失败: " + err.message, "error");
}
}
/**
* 渲染访问记录表格
* @param {Array} logs - 访问记录列表
* datetime-local 输入值转 UTC ISO 字符串
* @param {string} value - 形如 "2026-09-21T12:30"(浏览器本地时区)
* @returns {string} UTC ISO,空值原样返回
*/
function toUtcIso(value) {
if (!value) return "";
const d = new Date(value);
return isNaN(d.getTime()) ? "" : d.toISOString();
}
/**
* 渲染分页信息
* @param {Object} data - { total, page, pageSize }
* @returns {void}
*/
function renderAudit(logs) {
function renderAuditPagination(data) {
const totalPages = Math.max(1, Math.ceil((data.total || 0) / (data.pageSize || 1)));
document.getElementById("btn-audit-prev").disabled = data.page <= 1;
document.getElementById("btn-audit-next").disabled = data.page >= totalPages;
document.getElementById("audit-page-info").textContent = `第 ${data.page} / ${totalPages} 页 · 共 ${data.total} 条`;
}
/**
* 渲染访问记录表格
* @param {Array} items - 当前页访问记录列表
* @returns {void}
*/
function renderAudit(items) {
const tbody = document.getElementById("audit-tbody");
if (!logs || !logs.length) {
if (!items || !items.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
tbody.innerHTML = items
.map((l) => {
const act = ACTION_NAMES[l.action] || l.action;
const src = SOURCE_NAMES[l.source] || l.source;
@@ -867,9 +928,43 @@ function bindEvents() {
}
});
// 操作记录
// 访问记录:打开弹窗
document.getElementById("btn-audit").addEventListener("click", () => openAudit(null));
// 访问记录:过滤与分页
document.getElementById("btn-audit-query").addEventListener("click", () => {
auditState.page = 1;
loadAudit();
});
document.getElementById("btn-audit-reset").addEventListener("click", () => {
document.getElementById("audit-filter-key").value = "";
document.getElementById("audit-filter-action").value = "";
document.getElementById("audit-filter-start").value = "";
document.getElementById("audit-filter-end").value = "";
auditState.page = 1;
loadAudit();
});
document.getElementById("btn-audit-prev").addEventListener("click", () => {
if (auditState.page > 1) {
auditState.page -= 1;
loadAudit();
}
});
document.getElementById("btn-audit-next").addEventListener("click", () => {
auditState.page += 1;
loadAudit();
});
document.getElementById("audit-page-size").addEventListener("change", () => {
auditState.page = 1;
loadAudit();
});
document.getElementById("audit-filter-key").addEventListener("keydown", (e) => {
if (e.key === "Enter") {
auditState.page = 1;
loadAudit();
}
});
// 表格操作(事件委托)
document.getElementById("config-tbody").addEventListener("click", (e) => {
const btn = e.target.closest(".row-btn");
+43 -11
View File
@@ -322,9 +322,12 @@ async function appendAuditLog(entry) {
}
/**
* 查询操作记录
* @param {Object} filter - { projectId, key }
* @returns {Promise<Array>} 操作记录,按时间倒序
* 分页查询访问记录(支持过滤参数)
* @param {Object} filter - { projectId, key, action, start, end, page, pageSize }
* - key: 键名模糊匹配
* - action: read/create/update/delete
* - start/end: ISO 时间,按 created_at 过滤
* @returns {Promise<{items: Array, total: number, page: number, pageSize: number}>}
*/
async function loadAuditLogs(filter = {}) {
await init();
@@ -335,20 +338,49 @@ async function loadAuditLogs(filter = {}) {
params.push(filter.projectId);
}
if (filter.key) {
where.push("config_key = ?");
params.push(filter.key);
where.push("config_key LIKE ?");
params.push(`%${filter.key}%`);
}
const sql =
if (filter.action) {
where.push("action = ?");
params.push(filter.action);
}
if (filter.start) {
const s = new Date(filter.start);
if (!isNaN(s.getTime())) {
where.push("created_at >= ?");
params.push(s.toISOString());
}
}
if (filter.end) {
const e = new Date(filter.end);
if (!isNaN(e.getTime())) {
where.push("created_at <= ?");
params.push(e.toISOString());
}
}
const whereSql = where.length ? ` WHERE ${where.join(" AND ")}` : "";
const countReader = await connection.runAndReadAll(
`SELECT COUNT(*) AS total FROM audit_logs${whereSql}`,
params
);
const total = Number(countReader.getRowObjects()[0].total) || 0;
const page = Math.max(1, parseInt(filter.page, 10) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(filter.pageSize, 10) || 20));
const offset = (page - 1) * pageSize;
const reader = await connection.runAndReadAll(
`SELECT id, project_id AS projectId, config_key AS key, action, source, actor, ip,
value_text AS value, detail, created_at AS createdAt
FROM audit_logs` +
(where.length ? ` WHERE ${where.join(" AND ")}` : "") +
` ORDER BY created_at DESC, id DESC`;
const reader = await connection.runAndReadAll(sql, params);
return reader.getRowObjects().map((r) => ({
FROM audit_logs${whereSql}
ORDER BY created_at DESC, id DESC
LIMIT ? OFFSET ?`,
[...params, pageSize, offset]
);
const items = reader.getRowObjects().map((r) => ({
...r,
detail: r.detail ? parseData(r.detail) : null,
}));
return { items, total, page, pageSize };
}
module.exports = {
+14 -4
View File
@@ -29,16 +29,26 @@ async function getCurrentMode() {
}
/**
* GET /api/projects/:projectId/configs/audit?key=xxx
* 查询当前项目(或指定配置键)的操作记录
* GET /api/projects/:projectId/configs/audit
* 分页查询当前项目访问记录,支持过滤参数:
* key - 键名模糊匹配
* action - read/create/update/delete
* start - 开始时间(ISO)
* end - 结束时间(ISO)
* page/pageSize - 分页
*/
router.get("/audit", async (req, res) => {
try {
const logs = await loadAuditLogs({
const result = await loadAuditLogs({
projectId: req.params.projectId,
key: req.query.key || "",
action: req.query.action || "",
start: req.query.start || "",
end: req.query.end || "",
page: req.query.page,
pageSize: req.query.pageSize,
});
res.json({ success: true, data: logs });
res.json({ success: true, data: result });
} catch (err) {
res.status(400).json({ success: false, message: err.message });
}
+53 -8
View File
@@ -33,14 +33,15 @@ test("记录一条配置操作并可按项目/键查询", async () => {
});
const byProject = await duckdbAdapter.loadAuditLogs({ projectId: "proj-a" });
assert.strictEqual(byProject.length, 1);
assert.strictEqual(byProject.items.length, 1);
assert.strictEqual(byProject.total, 1);
const byKey = await duckdbAdapter.loadAuditLogs({ projectId: "proj-a", key: "db/host" });
assert.strictEqual(byKey.length, 1);
assert.strictEqual(byKey.items.length, 1);
// 其它项目/键查不到
assert.strictEqual((await duckdbAdapter.loadAuditLogs({ projectId: "proj-b" })).length, 0);
assert.strictEqual((await duckdbAdapter.loadAuditLogs({ projectId: "proj-a", key: "other" })).length, 0);
assert.strictEqual((await duckdbAdapter.loadAuditLogs({ projectId: "proj-b" })).items.length, 0);
assert.strictEqual((await duckdbAdapter.loadAuditLogs({ projectId: "proj-a", key: "other" })).items.length, 0);
const rec = byKey[0];
const rec = byKey.items[0];
assert.strictEqual(rec.source, "web");
assert.strictEqual(rec.actor, "admin");
assert.strictEqual(rec.ip, "203.0.113.9");
@@ -55,9 +56,53 @@ test("记录按时间倒序返回,MCP 来源也正确记录 Token 名", async
await auditLog({ source: "mcp", actor: "ci-token", ip: "10.0.0.1", projectId: "proj-a", key: "k/1", action: "create", value: "x" });
const logs = await duckdbAdapter.loadAuditLogs({ projectId: "proj-a", key: "k/1" });
assert.strictEqual(logs.length, 2);
assert.ok(logs[0].createdAt >= logs[1].createdAt, "最新的应在前");
assert.ok(logs.every((l) => l.source === "mcp" && l.actor === "ci-token"));
const items = logs.items;
assert.strictEqual(items.length, 2);
assert.ok(items[0].createdAt >= items[1].createdAt, "最新的应在前");
assert.ok(items.every((l) => l.source === "mcp" && l.actor === "ci-token"));
});
test("分页与过滤:action / start-end 时间范围 / 键名模糊匹配 / 页码切片", async () => {
const base = Date.now() - 5 * 60000;
for (let i = 1; i <= 3; i++) {
await duckdbAdapter.appendAuditLog({
id: "aud-pg-" + i,
source: "mcp",
actor: "ci-token",
projectId: "proj-p",
key: `svc/app-${i}`,
action: i === 3 ? "delete" : "create",
value: `v${i}`,
createdAt: new Date(base + i * 1000).toISOString(),
});
}
// 分页:page=1 取 2 条,total 应为 3
const page1 = await duckdbAdapter.loadAuditLogs({ projectId: "proj-p", page: 1, pageSize: 2 });
assert.strictEqual(page1.items.length, 2);
assert.strictEqual(page1.total, 3);
assert.strictEqual(page1.page, 1);
assert.strictEqual(page1.pageSize, 2);
const page2 = await duckdbAdapter.loadAuditLogs({ projectId: "proj-p", page: 2, pageSize: 2 });
assert.strictEqual(page2.items.length, 1);
// action 过滤
const creates = await duckdbAdapter.loadAuditLogs({ projectId: "proj-p", action: "create" });
assert.strictEqual(creates.items.length, 2);
assert.strictEqual(creates.items.every((l) => l.action === "create"), true);
const deletes = await duckdbAdapter.loadAuditLogs({ projectId: "proj-p", action: "delete" });
assert.strictEqual(deletes.items.length, 1);
// key 模糊匹配
const fuzzy = await duckdbAdapter.loadAuditLogs({ projectId: "proj-p", key: "app-" });
assert.strictEqual(fuzzy.items.length, 3);
// 时间范围:取中间那条
const midStart = new Date(base + 1500).toISOString();
const midEnd = new Date(base + 2500).toISOString();
const range = await duckdbAdapter.loadAuditLogs({ projectId: "proj-p", start: midStart, end: midEnd });
assert.strictEqual(range.items.length, 1);
assert.strictEqual(range.items[0].key, "svc/app-2");
});
test("getClientIp 优先取 X-Forwarded-For,其次 remoteAddress", () => {
+2 -2
View File
@@ -182,7 +182,7 @@ test("Token 项目范围限制 MCP 配置访问,越权操作被拒绝且不入
assert.ok(!createdReal.isError);
const logsReal = await loadAuditLogs({ projectId: project.id, key: "sql/dsn" });
const createLog = logsReal.find((l) => l.action === "create");
const createLog = logsReal.items.find((l) => l.action === "create");
assert.ok(createLog, "应在 audit_logs 中存在 create 记录");
assert.strictEqual(createLog.source, "mcp");
assert.strictEqual(createLog.actor, "scope-token");
@@ -191,7 +191,7 @@ test("Token 项目范围限制 MCP 配置访问,越权操作被拒绝且不入
// 越权操作不产生记录(other-proj 无任何记录)
const deniedLogs = await loadAuditLogs({ projectId: "other-proj" });
assert.strictEqual(deniedLogs.length, 0);
assert.strictEqual(deniedLogs.items.length, 0);
// 清理测试项目
const delProject = await callTool("delete_project", { projectId: project.id });