Files
UserclassService/src/main/resources/static/work-view.html
T
2026-08-21 00:49:38 +08:00

1264 lines
61 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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>