1264 lines
61 KiB
HTML
1264 lines
61 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8"/>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||
<title>运维工作台</title>
|
||
<style>
|
||
:root {
|
||
--bg: #f5f7fa;
|
||
--panel: #ffffff;
|
||
--panel-2: #f8f8f9;
|
||
--border: #e8eaec;
|
||
--border-in: #dcdee2;
|
||
--text: #515a6e;
|
||
--muted: #808695;
|
||
--accent: #2d8cf0;
|
||
--accent-hover: #57a3f3;
|
||
--accent-2: #2487e8;
|
||
--green: #19be6b;
|
||
--red: #ed4014;
|
||
--yellow: #ff9900;
|
||
--mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0;
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
font-size: 14px;
|
||
}
|
||
/* ===== Breadcrumb(View UI Breadcrumb 风格) ===== */
|
||
.breadcrumb {
|
||
padding: 14px 24px; background: #fff;
|
||
border-bottom: 1px solid #e8eaec;
|
||
font-size: 14px; color: #515a6e;
|
||
}
|
||
.breadcrumb a { color: #515a6e; text-decoration: none; transition: color .2s; }
|
||
.breadcrumb a:hover { color: #2d8cf0; }
|
||
.breadcrumb .sep { margin: 0 8px; color: #dcdee2; }
|
||
.breadcrumb .current { color: #515a6e; }
|
||
|
||
/* ===== Tabs(View UI line 型:底部下划线激活) ===== */
|
||
.tabs {
|
||
display: flex; gap: 24px; padding: 0 24px;
|
||
border-bottom: 1px solid var(--border-in); background: #fff;
|
||
}
|
||
.tabs .tab {
|
||
padding: 11px 2px; cursor: pointer; font-size: 14px; color: var(--muted);
|
||
border-bottom: 2px solid transparent; user-select: none; transition: color .2s;
|
||
}
|
||
.tabs .tab:hover { color: var(--accent); }
|
||
.tabs .tab.active {
|
||
color: var(--accent); font-weight: 500;
|
||
border-bottom-color: var(--accent);
|
||
}
|
||
.panel { display: none; padding: 16px 24px; }
|
||
.panel.active { display: block; }
|
||
|
||
/* ===== 通用卡片/表单/按钮 ===== */
|
||
.wrap { display: flex; gap: 16px; align-items: flex-start; }
|
||
.left { width: 320px; flex: 0 0 320px; }
|
||
.right { flex: 1 1 auto; min-width: 0; }
|
||
.card {
|
||
background: #fff;
|
||
border-radius: 4px;
|
||
box-shadow: 0 1px 1px rgba(0,0,0,.1); /* View UI Card */
|
||
padding: 16px;
|
||
}
|
||
.card + .card { margin-top: 16px; }
|
||
.field { margin-bottom: 12px; }
|
||
.field:last-child { margin-bottom: 0; }
|
||
label { display: block; color: var(--muted); font-size: 12px; margin-bottom: 6px; }
|
||
input[type=text], input[type=number] {
|
||
width: 100%;
|
||
padding: 9px 11px;
|
||
background: #fff;
|
||
border: 1px solid #dcdee2;
|
||
border-radius: 4px;
|
||
color: var(--text);
|
||
font-family: var(--mono);
|
||
outline: none;
|
||
transition: border-color .2s, box-shadow .2s;
|
||
}
|
||
input[type=text]:hover, input[type=number]:hover { border-color: #57a3f3; }
|
||
input[type=text]:focus, input[type=number]:focus { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(45,140,240,.2); outline: none; }
|
||
.btn {
|
||
padding: 5px 15px;
|
||
border: 1px solid var(--border-in);
|
||
background: #fff;
|
||
color: var(--text);
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
transition: border-color .2s, color .2s, background .2s;
|
||
}
|
||
.btn:hover { border-color: var(--accent); color: var(--accent); }
|
||
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||
.btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: #fff; }
|
||
.btn.danger { background: #fff; border-color: var(--red); color: var(--red); }
|
||
.btn.danger:hover { background: rgba(237,64,20,.08); border-color: var(--red); color: var(--red); }
|
||
.btn.mini { padding: 2px 10px; font-size: 12px; border-radius: 4px; }
|
||
.btn-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||
.hint { color: var(--muted); font-size: 11px; margin-top: 6px; }
|
||
|
||
/* ===== 缓存管理(原 cache-view 样式) ===== */
|
||
.keys-list { max-height: 60vh; overflow: auto; margin-top: 10px; border-top: 1px solid var(--border); }
|
||
.keys-list .k {
|
||
padding: 8px 10px;
|
||
font-family: var(--mono);
|
||
font-size: 12px;
|
||
border-bottom: 1px solid var(--border);
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.keys-list .k:hover { background: #f3f3f3; }
|
||
.keys-list .k.active { background: #f0faff; }
|
||
.keys-list .k .kname {
|
||
display: block; color: var(--accent); font-size: 11px; margin-bottom: 2px;
|
||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||
}
|
||
.keys-list .k .kkey {
|
||
display: block; font-family: var(--mono); font-size: 12px;
|
||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||
}
|
||
.meta { display: grid; grid-template-columns: 90px 1fr; gap: 10px 14px; margin-bottom: 14px; }
|
||
.meta .mk { color: var(--muted); font-size: 12px; }
|
||
.meta .mv { font-family: var(--mono); word-break: break-all; }
|
||
.badge {
|
||
display: inline-block; padding: 0 8px; height: 22px; line-height: 20px;
|
||
border-radius: 4px; font-size: 12px; border: 1px solid #e8eaec; color: var(--muted); background: #fff;
|
||
}
|
||
.badge.string { color: var(--green); border-color: rgba(25,190,107,.4); background: rgba(25,190,107,.1); }
|
||
.badge.hash { color: var(--accent); border-color: rgba(45,140,240,.4); background: rgba(45,140,240,.1); }
|
||
.badge.list, .badge.set, .badge.zset { color: var(--yellow); border-color: rgba(255,153,0,.4); background: rgba(255,153,0,.1); }
|
||
.toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
|
||
.count { color: var(--muted); font-size: 12px; }
|
||
select {
|
||
padding: 7px 10px;
|
||
background: #fff;
|
||
border: 1px solid #dcdee2;
|
||
border-radius: 4px;
|
||
color: var(--text);
|
||
font-size: 13px;
|
||
outline: none;
|
||
cursor: pointer;
|
||
transition: border-color .2s, box-shadow .2s;
|
||
}
|
||
select:hover { border-color: #57a3f3; }
|
||
select:focus { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(45,140,240,.2); }
|
||
pre.json {
|
||
margin: 0; padding: 12px; background: #f8f8f9; border: 1px solid #e8eaec;
|
||
border-radius: 4px; overflow: auto; max-height: 60vh; font-family: var(--mono);
|
||
font-size: 12px; line-height: 1.5; color: #1f2933; white-space: pre;
|
||
}
|
||
pre.json .jk { color: #c0392b; } /* key */
|
||
pre.json .js { color: #1a9e54; } /* string value */
|
||
pre.json .jn { color: #b8860b; } /* number */
|
||
pre.json .jb { color: #8e44ad; } /* boolean / null */
|
||
.empty { color: var(--muted); padding: 24px; text-align: center; }
|
||
.spinner {
|
||
display: inline-block; width: 14px; height: 14px; border: 2px solid var(--muted);
|
||
border-top-color: var(--accent); border-radius: 50%; animation: spin .7s linear infinite;
|
||
vertical-align: -2px; margin-right: 6px;
|
||
}
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
code { background: var(--panel-2); padding: 1px 5px; border-radius: 4px; font-family: var(--mono); }
|
||
|
||
/* ===== 日志表格 ===== */
|
||
/* 筛选工具栏(参考页面风格:白底圆角卡、一行内联 + 横向滚动) */
|
||
.filter-bar { display: flex; gap: 8px; flex-wrap: nowrap; align-items: center; background: var(--panel); border: 1px solid #e8eaec; border-radius: 4px; padding: 8px 12px; margin-bottom: 14px; overflow-x: auto; }
|
||
.filter-bar .fi { display: flex; align-items: center; gap: 5px; flex-shrink: 0; }
|
||
.filter-bar .fi label { margin: 0; color: #515a6e; font-size: 12px; white-space: nowrap; }
|
||
.filter-bar .fi input[type=text], .filter-bar .fi input[type=number] { width: 105px; padding: 5px 8px; font-size: 12px; border: 1px solid #dcdee2; border-radius: 4px; background: #fff; color: var(--text); outline: none; transition: border-color .2s, box-shadow .2s; }
|
||
.filter-bar .fi input[type=text]:hover, .filter-bar .fi input[type=number]:hover { border-color: #57a3f3; }
|
||
.filter-bar .fi input[type=text]:focus, .filter-bar .fi input[type=number]:focus { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(45,140,240,.2); }
|
||
.filter-bar .fi select { min-width: 80px; padding: 5px 8px; border: 1px solid #dcdee2; border-radius: 4px; background: #fff; font-size: 12px; color: var(--text); transition: border-color .2s, box-shadow .2s; }
|
||
.filter-bar .fi select:hover { border-color: #57a3f3; }
|
||
.filter-bar .fi select:focus { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(45,140,240,.2); }
|
||
/* 搜索框左侧放大镜图标 */
|
||
.filter-bar .fi.search { position: relative; }
|
||
.filter-bar .fi.search input[type=text] { padding-left: 26px; }
|
||
.filter-bar .fi.search::before {
|
||
content: "\1F50D"; /* 🔍 */
|
||
position: absolute; left: 9px; top: 50%; transform: translateY(-50%);
|
||
color: var(--muted); font-size: 13px; pointer-events: none;
|
||
}
|
||
.filter-bar > .btn.primary { margin-left: auto; flex-shrink: 0; } /* 查询按钮置右 */
|
||
table.log { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
||
table.log th, table.log td {
|
||
padding: 9px 12px; border-bottom: 1px solid #e8eaec;
|
||
text-align: left; vertical-align: top;
|
||
}
|
||
table.log th { color: #515a6e; font-weight: 600; white-space: nowrap; background: #f8f8f9; position: sticky; top: 0; }
|
||
table.log td { color: #515a6e; }
|
||
table.log tr:hover td { background: #ebf7ff; }
|
||
table.log td.mono { font-family: var(--mono); font-size: 12px; }
|
||
table.log td .cell-clip {
|
||
display: inline-block; max-width: 280px; overflow: hidden;
|
||
text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom;
|
||
}
|
||
.table-wrap { overflow: auto; max-height: 62vh; border: 1px solid #e8eaec; border-radius: 4px; }
|
||
.tag { display: inline-block; padding: 0 8px; height: 22px; line-height: 20px; border-radius: 4px; font-size: 12px; border: 1px solid; white-space: nowrap; }
|
||
.tag.green { color: var(--green); border-color: rgba(25,190,107,.4); background: rgba(25,190,107,.1); }
|
||
.tag.red { color: var(--red); border-color: rgba(237,64,20,.4); background: rgba(237,64,20,.1); }
|
||
.tag.yellow { color: var(--yellow); border-color: rgba(255,153,0,.4); background: rgba(255,153,0,.1); }
|
||
.tag.blue { color: var(--accent); border-color: rgba(45,140,240,.4); background: rgba(45,140,240,.1); }
|
||
.tag.gray { color: var(--muted); border-color: #e8eaec; background: #f8f8f9; }
|
||
/* 分页条:View UI 风格页码按钮,靠右 */
|
||
.pager { display: flex; gap: 8px; align-items: center; justify-content: flex-end; margin-top: 14px; flex-wrap: wrap; color: #808695; font-size: 13px; }
|
||
.pager .pinfo { color: #808695; margin-right: 4px; }
|
||
.pager .pg { display: inline-flex; gap: 4px; }
|
||
.pager .pg-btn { min-width: 32px; height: 32px; padding: 0 6px; border: 1px solid #dcdee2; border-radius: 4px; background: #fff; color: #515a6e; cursor: pointer; font-size: 13px; text-align: center; line-height: 30px; transition: border-color .2s, color .2s; }
|
||
.pager .pg-btn:hover:not(:disabled) { border-color: #2d8cf0; color: #2d8cf0; }
|
||
.pager .pg-btn.active { border-color: #2d8cf0; color: #2d8cf0; font-weight: 600; }
|
||
.pager .pg-btn:disabled { opacity: .5; cursor: not-allowed; }
|
||
.pager .pg-ellipsis { line-height: 32px; padding: 0 2px; color: #c5c8ce; }
|
||
.pager .size { display: inline-flex; align-items: center; gap: 4px; }
|
||
.pager .size select { padding: 5px 8px; border: 1px solid #dcdee2; border-radius: 4px; background: #fff; font-size: 13px; color: #515a6e; }
|
||
.row-actions { display: flex; gap: 4px; white-space: nowrap; }
|
||
.row-actions .btn.mini { padding: 2px 8px; border: 1px solid transparent; border-radius: 4px; background: none; color: #2d8cf0; font-size: 12px; }
|
||
.row-actions .btn.mini:hover { border-color: #2d8cf0; background: rgba(45,140,240,.06); }
|
||
|
||
/* ===== 详情弹窗(View UI Modal 风格) ===== */
|
||
.modal-mask {
|
||
position: fixed; inset: 0; background: rgba(55,55,55,.6);
|
||
display: none; z-index: 90; align-items: center; justify-content: center;
|
||
}
|
||
.modal-mask.show { display: flex; }
|
||
.modal {
|
||
width: min(860px, 92vw); max-height: 84vh; overflow: hidden;
|
||
background: #fff; border-radius: 6px;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,.2);
|
||
display: flex; flex-direction: column;
|
||
}
|
||
.modal .modal-header {
|
||
padding: 13px 16px; border-bottom: 1px solid #e8eaec;
|
||
display: flex; align-items: center; justify-content: space-between; flex: 0 0 auto;
|
||
}
|
||
.modal .modal-header h3 { margin: 0; font-size: 14px; font-weight: 600; color: #17233d; }
|
||
.modal .mclose {
|
||
border: none; background: none; cursor: pointer;
|
||
font-size: 20px; line-height: 1; color: #808695; padding: 0 4px; border-radius: 4px;
|
||
transition: color .2s;
|
||
}
|
||
.modal .mclose:hover { color: #2d8cf0; }
|
||
.modal .modal-body { padding: 16px; overflow: auto; flex: 1 1 auto; }
|
||
.modal .kv { display: grid; grid-template-columns: 110px 1fr; gap: 8px 14px; margin-bottom: 12px; }
|
||
.modal .kv .k { color: var(--muted); font-size: 12px; }
|
||
.modal .kv .v { font-family: var(--mono); font-size: 12.5px; word-break: break-all; white-space: pre-wrap; }
|
||
.modal .kv .v.scroll { max-height: 160px; overflow-y: auto; padding: 6px 8px; background: #f8f8f9; border: 1px solid #e8eaec; border-radius: 4px; }
|
||
.modal .modal-actions { display: none; gap: 8px; justify-content: flex-end; padding: 12px 16px; border-top: 1px solid #e8eaec; flex: 0 0 auto; }
|
||
|
||
/* 确认弹窗(View UI Modal.confirm 风格:居中 + 警示图标) */
|
||
.confirm-mask {
|
||
position: fixed; inset: 0; z-index: 120; display: none;
|
||
background: rgba(55,55,55,.6);
|
||
align-items: flex-start; justify-content: center;
|
||
}
|
||
.confirm-mask.show { display: flex; }
|
||
.confirm-box {
|
||
margin-top: 20vh; width: min(416px, 92vw);
|
||
background: #fff; border-radius: 6px; padding: 20px 24px 16px;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,.2);
|
||
}
|
||
.confirm-body { display: flex; gap: 12px; align-items: flex-start; margin-bottom: 18px; }
|
||
.confirm-icon {
|
||
flex: 0 0 32px; width: 32px; height: 32px; border-radius: 50%;
|
||
background: #ff9900; color: #fff; font-size: 20px; font-weight: 700;
|
||
display: flex; align-items: center; justify-content: center; margin-top: 2px;
|
||
}
|
||
.confirm-msg { font-size: 14px; line-height: 1.6; color: #515a6e; }
|
||
.confirm-btns { display: flex; justify-content: flex-end; gap: 8px; }
|
||
|
||
/* 消息提示(View UI Message 风格:顶部居中白卡 + 彩色图标) */
|
||
.toast, .toast-top {
|
||
position: fixed; top: 16px; left: 50%; transform: translateX(-50%);
|
||
background: #fff; border-radius: 4px;
|
||
box-shadow: 0 1px 6px rgba(0,0,0,.2);
|
||
padding: 10px 16px; font-size: 14px; color: #515a6e; max-width: 80vw;
|
||
display: none; z-index: 100; align-items: center; gap: 8px;
|
||
}
|
||
.toast.show, .toast-top.show { display: flex; }
|
||
.toast::before, .toast-top::before {
|
||
content: ''; display: inline-block; flex: 0 0 16px; width: 16px; height: 16px;
|
||
border-radius: 50%; font-size: 11px; line-height: 16px; text-align: center; color: #fff;
|
||
}
|
||
.toast.show::before, .toast-top.err.show::before { content: '✕'; background: #ed4014; }
|
||
.toast.ok.show::before, .toast-top.show::before { content: '✓'; background: #19be6b; }
|
||
.toast-top { z-index: 130; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="breadcrumb">
|
||
<a href="#">首页</a><span class="sep">/</span>
|
||
<a href="#">运维管理</a><span class="sep">/</span>
|
||
<span class="current" id="bcCurrent">接口日志</span>
|
||
</div>
|
||
|
||
<div class="tabs">
|
||
<div class="tab active" data-tab="oplog">接口日志</div>
|
||
<div class="tab" data-tab="synclog">同步日志</div>
|
||
<div class="tab" data-tab="cache">缓存管理</div>
|
||
</div>
|
||
|
||
<!-- ==================== 接口日志 ==================== -->
|
||
<div class="panel active" id="panel-oplog">
|
||
<div class="card">
|
||
<div class="filter-bar">
|
||
<div class="fi"><label>学校</label>
|
||
<select id="op_schId">
|
||
<option value="">全部学校</option>
|
||
</select>
|
||
</div>
|
||
<div class="fi"><label>URI 包含</label><input type="text" id="op_uri" placeholder="如 /api/syslog"/></div>
|
||
<div class="fi"><label>请求方法</label>
|
||
<select id="op_method">
|
||
<option value="">全部</option><option>GET</option><option>POST</option>
|
||
<option>PUT</option><option>DELETE</option>
|
||
</select>
|
||
</div>
|
||
<div class="fi"><label>状态码</label><input type="number" id="op_statusCode" placeholder="如 200 / 500"/></div>
|
||
<div class="fi"><label>日志级别</label>
|
||
<select id="op_logLevel">
|
||
<option value="">全部</option>
|
||
<option value="1">1-重要(删除)</option>
|
||
<option value="2">2-次要(新增/编辑)</option>
|
||
<option value="3">3-一般(查询)</option>
|
||
</select>
|
||
</div>
|
||
<div class="fi"><label>操作人</label><input type="text" id="op_userName" placeholder="按姓名模糊搜索"/></div>
|
||
<button class="btn primary" id="op_btnQuery">查询</button>
|
||
<button class="btn" id="op_btnReset">重置</button>
|
||
</div>
|
||
<div class="table-wrap">
|
||
<table class="log">
|
||
<thead>
|
||
<tr>
|
||
<th>ID</th><th>级别</th><th>操作类型</th><th>URI</th><th>方法</th>
|
||
<th>状态码</th><th>耗时</th><th>操作人</th><th>来源</th><th>IP</th><th>时间</th><th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="op_tbody"><tr><td colspan="12" class="empty">点击「查询」加载数据</td></tr></tbody>
|
||
</table>
|
||
</div>
|
||
<div class="pager">
|
||
<span class="pinfo" id="op_pinfo">共 0 条</span>
|
||
<span class="pg" id="op_pg"></span>
|
||
<span class="size">每页<select id="op_size"><option>10</option><option selected>20</option><option>50</option><option>100</option></select>条</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ==================== 同步日志 ==================== -->
|
||
<div class="panel" id="panel-synclog">
|
||
<div class="card">
|
||
<div class="filter-bar">
|
||
<div class="fi"><label>学校</label>
|
||
<select id="sy_schId">
|
||
<option value="">全部学校</option>
|
||
</select>
|
||
</div>
|
||
<div class="fi"><label>大类</label>
|
||
<select id="sy_syncType">
|
||
<option value="">全部</option>
|
||
<option value="Sync">同步(Sync)</option>
|
||
<option value="Send">推送(Send)</option>
|
||
</select>
|
||
</div>
|
||
<div class="fi"><label>大类名称</label><input type="text" id="sy_syncTypeName" placeholder="如 同步 / 推送"/></div>
|
||
<div class="fi"><label>类名.方法名</label><input type="text" id="sy_classMethod" placeholder="如 SendUserBaseService.deleteTeachers"/></div>
|
||
<div class="fi"><label>执行状态</label>
|
||
<select id="sy_status">
|
||
<option value="">全部</option>
|
||
<option value="1">成功</option>
|
||
<option value="0">失败</option>
|
||
</select>
|
||
</div>
|
||
<div class="fi"><label>操作人</label><input type="text" id="sy_userName" placeholder="按姓名模糊搜索"/></div>
|
||
<button class="btn primary" id="sy_btnQuery">查询</button>
|
||
<button class="btn" id="sy_btnReset">重置</button>
|
||
</div>
|
||
<div class="table-wrap">
|
||
<table class="log">
|
||
<thead>
|
||
<tr>
|
||
<th>ID</th><th>大类</th><th>同步类型</th><th>类名.方法名</th><th>状态</th>
|
||
<th>操作人</th><th>来源</th><th>IP</th><th>时间</th><th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="sy_tbody"><tr><td colspan="10" class="empty">点击「查询」加载数据</td></tr></tbody>
|
||
</table>
|
||
</div>
|
||
<div class="pager">
|
||
<span class="pinfo" id="sy_pinfo">共 0 条</span>
|
||
<span class="pg" id="sy_pg"></span>
|
||
<span class="size">每页<select id="sy_size"><option>10</option><option selected>20</option><option>50</option><option>100</option></select>条</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ==================== 缓存管理 ==================== -->
|
||
<div class="panel" id="panel-cache">
|
||
<div class="wrap">
|
||
<div class="left">
|
||
<div class="card">
|
||
<div class="field">
|
||
<label>按 Key 精确查询</label>
|
||
<input type="text" id="keyInput" placeholder="例如 bsch.classmanage.sch_class_1" autocomplete="off"/>
|
||
</div>
|
||
<div class="btn-row">
|
||
<button class="btn primary" id="btnQuery">查询</button>
|
||
<button class="btn" id="btnClear">清空</button>
|
||
</div>
|
||
<div class="field" style="margin-top:14px">
|
||
<label>按模式列出 Keys(支持 * ? )</label>
|
||
<input type="text" id="patternInput" placeholder="例如 bsch.classmanage.*" autocomplete="off"/>
|
||
</div>
|
||
<div class="btn-row">
|
||
<button class="btn" id="btnList">列出 Keys</button>
|
||
</div>
|
||
<div class="hint">生产大库下 KEYS 会阻塞,请尽量指定明确前缀,勿用单独的 <code>*</code>。</div>
|
||
<div class="keys-list" id="keysList"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="right">
|
||
<div class="card" id="detailCard">
|
||
<div class="empty" id="detailEmpty">输入 Key 或列出 Keys 后点击查询,结果将显示在此处。</div>
|
||
<div id="detailBody" style="display:none">
|
||
<div class="meta">
|
||
<div class="mk">Key</div><div class="mv" id="mKey"></div>
|
||
<div class="mk">缓存名称</div><div class="mv" id="mCacheName"></div>
|
||
<div class="mk">类型</div><div class="mv" id="mType"></div>
|
||
<div class="mk">过期时间</div><div class="mv" id="mExpire"></div>
|
||
<div class="mk">大小</div><div class="mv" id="mSize"></div>
|
||
</div>
|
||
<div class="toolbar">
|
||
<select id="formatSelect" title="格式">
|
||
<option value="json" selected>JSON</option>
|
||
<option value="text">Text</option>
|
||
</select>
|
||
<button class="btn" id="btnCopy" title="复制当前内容">复制</button>
|
||
<button class="btn" id="btnRefresh" title="刷新当前 Key">刷新</button>
|
||
<span class="count" id="rowCount"></span>
|
||
<span style="flex:1"></span>
|
||
<button class="btn danger" id="btnDelete">删除此 Key</button>
|
||
</div>
|
||
<pre class="json" id="valueJson"></pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ==================== 日志详情弹窗 ==================== -->
|
||
<div class="modal-mask" id="modalMask">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<h3 id="modalTitle">日志详情</h3>
|
||
<button class="mclose" id="modalClose" title="关闭">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="kv" id="modalKv"></div>
|
||
</div>
|
||
<div class="modal-actions" id="modalActions"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ==================== 顶部确认弹窗(重试确认等) ==================== -->
|
||
<div class="confirm-mask" id="confirmMask">
|
||
<div class="confirm-box">
|
||
<div class="confirm-body">
|
||
<span class="confirm-icon">!</span>
|
||
<div class="confirm-msg" id="confirmMsg"></div>
|
||
</div>
|
||
<div class="confirm-btns">
|
||
<button class="btn" id="confirmCancel">取消</button>
|
||
<button class="btn primary" id="confirmOk">确定</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="toast" id="toast"></div>
|
||
<div class="toast-top" id="toastTop"></div>
|
||
|
||
<script>
|
||
// ==================== 公共 ====================
|
||
// 同源时从页面自身路径推导 API 基址(兼容 context-path = 根 / 或 /bschclass)
|
||
function resolveApi() {
|
||
const base = location.pathname.substring(0, location.pathname.lastIndexOf('/') + 1);
|
||
return base + 'api';
|
||
}
|
||
const API = resolveApi();
|
||
|
||
// ==================== 登录态校验 ====================
|
||
// 通过后端 checkLogin 接口完成登录校验:已登录则后端 302 重定向回 work-view.html;
|
||
// 未登录则由 CAS 拦截器 302 到 CAS 登录页,登录成功后再回跳本页。
|
||
// 用 sessionStorage 标记"已完成校验",避免重定向回本页后再次跳转造成死循环。
|
||
let loginChecked = false;
|
||
async function checkLogin() {
|
||
if (loginChecked) return true;
|
||
if (sessionStorage.getItem('wbLoginChecked') === '1') {
|
||
loginChecked = true;
|
||
return true;
|
||
}
|
||
sessionStorage.setItem('wbLoginChecked', '1');
|
||
location.replace(API + '/checkLogin');
|
||
return false;
|
||
}
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
function escHtml(s) {
|
||
return String(s == null ? '' : s)
|
||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
function authHeaders() {
|
||
return { 'Accept': 'application/json' };
|
||
}
|
||
|
||
function toast(msg, ok) {
|
||
const t = $('toast');
|
||
t.textContent = msg;
|
||
t.className = 'toast show' + (ok ? ' ok' : '');
|
||
clearTimeout(t._timer);
|
||
t._timer = setTimeout(() => t.classList.remove('show'), 3500);
|
||
}
|
||
|
||
// 顶部提示(重试结果等,显示在页面顶部)
|
||
function toastTop(msg, ok) {
|
||
const t = $('toastTop');
|
||
t.textContent = msg;
|
||
t.className = 'toast-top show' + (ok ? '' : ' err');
|
||
clearTimeout(t._timer);
|
||
t._timer = setTimeout(() => t.classList.remove('show'), 3500);
|
||
}
|
||
|
||
function showLoading(btn, text) {
|
||
btn.dataset.old = btn.textContent;
|
||
btn.innerHTML = '<span class="spinner"></span>' + text;
|
||
btn.disabled = true;
|
||
}
|
||
function hideLoading(btn) {
|
||
btn.textContent = btn.dataset.old || btn.textContent;
|
||
btn.disabled = false;
|
||
}
|
||
|
||
// 精确判断是否被 CAS 登录拦截:仅当真正发生重定向且 Location 指向 CAS 登录页时才算,
|
||
// 避免把后端 500 的 HTML 错误页误判成“未登录”。
|
||
function isCasRedirect(res) {
|
||
if (res.redirected) return true;
|
||
const loc = res.headers.get('location') || '';
|
||
return /cas\/login/i.test(loc);
|
||
}
|
||
|
||
// 读取响应体文本(成功失败都读),用于把真实错误暴露出来
|
||
async function readText(res) {
|
||
try { return await res.text(); } catch (e) { return ''; }
|
||
}
|
||
|
||
// 把非登录类错误整理成可读信息(含真实 HTTP 状态 + 后端返回内容片段)
|
||
function buildErr(res, data, text) {
|
||
let detail = '';
|
||
if (data && (data.msg || data.message)) detail = data.msg || data.message;
|
||
else if (text) detail = text.slice(0, 300);
|
||
return '请求失败 HTTP ' + res.status + (detail ? ':' + detail : '');
|
||
}
|
||
|
||
async function apiGet(path, params) {
|
||
const url = API + path + '?' + new URLSearchParams(params).toString();
|
||
let res;
|
||
try {
|
||
res = await fetch(url, { credentials: 'include', headers: authHeaders() });
|
||
} catch (e) {
|
||
throw new Error('网络请求失败:' + e.message);
|
||
}
|
||
if (isCasRedirect(res)) {
|
||
const pageUrl = location.origin + location.pathname.substring(0, location.pathname.lastIndexOf('/') + 1) + 'work-view.html';
|
||
throw new Error('未登录或登录态无效(请求被重定向到 CAS 登录页)。' +
|
||
'请在「同一个浏览器、已 CAS 登录且为超级管理员」的会话下访问:' + pageUrl);
|
||
}
|
||
const text = await readText(res);
|
||
let data = null;
|
||
if (text) { try { data = JSON.parse(text); } catch (e) { /* 非 JSON */ } }
|
||
if (!res.ok) throw new Error(buildErr(res, data, text));
|
||
// 统一解包:后端统一返回 { code, msg, data } 结构,成功(code===0)时取内层 data
|
||
if (data && typeof data === 'object' && 'code' in data && 'data' in data) {
|
||
if (data.code !== 0) throw new Error(data.msg || data.message || ('业务错误 code=' + data.code));
|
||
return data.data;
|
||
}
|
||
return data;
|
||
}
|
||
|
||
async function apiPost(path) {
|
||
let res;
|
||
try {
|
||
res = await fetch(API + path, { method: 'POST', credentials: 'include', headers: authHeaders() });
|
||
} catch (e) {
|
||
throw new Error('网络请求失败:' + e.message);
|
||
}
|
||
if (isCasRedirect(res)) {
|
||
const pageUrl = location.origin + location.pathname.substring(0, location.pathname.lastIndexOf('/') + 1) + 'work-view.html';
|
||
throw new Error('未登录或登录态无效(请求被重定向到 CAS 登录页)。' +
|
||
'请在「同一个浏览器、已 CAS 登录且为超级管理员」的会话下访问:' + pageUrl);
|
||
}
|
||
const text = await readText(res);
|
||
let data = null;
|
||
if (text) { try { data = JSON.parse(text); } catch (e) { /* 非 JSON */ } }
|
||
if (!res.ok) throw new Error(buildErr(res, data, text));
|
||
if (data && typeof data === 'object' && 'code' in data && 'data' in data && data.code !== 0) {
|
||
throw new Error(data.msg || data.message || ('业务错误 code=' + data.code));
|
||
}
|
||
return data;
|
||
}
|
||
|
||
// LocalDateTime 序列化兼容:ISO 字符串 或 Jackson 数组 [y,m,d,h,m,s,nano]
|
||
function fmtTime(t) {
|
||
if (t == null) return '-';
|
||
if (Array.isArray(t)) {
|
||
const p = (n) => String(n == null ? 0 : n).padStart(2, '0');
|
||
return t[0] + '-' + p(t[1]) + '-' + p(t[2]) + ' ' + p(t[3]) + ':' + p(t[4]) + ':' + p(t[5]);
|
||
}
|
||
return String(t).replace('T', ' ').substring(0, 19);
|
||
}
|
||
|
||
// ==================== Tab 切换 ====================
|
||
// 记住当前 Tab 与各表格页码(sessionStorage,点击刷新后恢复)
|
||
function saveState(key, val) {
|
||
try { sessionStorage.setItem(key, String(val)); } catch (e) {}
|
||
}
|
||
function loadState(key) {
|
||
try { return sessionStorage.getItem(key); } catch (e) { return null; }
|
||
}
|
||
function savedPage(prefix) {
|
||
const v = parseInt(loadState('wbPage_' + prefix), 10);
|
||
return isNaN(v) || v < 1 ? 1 : v;
|
||
}
|
||
const loadedTabs = {};
|
||
function switchTab(name) {
|
||
saveState('wbTab', name); // 记住点击/切换时的 Tab
|
||
document.querySelectorAll('.tabs .tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
|
||
document.querySelectorAll('.panel').forEach(p => p.classList.toggle('active', p.id === 'panel-' + name));
|
||
// 同步面包屑末级文字
|
||
const bcMap = { oplog: '接口日志', synclog: '同步日志', cache: '缓存管理' };
|
||
const bcEl = document.getElementById('bcCurrent');
|
||
if (bcEl) bcEl.textContent = bcMap[name] || '';
|
||
// 首次切到日志页时自动加载(用记住的页码)
|
||
if (!loadedTabs[name]) {
|
||
loadedTabs[name] = true;
|
||
if (name === 'oplog') { loadSchoolOptions(); loadOpLog(savedPage('op')); }
|
||
if (name === 'synclog') { loadSchoolOptions(); loadSyncLog(savedPage('sy')); }
|
||
}
|
||
}
|
||
document.querySelectorAll('.tabs .tab').forEach(t => { t.onclick = () => switchTab(t.dataset.tab); });
|
||
|
||
// ==================== 通用分页表格状态 ====================
|
||
function makePager(prefix, loader) {
|
||
state[prefix].loader = loader;
|
||
$(prefix + '_size').onchange = () => loader(1);
|
||
}
|
||
const state = {
|
||
op: { page: 1, totalPages: 1 },
|
||
sy: { page: 1, totalPages: 1 }
|
||
};
|
||
|
||
// View UI 风格分页:共N条 + 页码按钮(含省略号) + 上一页/下一页
|
||
function renderPager(prefix, pageVO) {
|
||
const s = state[prefix];
|
||
s.page = pageVO.current || 1;
|
||
s.totalPages = pageVO.totalPages || 1;
|
||
saveState('wbPage_' + prefix, s.page); // 记住当前页码,刷新后恢复
|
||
$(prefix + '_pinfo').textContent = '共 ' + (pageVO.total || 0) + ' 条';
|
||
const el = $(prefix + '_pg');
|
||
if (!el) return;
|
||
const cur = s.page, total = s.totalPages;
|
||
let html = '<button class="pg-btn" data-p="' + (cur - 1) + '"' + (cur <= 1 ? ' disabled' : '') + '>‹</button>';
|
||
const pages = [];
|
||
if (total <= 7) {
|
||
for (let i = 1; i <= total; i++) pages.push(i);
|
||
} else {
|
||
pages.push(1);
|
||
if (cur > 3) pages.push(-1); // 省略号
|
||
for (let i = Math.max(2, cur - 1); i <= Math.min(total - 1, cur + 1); i++) pages.push(i);
|
||
if (cur < total - 2) pages.push(-2); // 省略号
|
||
pages.push(total);
|
||
}
|
||
pages.forEach(p => {
|
||
if (p < 0) html += '<span class="pg-ellipsis">…</span>';
|
||
else html += '<button class="pg-btn' + (p === cur ? ' active' : '') + '" data-p="' + p + '">' + p + '</button>';
|
||
});
|
||
html += '<button class="pg-btn" data-p="' + (cur + 1) + '"' + (cur >= total ? ' disabled' : '') + '>›</button>';
|
||
el.innerHTML = html;
|
||
const loader = state[prefix].loader;
|
||
el.querySelectorAll('button.pg-btn[data-p]').forEach(b => {
|
||
b.onclick = () => { if (loader) loader(Number(b.dataset.p)); };
|
||
});
|
||
}
|
||
|
||
// ==================== 详情弹窗 ====================
|
||
function openModal(title, rows, actionsHtml, retryHandler) {
|
||
$('modalTitle').textContent = title;
|
||
$('modalKv').innerHTML = rows.map(r =>
|
||
'<div class="k">' + escHtml(r[0]) + '</div>' +
|
||
'<div class="v' + (r[2] ? ' scroll' : '') + '">' + escHtml(r[1] == null || r[1] === '' ? '-' : r[1]) + '</div>'
|
||
).join('');
|
||
const act = $('modalActions');
|
||
act.innerHTML = actionsHtml || '';
|
||
act.style.display = actionsHtml ? 'flex' : 'none';
|
||
act.querySelectorAll('button[data-a]').forEach(b => {
|
||
b.onclick = () => {
|
||
if (b.dataset.a === 'retry') (retryHandler || doSyncRetry)(Number(b.dataset.id));
|
||
};
|
||
});
|
||
$('modalMask').classList.add('show');
|
||
}
|
||
$('modalClose').onclick = () => $('modalMask').classList.remove('show');
|
||
$('modalMask').onclick = (e) => { if (e.target === $('modalMask')) $('modalMask').classList.remove('show'); };
|
||
|
||
// ==================== 下载(带登录校验,失败给出提示而不是存成 HTML) ====================
|
||
async function downloadFile(path, fallbackName) {
|
||
try {
|
||
const res = await fetch(API + path, { credentials: 'include' });
|
||
const ct = res.headers.get('content-type') || '';
|
||
if (res.redirected || ct.includes('text/html')) {
|
||
throw new Error('未登录或登录态无效,请先 CAS 登录后再下载');
|
||
}
|
||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||
const blob = await res.blob();
|
||
const cd = res.headers.get('content-disposition') || '';
|
||
const m = cd.match(/filename=([^;]+)/i);
|
||
const name = m ? decodeURIComponent(m[1].trim()) : fallbackName;
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = name;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(a.href);
|
||
} catch (e) {
|
||
toast('下载失败:' + e.message);
|
||
}
|
||
}
|
||
|
||
// ==================== 接口日志 ====================
|
||
const OP_LEVEL = { 1: '<span class="tag red">重要</span>', 2: '<span class="tag yellow">次要</span>', 3: '<span class="tag blue">一般</span>' };
|
||
|
||
function opFilters() {
|
||
const p = { sort: 'createTime,desc' };
|
||
if ($('op_uri').value.trim()) p.uri = $('op_uri').value.trim();
|
||
if ($('op_method').value) p.method = $('op_method').value;
|
||
if ($('op_statusCode').value.trim()) p.statusCode = $('op_statusCode').value.trim();
|
||
if ($('op_logLevel').value) p.logLevel = $('op_logLevel').value;
|
||
if ($('op_userName').value.trim()) p.userName = $('op_userName').value.trim();
|
||
if ($('op_schId').value) p.schId = $('op_schId').value;
|
||
return p;
|
||
}
|
||
|
||
async function loadOpLog(page) {
|
||
page = Math.max(1, page || 1);
|
||
const btn = $('op_btnQuery');
|
||
showLoading(btn, '查询中');
|
||
try {
|
||
const params = Object.assign(opFilters(), {
|
||
page: page - 1, // Spring Pageable 从 0 开始
|
||
size: $('op_size').value
|
||
});
|
||
const data = await apiGet('/syslog/list', params);
|
||
renderOpRows((data && data.content) || []);
|
||
renderPager('op', data || { current: 1, total: 0, totalPages: 1 });
|
||
} catch (e) {
|
||
toast('查询失败:' + e.message);
|
||
} finally {
|
||
hideLoading(btn);
|
||
}
|
||
}
|
||
|
||
function renderOpRows(rows) {
|
||
const tb = $('op_tbody');
|
||
if (!rows.length) {
|
||
tb.innerHTML = '<tr><td colspan="12" class="empty">无数据</td></tr>';
|
||
return;
|
||
}
|
||
tb.innerHTML = rows.map(r => {
|
||
const ok = r.statusCode === 200;
|
||
return '<tr>' +
|
||
'<td class="mono">' + r.id + '</td>' +
|
||
'<td>' + (OP_LEVEL[r.logLevel] || '<span class="tag gray">' + (r.logLevel == null ? '-' : r.logLevel) + '</span>') + '</td>' +
|
||
'<td class="mono">' + escHtml(r.operationType || '-') + '</td>' +
|
||
'<td class="mono"><span class="cell-clip" title="' + escHtml(r.requestUri) + '">' + escHtml(r.requestUri) + '</span></td>' +
|
||
'<td class="mono">' + escHtml(r.requestMethod || '-') + '</td>' +
|
||
'<td>' + (ok ? '<span class="tag green">200</span>' : '<span class="tag red">' + escHtml(r.statusCode) + '</span>') + '</td>' +
|
||
'<td class="mono">' + (r.costTime == null ? '-' : r.costTime + ' ms') + '</td>' +
|
||
'<td>' + escHtml(r.userName || (r.userId == null ? '-' : r.userId)) + '</td>' +
|
||
'<td>' + escHtml((r.schId != null && schoolMap[r.schId]) ? schoolMap[r.schId] : (r.schId == null ? '-' : r.schId)) + '</td>' +
|
||
'<td class="mono">' + escHtml(r.requestIp || '-') + '</td>' +
|
||
'<td class="mono">' + fmtTime(r.createTime) + '</td>' +
|
||
'<td><div class="row-actions">' +
|
||
'<button class="btn mini" data-a="detail" data-id="' + r.id + '">详情</button>' +
|
||
'<button class="btn mini" data-a="download" data-id="' + r.id + '">下载</button>' +
|
||
'</div></td>' +
|
||
'</tr>';
|
||
}).join('');
|
||
tb.querySelectorAll('button[data-a]').forEach(b => {
|
||
b.onclick = () => opAction(b.dataset.a, Number(b.dataset.id), rows);
|
||
});
|
||
}
|
||
|
||
async function opAction(action, id, rows) {
|
||
if (action === 'detail') {
|
||
const r = rows.find(x => x.id === id);
|
||
if (!r) return;
|
||
const lrStatus = r.lastRetryStatus === 1 ? '成功'
|
||
: r.lastRetryStatus === 0 ? '失败' : '-';
|
||
openModal('接口日志详情 #' + id, [
|
||
['ID', r.id],
|
||
['日志级别', r.logLevel === 1 ? '1-重要(删除)' : r.logLevel === 2 ? '2-次要(新增/编辑)' : r.logLevel === 3 ? '3-一般(查询)' : r.logLevel],
|
||
['操作类型', r.operationType],
|
||
['操作人', (r.userName || '-') + '(ID: ' + (r.userId == null ? '-' : r.userId) + ')'],
|
||
['学校 schId', r.schId],
|
||
['来源(学校)', schoolMap[r.schId] || r.schId || '-'],
|
||
['客户端 IP', r.requestIp],
|
||
['请求 URI', r.requestUri],
|
||
['请求方法', r.requestMethod],
|
||
['类名.方法名', r.classMethod],
|
||
['请求参数(脱敏)', r.requestParams, true],
|
||
['状态码', r.statusCode],
|
||
['耗时', (r.costTime == null ? '-' : r.costTime + ' ms')],
|
||
['异常信息', r.errorMsg, true],
|
||
['User-Agent', r.userAgent],
|
||
['时间', fmtTime(r.createTime)],
|
||
['重试次数', (r.retryCount == null ? 0 : r.retryCount) + ' 次'],
|
||
['上次重试状态', lrStatus],
|
||
['上次重试时间', r.lastRetryTime ? fmtTime(r.lastRetryTime) : '-']
|
||
]);
|
||
} else if (action === 'download') {
|
||
downloadFile('/syslog/download/' + id, 'audit-' + id + '.txt');
|
||
}
|
||
}
|
||
|
||
$('op_btnQuery').onclick = () => loadOpLog(1);
|
||
$('op_btnReset').onclick = () => {
|
||
['op_uri', 'op_statusCode', 'op_userName'].forEach(i => $(i).value = '');
|
||
$('op_method').value = ''; $('op_logLevel').value = '';
|
||
$('op_schId').value = '';
|
||
loadOpLog(1);
|
||
};
|
||
makePager('op', loadOpLog);
|
||
|
||
// ==================== 同步日志 ====================
|
||
// 学校下拉(GET /api/tree/getSchoolList → List<SelectIntVO{id,name}>);仅首次加载,填充接口/同步两个日志的下拉与 schoolMap
|
||
let schoolLoaded = false;
|
||
const schoolMap = {}; // schId -> 校名,供表格「来源」列与详情展示
|
||
async function loadSchoolOptions() {
|
||
if (schoolLoaded) return;
|
||
try {
|
||
const list = await apiGet('/tree/getSchoolList', {});
|
||
['op_schId', 'sy_schId'].forEach(id => {
|
||
const sel = $(id);
|
||
if (!sel) return;
|
||
(list || []).forEach(s => {
|
||
const o = document.createElement('option');
|
||
o.value = s.id; o.textContent = s.name;
|
||
sel.appendChild(o);
|
||
schoolMap[s.id] = s.name;
|
||
});
|
||
});
|
||
schoolLoaded = true;
|
||
} catch (e) {
|
||
schoolLoaded = false; // 未登录等,下次切到该 tab 再试
|
||
}
|
||
}
|
||
|
||
function syFilters() {
|
||
const p = { sort: 'createTime,desc' };
|
||
if ($('sy_schId').value) p.schId = $('sy_schId').value;
|
||
if ($('sy_syncType').value) p.syncType = $('sy_syncType').value;
|
||
if ($('sy_syncTypeName').value.trim()) p.syncTypeName = $('sy_syncTypeName').value.trim();
|
||
if ($('sy_classMethod').value.trim()) p.classMethod = $('sy_classMethod').value.trim();
|
||
if ($('sy_status').value !== '') p.status = $('sy_status').value;
|
||
if ($('sy_userName').value.trim()) p.userName = $('sy_userName').value.trim();
|
||
return p;
|
||
}
|
||
|
||
async function loadSyncLog(page) {
|
||
page = Math.max(1, page || 1);
|
||
const btn = $('sy_btnQuery');
|
||
showLoading(btn, '查询中');
|
||
try {
|
||
const params = Object.assign(syFilters(), {
|
||
page: page - 1,
|
||
size: $('sy_size').value
|
||
});
|
||
const data = await apiGet('/synclog/list', params);
|
||
renderSyRows((data && data.content) || []);
|
||
renderPager('sy', data || { current: 1, total: 0, totalPages: 1 });
|
||
} catch (e) {
|
||
toast('查询失败:' + e.message);
|
||
} finally {
|
||
hideLoading(btn);
|
||
}
|
||
}
|
||
|
||
// 类名.方法名 缩写:去掉包名前缀,只保留 类名.方法名(如 ...SendUserBaseService.deleteTeachers → SendUserBaseService.deleteTeachers)
|
||
function shortClassMethod(cm) {
|
||
if (cm == null) return '-';
|
||
const parts = String(cm).split('.');
|
||
return parts.length >= 2 ? parts.slice(-2).join('.') : String(cm);
|
||
}
|
||
|
||
function renderSyRows(rows) {
|
||
const tb = $('sy_tbody');
|
||
if (!rows.length) {
|
||
tb.innerHTML = '<tr><td colspan="10" class="empty">无数据</td></tr>';
|
||
return;
|
||
}
|
||
tb.innerHTML = rows.map(r => {
|
||
const st = r.status === 1 ? '<span class="tag green">成功</span>'
|
||
: r.status === 0 ? '<span class="tag red">失败</span>'
|
||
: '<span class="tag gray">未判定</span>';
|
||
const big = r.syncType === 'Sync' ? '<span class="tag blue">同步</span>'
|
||
: r.syncType === 'Send' ? '<span class="tag yellow">推送</span>'
|
||
: '<span class="tag gray">' + escHtml(r.syncType || '-') + '</span>';
|
||
const typeText = r.syncDataTypeName
|
||
? r.syncDataTypeName + (r.syncDataTypeKey ? '(' + r.syncDataTypeKey + ')' : '')
|
||
: (r.syncDataTypeKey || '-');
|
||
const srcName = (r.schId != null && schoolMap[r.schId]) ? schoolMap[r.schId] : (r.schId == null ? '-' : r.schId);
|
||
return '<tr>' +
|
||
'<td class="mono">' + r.id + '</td>' +
|
||
'<td>' + big + '</td>' +
|
||
'<td>' + escHtml(typeText) + '</td>' +
|
||
'<td class="mono"><span class="cell-clip" title="' + escHtml(r.classMethod) + '">' + escHtml(shortClassMethod(r.classMethod)) + '</span></td>' +
|
||
'<td>' + st + '</td>' +
|
||
'<td>' + escHtml(r.userName || (r.userId == null ? '-' : r.userId)) + '</td>' +
|
||
'<td>' + escHtml(srcName) + '</td>' +
|
||
'<td class="mono">' + escHtml(r.requestIp || '-') + '</td>' +
|
||
'<td class="mono">' + fmtTime(r.createTime) + '</td>' +
|
||
'<td><div class="row-actions">' +
|
||
'<button class="btn mini" data-a="detail" data-id="' + r.id + '">详情</button>' +
|
||
'<button class="btn mini" data-a="download" data-id="' + r.id + '">下载</button>' +
|
||
'</div></td>' +
|
||
'</tr>';
|
||
}).join('');
|
||
tb.querySelectorAll('button[data-a]').forEach(b => {
|
||
b.onclick = () => syAction(b.dataset.a, Number(b.dataset.id), rows);
|
||
});
|
||
}
|
||
|
||
async function syAction(action, id, rows) {
|
||
if (action === 'detail') {
|
||
const r = rows.find(x => x.id === id);
|
||
if (!r) return;
|
||
const lrStatus = r.lastRetryStatus === 1 ? '成功'
|
||
: r.lastRetryStatus === 0 ? '失败' : '-';
|
||
const retryBtn = r.status !== 1
|
||
? '<button class="btn primary" data-a="retry" data-id="' + r.id + '">重试</button>'
|
||
: '';
|
||
openModal('同步日志详情 #' + id, [
|
||
['ID', r.id],
|
||
['大类', (r.syncTypeName || r.syncType || '-') + (r.syncType ? '(' + r.syncType + ')' : '')],
|
||
['同步类型', (r.syncDataTypeName || '-') + (r.syncDataTypeKey ? '(' + r.syncDataTypeKey + ')' : '')],
|
||
['类名.方法名', r.classMethod],
|
||
['执行状态', r.status === 1 ? '成功' : r.status === 0 ? '失败' : '未判定'],
|
||
['操作人', (r.userName || '-') + '(ID: ' + (r.userId == null ? '-' : r.userId) + ')'],
|
||
['学校 schId', r.schId],
|
||
['来源(学校)', schoolMap[r.schId] || r.schId || '-'],
|
||
['客户端 IP', r.requestIp],
|
||
['请求入参(脱敏)', r.jsonData, true],
|
||
['异常信息', r.errorMsg, true],
|
||
['时间', fmtTime(r.createTime)],
|
||
['重试次数', (r.retryCount == null ? 0 : r.retryCount) + ' 次'],
|
||
['上次重试状态', lrStatus],
|
||
['上次重试时间', r.lastRetryTime ? fmtTime(r.lastRetryTime) : '-']
|
||
], retryBtn);
|
||
} else if (action === 'download') {
|
||
downloadFile('/synclog/download/' + id, 'sync-' + id + '.txt');
|
||
}
|
||
}
|
||
|
||
// 顶部确认弹窗(替代原生 confirm,提示框显示在页面顶部)
|
||
function confirmTop(msg) {
|
||
return new Promise(resolve => {
|
||
$('confirmMsg').textContent = msg;
|
||
const mask = $('confirmMask');
|
||
mask.classList.add('show');
|
||
const done = (val) => {
|
||
mask.classList.remove('show');
|
||
$('confirmOk').onclick = null;
|
||
$('confirmCancel').onclick = null;
|
||
mask.onclick = null;
|
||
resolve(val);
|
||
};
|
||
$('confirmOk').onclick = () => done(true);
|
||
$('confirmCancel').onclick = () => done(false);
|
||
mask.onclick = (e) => { if (e.target === mask) done(false); };
|
||
});
|
||
}
|
||
|
||
// 重试:从详情弹窗发起(成功提示 + 刷新列表展示最新重试字段)
|
||
async function doSyncRetry(id) {
|
||
if (!await confirmTop('确认按记录中的 classMethod + jsonData 反射重放该同步/推送?')) return;
|
||
try {
|
||
const data = await apiPost('/synclog/retry/' + id);
|
||
toastTop((data && (data.message || data.msg)) || '重试成功', true);
|
||
$('modalMask').classList.remove('show');
|
||
loadSyncLog(state.sy.page); // 刷新当前页,展示最新重试次数/状态/时间
|
||
} catch (e) {
|
||
toastTop('重试失败:' + e.message, false);
|
||
}
|
||
}
|
||
|
||
$('sy_btnQuery').onclick = () => loadSyncLog(1);
|
||
$('sy_btnReset').onclick = () => {
|
||
['sy_syncTypeName', 'sy_classMethod', 'sy_userName'].forEach(i => $(i).value = '');
|
||
$('sy_schId').value = ''; $('sy_syncType').value = ''; $('sy_status').value = '';
|
||
loadSyncLog(1);
|
||
};
|
||
makePager('sy', loadSyncLog);
|
||
|
||
// ==================== 缓存管理(原 cache-view 功能) ====================
|
||
let currentKey = null;
|
||
let currentData = null;
|
||
let currentValue = null;
|
||
let currentFormat = 'json';
|
||
|
||
// 缓存名称映射:prefix 与 Key 精确相等或以 prefix_ 开头即命中。
|
||
// 注意:数组须按前缀长度降序排列,避免 sch_class 抢走 sch_class_teach / sch_class_student。
|
||
const CACHE_NAMES = [
|
||
{ prefix: 'bsch.classmanage.sch_class_student', name: '班级学生缓存' },
|
||
{ prefix: 'bsch.classmanage.sch_class_teach', name: '班级任教缓存' },
|
||
{ prefix: 'bsch.classmanage.sch_group_teacher', name: '教师组缓存' },
|
||
{ prefix: 'bsch.classmanage.sch_class', name: '班级缓存' },
|
||
{ prefix: 'bsch.classmanage.external_plan_data',name: '执行任务数据缓存' },
|
||
{ prefix: 'bsch.classmanage.external_progress', name: '执行进度缓存' },
|
||
{ prefix: 'bsch.classmanage.system_group_tree', name: '全区树缓存' },
|
||
{ prefix: 'bsch.classmanage.system_schsystem', name: '系统学制缓存' },
|
||
{ prefix: 'bsch.classmanage.system_yearterm', name: '系统学年学期缓存' },
|
||
{ prefix: 'bsch.classmanage.system_subject', name: '系统科目缓存' },
|
||
{ prefix: 'bsch.classmanage.system_grdae', name: '系统年级缓存' },
|
||
{ prefix: 'bsch.classmanage.sch_tree', name: '学校树缓存' },
|
||
{ prefix: 'bsch.classmanage.sch_list', name: '学校列表缓存' },
|
||
{ prefix: 'bsch.classmanage.sch_subject', name: '学校科目缓存' },
|
||
{ prefix: 'bsch.classmanage.sch_campus', name: '学校院校缓存' },
|
||
{ prefix: 'bsch.classmanage.sch_division', name: '学部缓存' },
|
||
{ prefix: 'bsch.classmanage.sch_majors', name: '专业缓存' },
|
||
{ prefix: 'bsch.classmanage.sch_course', name: '课程缓存' },
|
||
{ prefix: 'bsch.classmanage.sch_grdae', name: '学校年级缓存' },
|
||
{ prefix: 'bsch.classmanage.sch_teacher', name: '教师缓存' }
|
||
];
|
||
|
||
function cacheName(key) {
|
||
if (!key) return '';
|
||
for (const c of CACHE_NAMES) {
|
||
if (key === c.prefix || key.startsWith(c.prefix + '_')) return c.name;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function formatSize(bytes) {
|
||
if (bytes == null || bytes < 0) return '-';
|
||
if (bytes < 1024) return bytes + ' B';
|
||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
|
||
return (bytes / 1024 / 1024).toFixed(2) + ' MB';
|
||
}
|
||
|
||
async function queryByKey(key) {
|
||
if (!key) { toast('请输入 Key'); return; }
|
||
const btn = $('btnQuery');
|
||
showLoading(btn, '查询中');
|
||
try {
|
||
const data = await apiGet('/cache/get', { key });
|
||
currentKey = key;
|
||
currentData = data;
|
||
renderDetail(data);
|
||
markActiveKey(key);
|
||
} catch (e) {
|
||
toast('查询失败:' + e.message);
|
||
} finally {
|
||
hideLoading(btn);
|
||
}
|
||
}
|
||
|
||
async function listKeys(pattern) {
|
||
pattern = pattern || '*';
|
||
const btn = $('btnList');
|
||
showLoading(btn, '扫描中');
|
||
try {
|
||
const keys = await apiGet('/cache/keys', { pattern });
|
||
renderKeys(Array.from(keys || []).sort());
|
||
} catch (e) {
|
||
toast('列出失败:' + e.message);
|
||
} finally {
|
||
hideLoading(btn);
|
||
}
|
||
}
|
||
|
||
function renderKeys(keys) {
|
||
const box = $('keysList');
|
||
box.innerHTML = '';
|
||
if (!keys.length) {
|
||
box.innerHTML = '<div class="k" style="cursor:default;color:var(--muted)">未匹配到 Key</div>';
|
||
return;
|
||
}
|
||
keys.forEach(k => {
|
||
const div = document.createElement('div');
|
||
div.className = 'k';
|
||
const name = cacheName(k);
|
||
div.innerHTML = (name ? '<span class="kname">' + escHtml(name) + '</span>' : '') +
|
||
'<span class="kkey">' + escHtml(k) + '</span>';
|
||
div.title = k;
|
||
div.onclick = () => { $('keyInput').value = k; queryByKey(k); };
|
||
box.appendChild(div);
|
||
});
|
||
}
|
||
|
||
function markActiveKey(key) {
|
||
document.querySelectorAll('#keysList .k').forEach(el => {
|
||
el.classList.toggle('active', el.textContent === key);
|
||
});
|
||
}
|
||
|
||
function renderDetail(data) {
|
||
$('detailEmpty').style.display = 'none';
|
||
$('detailBody').style.display = 'block';
|
||
|
||
$('mKey').textContent = data.key || '';
|
||
$('mCacheName').textContent = cacheName(data.key) || '-';
|
||
const type = data.type || '-';
|
||
$('mType').innerHTML = '<span class="badge ' + type + '">' + type + '</span>';
|
||
|
||
const expireSec = data.expire;
|
||
let exp = data.expireText || '';
|
||
if (expireSec !== undefined && expireSec !== null && expireSec >= 0) {
|
||
exp += '(' + expireSec + ' 秒)';
|
||
}
|
||
$('mExpire').textContent = exp || '-';
|
||
$('mSize').textContent = (data.size !== undefined ? formatSize(data.size) : '-') +
|
||
(data.size !== undefined ? '(' + data.size + ' 字节)' : '');
|
||
|
||
renderValue(data.value);
|
||
}
|
||
|
||
function renderValue(value) {
|
||
currentValue = value;
|
||
currentData = Object.assign({}, currentData, { value });
|
||
const valType = Array.isArray(value) ? 'array' : (value && typeof value === 'object' ? 'object' : 'scalar');
|
||
const count = Array.isArray(value) ? value.length : (value && typeof value === 'object' ? Object.keys(value).length : 1);
|
||
renderFormatted();
|
||
$('rowCount').textContent = '类型=' + valType + ' · 数量=' + count;
|
||
}
|
||
|
||
function renderFormatted() {
|
||
if (currentValue === undefined || currentValue === null) return;
|
||
const value = currentValue;
|
||
if (currentFormat === 'text') {
|
||
const text = JSON.stringify(value);
|
||
$('valueJson').textContent = text;
|
||
} else {
|
||
const json = JSON.stringify(value, null, 2);
|
||
$('valueJson').innerHTML = highlightJson(json);
|
||
}
|
||
}
|
||
|
||
function highlightJson(json) {
|
||
let html = escHtml(json);
|
||
// 高亮 key(引号后跟冒号)
|
||
html = html.replace(/("(?:\\.|[^"\\])*")(\s*:)/g, '<span class="jk">$1</span>$2');
|
||
// 高亮 string value(冒号+空白后跟引号串)
|
||
html = html.replace(/(:\s*)("(?:\\.|[^"\\])*")/g, '$1<span class="js">$2</span>');
|
||
// 高亮 number
|
||
html = html.replace(/(:\s*)(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\b/g, '$1<span class="jn">$2</span>');
|
||
// 高亮 boolean / null
|
||
html = html.replace(/(:\s*)(true|false|null)\b/g, '$1<span class="jb">$2</span>');
|
||
return html;
|
||
}
|
||
|
||
async function deleteKey() {
|
||
if (!currentKey) return;
|
||
if (!confirm('确认删除 Key:\n' + currentKey + '\n此操作不可恢复!')) return;
|
||
const btn = $('btnDelete');
|
||
showLoading(btn, '删除中');
|
||
try {
|
||
const res = await fetch(API + '/cache/delete?key=' + encodeURIComponent(currentKey), {
|
||
method: 'POST', credentials: 'include', headers: authHeaders()
|
||
});
|
||
const loginRedirect = isCasRedirect(res);
|
||
let data = null; try { data = await res.json(); } catch (e) {}
|
||
if (loginRedirect) {
|
||
const pageUrl = location.origin + location.pathname.substring(0, location.pathname.lastIndexOf('/') + 1) + 'work-view.html';
|
||
throw new Error('未登录或登录态无效(请求被重定向到 CAS 登录页)。请在登录后访问:' + pageUrl);
|
||
}
|
||
if (!res.ok) throw new Error((data && (data.msg || data.message)) || ('HTTP ' + res.status));
|
||
toast('已删除:' + (data.msg || ''), true);
|
||
$('detailBody').style.display = 'none';
|
||
$('detailEmpty').style.display = 'block';
|
||
currentKey = null; currentData = null;
|
||
} catch (e) {
|
||
toast('删除失败:' + e.message);
|
||
} finally {
|
||
hideLoading(btn);
|
||
}
|
||
}
|
||
|
||
async function copyValue() {
|
||
if (currentValue === null || currentValue === undefined) { toast('当前没有可复制的内容'); return; }
|
||
const text = currentFormat === 'text' ? JSON.stringify(currentValue) : JSON.stringify(currentValue, null, 2);
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
toast('已复制到剪贴板', true);
|
||
} catch (e) {
|
||
// 降级方案
|
||
const ta = document.createElement('textarea');
|
||
ta.value = text;
|
||
ta.style.position = 'fixed';
|
||
ta.style.opacity = '0';
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
try {
|
||
document.execCommand('copy');
|
||
toast('已复制到剪贴板', true);
|
||
} catch (err) {
|
||
toast('复制失败:' + err.message);
|
||
}
|
||
document.body.removeChild(ta);
|
||
}
|
||
}
|
||
|
||
function refreshValue() {
|
||
if (!currentKey) { toast('当前没有 Key 可刷新'); return; }
|
||
queryByKey(currentKey);
|
||
}
|
||
|
||
function changeFormat(fmt) {
|
||
currentFormat = fmt || $('formatSelect').value;
|
||
$('formatSelect').value = currentFormat;
|
||
renderFormatted();
|
||
}
|
||
|
||
// 缓存管理事件绑定
|
||
$('btnQuery').onclick = () => queryByKey($('keyInput').value.trim());
|
||
$('btnList').onclick = () => listKeys($('patternInput').value.trim() || '*');
|
||
$('btnClear').onclick = () => { $('keyInput').value = ''; $('patternInput').value = ''; $('keysList').innerHTML = ''; };
|
||
$('btnDelete').onclick = deleteKey;
|
||
$('btnCopy').onclick = copyValue;
|
||
$('btnRefresh').onclick = refreshValue;
|
||
$('formatSelect').onchange = () => changeFormat();
|
||
$('keyInput').addEventListener('keydown', e => { if (e.key === 'Enter') queryByKey(e.target.value.trim()); });
|
||
$('patternInput').addEventListener('keydown', e => { if (e.key === 'Enter') listKeys(e.target.value.trim() || '*'); });
|
||
|
||
// 打开页面先校验登录态,未登录直接跳转 CAS;已登录再恢复上次的 Tab 与表格页码
|
||
(async () => {
|
||
const ok = await checkLogin();
|
||
if (!ok) return; // 已跳转登录页
|
||
const savedTab = loadState('wbTab');
|
||
switchTab(savedTab === 'oplog' || savedTab === 'synclog' || savedTab === 'cache' ? savedTab : 'oplog');
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|