This commit is contained in:
2026-08-19 21:27:50 +08:00
parent 3ed16db9e6
commit 300cbb807b
+505
View File
@@ -0,0 +1,505 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Redis 缓存查看(超管)</title>
<style>
:root {
--bg: #0f1419;
--panel: #1a212b;
--panel-2: #222c38;
--border: #2c3845;
--text: #d7e0ea;
--muted: #8b9aab;
--accent: #4c9aff;
--accent-2: #2f6fd1;
--green: #36c275;
--red: #ff6b6b;
--yellow: #f7c948;
--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;
}
header {
padding: 16px 24px;
border-bottom: 1px solid var(--border);
background: linear-gradient(180deg, #16202c, #0f1419);
}
header h1 { margin: 0; font-size: 18px; font-weight: 600; }
header .sub { margin-top: 4px; color: var(--muted); font-size: 12px; }
.wrap { display: flex; gap: 16px; padding: 16px 24px; align-items: flex-start; }
.left { width: 320px; flex: 0 0 320px; }
.right { flex: 1 1 auto; min-width: 0; }
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
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] {
width: 100%;
padding: 9px 11px;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-family: var(--mono);
outline: none;
}
input[type=text]:focus { border-color: var(--accent); }
.btn {
padding: 9px 14px;
border: 1px solid var(--border);
background: var(--panel-2);
color: var(--text);
border-radius: 8px;
cursor: pointer;
font-size: 13px;
}
.btn:hover { border-color: var(--accent); }
.btn.primary { background: var(--accent-2); border-color: var(--accent); color: #fff; }
.btn.primary:hover { background: var(--accent); }
.btn.danger { background: transparent; border-color: var(--red); color: var(--red); }
.btn.danger:hover { background: rgba(255,107,107,.12); }
.btn-row { display: flex; gap: 8px; flex-wrap: wrap; }
.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: var(--panel-2); }
.keys-list .k.active { background: rgba(76,154,255,.15); border-left: 3px solid var(--accent); padding-left: 7px; }
.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: 2px 8px; border-radius: 999px;
font-size: 11px; border: 1px solid var(--border); color: var(--muted);
}
.badge.string { color: var(--green); border-color: rgba(54,194,117,.4); }
.badge.hash { color: var(--accent); border-color: rgba(76,154,255,.4); }
.badge.list, .badge.set, .badge.zset { color: var(--yellow); border-color: rgba(247,201,72,.4); }
.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: var(--panel-2);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 13px;
outline: none;
cursor: pointer;
}
select:focus { border-color: var(--accent); }
pre.json {
margin: 0; padding: 12px; background: #0b0f14; border: 1px solid var(--border);
border-radius: 8px; overflow: auto; max-height: 60vh; font-family: var(--mono);
font-size: 12px; line-height: 1.5; color: #cfe3ff; white-space: pre;
}
pre.json .jk { color: #e06c75; } /* key */
pre.json .js { color: #98c379; } /* string value */
pre.json .jn { color: #d19a66; } /* number */
pre.json .jb { color: #c678dd; } /* boolean / null */
.empty { color: var(--muted); padding: 24px; text-align: center; }
.toast {
position: fixed; left: 50%; bottom: 28px; transform: translateX(-50%);
background: #2a1d1d; border: 1px solid var(--red); color: #ffd5d5;
padding: 10px 16px; border-radius: 8px; font-size: 13px; max-width: 80vw;
display: none; z-index: 50;
}
.toast.ok { background: #1d2a20; border-color: var(--green); color: #c9f4d8; }
.toast.show { display: block; }
.hint { color: var(--muted); font-size: 11px; margin-top: 6px; }
.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); }
</style>
</head>
<body>
<header>
<h1>Redis 缓存查看 <span style="color:var(--accent)">(超管)</span></h1>
<div class="sub">仅超级管理员可访问。部署后同源访问自动带登录态。</div>
</header>
<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 class="toast" id="toast"></div>
<script>
// 同源时从页面自身路径推导 API 基址(兼容 context-path = 根 / 或 /bschclass)
function resolveApi() {
const base = location.pathname.substring(0, location.pathname.lastIndexOf('/') + 1);
return base + 'api/cache';
}
const API = resolveApi();
let currentKey = null;
let currentData = null;
let currentValue = null;
let currentFormat = 'json';
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;');
}
// 缓存名称映射: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 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 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;
}
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';
}
// 检测被 CAS 登录拦截(302 重定向到登录页后再被 fetch 跟随后返回 HTML)
function checkLoginRedirect(res) {
const ct = res.headers.get('content-type') || '';
if (res.redirected || !ct.includes('json')) {
// 页面地址动态推导:开发 http://192.168.10.166:8086/,生产 http://base.yunhaixingtong.com/bschclass/
const pageUrl = location.origin + location.pathname.substring(0, location.pathname.lastIndexOf('/') + 1) + 'cache-view.html';
return '未登录或登录态无效(请求被重定向到 CAS 登录页)。' +
'请在「同一个浏览器、已 CAS 登录且为超级管理员」的会话下访问:' + pageUrl;
}
return null;
}
async function apiGet(path, params) {
const url = API + path + '?' + new URLSearchParams(params).toString();
const res = await fetch(url, { credentials: 'include', headers: authHeaders() });
const loginErr = checkLoginRedirect(res);
let data = null;
try { data = await res.json(); } catch (e) { /* ignore */ }
if (loginErr) throw new Error(loginErr);
if (!res.ok) {
const msg = (data && (data.msg || data.message)) || ('请求失败 HTTP ' + res.status);
throw new Error(msg);
}
// 统一解包:后端统一返回 { 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 queryByKey(key) {
if (!key) { toast('请输入 Key'); return; }
const btn = $('btnQuery');
showLoading(btn, '查询中');
try {
const data = await apiGet('/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('/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 + '/delete?key=' + encodeURIComponent(currentKey), {
method: 'POST', credentials: 'include', headers: authHeaders()
});
const loginErr = checkLoginRedirect(res);
let data = null; try { data = await res.json(); } catch (e) {}
if (loginErr) throw new Error(loginErr);
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() || '*'); });
</script>
</body>
</html>