zcbot/web/static/admin.html

328 lines
16 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" />
<title>zcbot 管理后台</title>
<style>
:root {
--bg: #f7f7f7; --panel: #ffffff; --border: #e3e3e3; --border-soft: #ececec;
--text: #222; --muted: #888; --accent: #c0392b; --accent-soft: #fde9e7;
--ok: #2e7d32; --warn: #c87f0a; --danger: #c0392b;
--r-md: 4px; --r-lg: 8px;
--mono: ui-monospace, "Cascadia Code", "SF Mono", Consolas, monospace;
}
* { box-sizing: border-box; }
body {
margin: 0; font-family: system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
color: var(--text); background: var(--bg); font-size: 13px; line-height: 1.5;
}
header {
position: sticky; top: 0; z-index: 10; background: #fff;
border-bottom: 1px solid var(--border); box-shadow: 0 1px 2px rgba(0,0,0,.03);
display: flex; align-items: center; gap: 12px; padding: 10px 16px; flex-wrap: wrap;
}
header .logo {
width: 24px; height: 24px; border-radius: var(--r-md);
background: linear-gradient(135deg, var(--accent), #8e2a20);
color: #fff; font-weight: 700; font-size: 13px;
display: flex; align-items: center; justify-content: center;
}
header .title { font-weight: 600; font-size: 15px; }
header .spacer { flex: 1; }
header .meta { color: var(--muted); font-size: 12px; font-family: var(--mono); }
header a, header button {
font-size: 12px; color: var(--accent); text-decoration: none;
padding: 4px 10px; border: 1px solid var(--accent-soft); border-radius: var(--r-md);
background: #fff; cursor: pointer;
}
header a:hover, header button:hover { background: var(--accent-soft); }
header label.auto { color: var(--muted); display: flex; align-items: center; gap: 4px; cursor: pointer; }
main { padding: 16px; max-width: 1200px; margin: 0 auto; }
.msg { padding: 40px 16px; text-align: center; color: var(--muted); }
.msg a { color: var(--accent); }
/* 目录 + 内容两栏;目录 sticky 跟随滚动 */
#layout { display: grid; grid-template-columns: 132px 1fr; gap: 18px; align-items: start; }
#toc { position: sticky; top: 64px; display: flex; flex-direction: column; gap: 2px; }
#toc a {
color: var(--muted); text-decoration: none; font-size: 13px; padding: 6px 10px;
border-radius: var(--r-md); border-left: 2px solid transparent;
}
#toc a:hover { background: var(--accent-soft); color: var(--accent); }
#toc a.active { color: var(--accent); border-left-color: var(--accent); background: var(--accent-soft); font-weight: 600; }
.anchor { scroll-margin-top: 64px; } /* 滚动定位避开 sticky 顶栏 */
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
.card-head h2 { margin: 0; }
.ctrl { display: flex; gap: 6px; }
.ctrl select {
font-size: 12px; padding: 3px 6px; border: 1px solid var(--border);
border-radius: var(--r-md); background: #fff; color: var(--text);
}
.sublabel { color: var(--muted); font-size: 11px; margin-bottom: 4px; }
.grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
.card {
background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-lg);
padding: 14px 16px; margin-bottom: 14px;
}
.card h2 { margin: 0 0 10px; font-size: 13px; color: var(--muted); font-weight: 600; letter-spacing: .3px; }
/* 大数字 stat 块 */
.stat { background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-lg); padding: 12px 14px; }
.stat .k { color: var(--muted); font-size: 12px; }
.stat .v { font-size: 22px; font-weight: 600; font-family: var(--mono); margin-top: 4px; }
.stat .sub { color: var(--muted); font-size: 11px; margin-top: 2px; }
.stat.warn { border-color: var(--warn); background: #fff8ec; }
.stat.warn .v { color: var(--warn); }
.stat.danger { border-color: var(--danger); background: var(--accent-soft); }
.stat.danger .v { color: var(--danger); }
/* chips(状态分布) */
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip {
font-size: 12px; padding: 3px 9px; border-radius: 999px;
background: #f1f1f1; color: var(--text); font-family: var(--mono);
}
.chip b { font-weight: 700; }
.chip.err { background: var(--accent-soft); color: var(--danger); }
.chip.run { background: #e7f3ff; color: #1565c0; }
.chip.ok { background: #e8f5e9; color: var(--ok); }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th, td { padding: 6px 8px; text-align: right; border-bottom: 1px solid var(--border-soft); white-space: nowrap; }
th:first-child, td:first-child { text-align: left; }
th { color: var(--muted); font-weight: 600; }
td.num { font-family: var(--mono); }
td.num.hot { color: var(--danger); font-weight: 700; }
td.trend { font-family: var(--mono); letter-spacing: 1px; color: var(--accent); }
tr.quiet td { opacity: .45; }
td.email { font-family: var(--mono); max-width: 220px; overflow: hidden; text-overflow: ellipsis; }
.bar-cell { position: relative; }
tfoot .total-row td { border-top: 2px solid var(--border); border-bottom: none; font-weight: 700; }
.scroll-x { overflow-x: auto; }
.empty { color: var(--muted); padding: 8px; text-align: center; }
#ext-admin-form label { display: grid; gap: 3px; color: var(--muted); font-size: 12px; }
#ext-admin-form [hidden] { display: none; }
#ext-admin-form input:not([type="checkbox"]), #ext-admin-form select {
width: 100%; padding: 6px 8px; border: 1px solid var(--border);
border-radius: var(--r-md); color: var(--text); background: #fff;
}
#ext-admin-form button, #s-external button {
font-size: 12px; padding: 4px 9px; border: 1px solid var(--border);
border-radius: var(--r-md); background: #fff; cursor: pointer;
}
#s-external button.primary, #ext-admin-form button.primary {
color: #fff; border-color: var(--accent); background: var(--accent);
}
#external-definition-modal .card {
width: min(820px, calc(100vw - 32px)); max-height: calc(100vh - 32px);
margin: 0; padding: 0; display: flex; flex-direction: column;
}
#ext-admin-form { display: flex; flex: 1; min-height: 0; flex-direction: column; }
.ext-modal-head {
display: flex; align-items: center; gap: 12px; padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
.ext-modal-head h3 { flex: 1; margin: 0; font-size: 14px; }
.ext-modal-head button { font-size: 18px !important; line-height: 1; border: 0 !important; }
.ext-form-body { min-height: 0; padding: 16px; overflow-y: auto; overscroll-behavior: contain; }
.ext-form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 12px; }
.ext-form-section {
grid-column: 1 / -1; margin: 4px 0 0; padding-top: 10px;
border-top: 1px solid var(--border-soft); color: var(--text); font-weight: 600;
}
.ext-form-actions {
display: flex; justify-content: flex-end; gap: 8px; padding: 10px 16px;
border-top: 1px solid var(--border); background: #fff;
}
/* 复用主控制台 dialog.js 的单例页内弹框。 */
.modal {
display: none; position: fixed; inset: 0; z-index: 130;
align-items: center; justify-content: center; padding: 16px;
background: rgba(0,0,0,.32);
}
.modal.show { display: flex; }
html.modal-open, body.modal-open { overflow: hidden; }
#app-dialog .card { width: min(560px, calc(100vw - 32px)); margin: 0; padding: 0; }
#app-dialog h3 { margin: 0; padding: 12px 16px; border-bottom: 1px solid var(--border); font-size: 14px; }
#app-dialog .body { padding: 16px; }
#app-dialog label { display: block; color: var(--muted); font-size: 12px; margin-bottom: 6px; }
#app-dialog input, #app-dialog textarea {
width: 100%; padding: 8px 10px; border: 1px solid var(--border);
border-radius: var(--r-md); color: var(--text); background: #fff; resize: vertical;
}
#app-dialog input:focus, #app-dialog textarea:focus { outline: none; border-color: var(--accent); }
#app-dialog .actions { display: flex; justify-content: flex-end; gap: 8px; padding: 10px 16px; border-top: 1px solid var(--border); }
#app-dialog button { padding: 5px 12px; border: 1px solid var(--border); border-radius: var(--r-md); background: #fff; cursor: pointer; }
#app-dialog button.primary { color: #fff; border-color: var(--accent); background: var(--accent); }
#s-windows-node button, #node-enrollment-modal button {
font-size: 12px; padding: 5px 11px; border: 1px solid var(--border);
border-radius: var(--r-md); background: #fff; color: var(--text); cursor: pointer;
}
#s-windows-node button.primary, #node-enrollment-modal button.primary {
color: #fff; border-color: var(--accent); background: var(--accent);
}
#s-windows-node button.danger {
color: var(--danger); border-color: #edc1bd; background: #fff;
}
#s-windows-node button:disabled { opacity: .55; cursor: default; }
.node-status {
display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px;
border-radius: 999px; background: #f1f1f1; color: var(--muted);
}
.node-status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
.node-status.online { color: var(--ok); background: #e8f5e9; }
.node-status.offline { color: var(--muted); }
.node-status.disabled { color: var(--danger); background: var(--accent-soft); }
.node-name { font-weight: 600; }
.node-id { color: var(--muted); font: 11px/1.4 var(--mono); }
.node-runtime { max-width: 280px; white-space: normal; color: var(--muted); }
.node-actions { text-align: right; }
#node-enrollment-modal .card { width: min(520px, calc(100vw - 32px)); margin: 0; padding: 0; }
.node-modal-head, .node-modal-actions {
display: flex; align-items: center; gap: 8px; padding: 11px 16px;
border-bottom: 1px solid var(--border);
}
.node-modal-head h3 { flex: 1; margin: 0; font-size: 14px; }
.node-modal-head button { padding: 2px 8px !important; border: 0 !important; font-size: 18px !important; }
.node-modal-body { padding: 16px; }
.node-modal-body label { display: block; margin-bottom: 6px; color: var(--muted); font-size: 12px; }
.node-modal-body input {
width: 100%; padding: 8px 10px; border: 1px solid var(--border);
border-radius: var(--r-md); color: var(--text); background: #fff;
}
.node-modal-actions { justify-content: flex-end; border-top: 1px solid var(--border); border-bottom: 0; }
.node-help { margin: 8px 0 0; color: var(--muted); font-size: 12px; }
.node-code-wrap { display: flex; gap: 8px; align-items: stretch; }
.node-code {
flex: 1; min-width: 0; padding: 10px; border: 1px solid var(--border);
border-radius: var(--r-md); background: #f8f8f8; font: 600 13px/1.5 var(--mono);
overflow-wrap: anywhere; user-select: all;
}
.node-warning {
margin: 0 0 12px; padding: 9px 10px; border-left: 3px solid var(--warn);
background: #fff8ec; color: #7a520d; font-size: 12px;
}
#app-messages {
position: fixed; top: 16px; left: 50%; z-index: 200;
display: flex; flex-direction: column; align-items: center; gap: 8px;
transform: translateX(-50%); pointer-events: none;
}
.app-msg {
padding: 8px 13px; border: 1px solid var(--border); border-radius: var(--r-md);
background: #fff; box-shadow: 0 4px 14px rgba(0,0,0,.12);
opacity: 0; transform: translateY(-6px); transition: opacity .15s, transform .15s;
pointer-events: auto;
}
.app-msg.in { opacity: 1; transform: translateY(0); }
.app-msg.success { border-color: #b8ddb9; background: #f1faf1; color: var(--ok); }
.app-msg.error { border-color: #edc1bd; background: var(--accent-soft); color: var(--danger); }
.pager { display: flex; align-items: center; gap: 12px; justify-content: flex-end; margin-top: 10px; }
.pager button {
font-size: 12px; padding: 4px 12px; border: 1px solid var(--border); border-radius: var(--r-md);
background: #fff; cursor: pointer; color: var(--text);
}
.pager button:hover:not(:disabled) { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
.pager button:disabled { opacity: .45; cursor: default; }
.pager .pginfo { color: var(--muted); font-size: 12px; font-family: var(--mono); }
@media (max-width: 640px) {
main { padding: 10px; }
.grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
.stat .v { font-size: 18px; }
/* header 紧凑化:缩 padding/gap、字号,gen-at 时间戳截断不撑宽 */
header { padding: 8px 10px; gap: 8px; }
header .title { font-size: 14px; }
header .meta { font-size: 11px; max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
header a, header button { padding: 4px 8px; }
/* card-head 允许换行,标题长 + 下拉不再撑出横向溢出 */
.card-head { flex-wrap: wrap; }
.ctrl { flex-wrap: wrap; }
.ctrl select { min-width: 0; }
.ext-form-grid { grid-template-columns: 1fr; }
.ext-form-grid > label, .ext-form-section { grid-column: 1 !important; }
.node-code-wrap { flex-direction: column; }
/* 窄屏:目录变顶部横向 chip 条 */
#layout { grid-template-columns: 1fr; gap: 10px; }
#toc {
flex-direction: row; overflow-x: auto; gap: 6px; top: 52px;
background: var(--bg); padding: 6px 0; z-index: 5;
}
#toc a { white-space: nowrap; border-left: none; border: 1px solid var(--border); }
#toc a.active { border-color: var(--accent); }
.anchor { scroll-margin-top: 96px; }
}
/* 屏幕上隐藏打印报告;打印时只显它 */
#print-report { display: none; }
@media print {
header, #layout, .msg { display: none !important; }
body { background: #fff; }
#print-report { display: block; color: #000; }
#print-report h1 { font-size: 18px; margin: 0 0 4px; }
#print-report h2 { font-size: 13px; margin: 14px 0 4px; border-bottom: 1px solid #999; padding-bottom: 2px; }
#print-report .rpt-meta { font-size: 11px; color: #555; margin-bottom: 8px; }
#print-report .rpt-kv { font-size: 12px; margin: 2px 0 4px; }
table.rpt { width: 100%; border-collapse: collapse; font-size: 11px; margin: 4px 0 6px; }
table.rpt th, table.rpt td { border: 1px solid #bbb; padding: 3px 6px; text-align: right; }
table.rpt th:first-child, table.rpt td:first-child { text-align: left; }
table.rpt th { background: #f0f0f0; }
@page { margin: 14mm; }
}
</style>
</head>
<body>
<header>
<div class="logo">Z</div>
<div class="title">zcbot 管理后台</div>
<span class="meta" id="gen-at"></span>
<div class="spacer"></div>
<label class="auto"><input type="checkbox" id="auto-refresh" checked /> 自动刷新</label>
<button id="refresh">刷新</button>
<button id="export">导出 PDF</button>
<a href="/static/dev.html">← 返回控制台</a>
</header>
<main id="main">
<div class="msg" id="boot">加载中…</div>
</main>
<!-- 导出 PDF:屏幕隐藏,仅 @media print 显示;exportPdf() 现填充后 window.print() -->
<div id="print-report"></div>
<div id="app-dialog" class="modal"></div>
<div id="node-enrollment-modal" class="modal" aria-hidden="true">
<div class="card" role="dialog" aria-modal="true" aria-labelledby="node-enrollment-title">
<div class="node-modal-head">
<h3 id="node-enrollment-title">生成 Windows Node 注册码</h3>
<button id="node-enrollment-close" type="button" aria-label="关闭">×</button>
</div>
<form id="node-enrollment-form">
<div class="node-modal-body" id="node-enrollment-inputs">
<label for="node-expected-name">限定节点名称(可选)</label>
<input id="node-expected-name" maxlength="120" autocomplete="off" placeholder="例如:实验室绘图节点 01" />
<p class="node-help">填写后,客户端注册时必须使用完全相同的节点名称。注册码有效期为 10 分钟。</p>
</div>
<div class="node-modal-body" id="node-enrollment-result" hidden>
<p class="node-warning">该注册码只能成功使用一次,关闭窗口后无法再次查看。请立即复制并发送到目标电脑。</p>
<label>一次性注册码</label>
<div class="node-code-wrap">
<code class="node-code" id="node-enrollment-code"></code>
<button id="node-enrollment-copy" type="button">复制</button>
</div>
<p class="node-help" id="node-enrollment-expiry"></p>
</div>
<div class="node-modal-actions">
<button id="node-enrollment-cancel" type="button">取消</button>
<button id="node-enrollment-submit" class="primary" type="submit">生成注册码</button>
</div>
</form>
</div>
</div>
<script type="module" src="/static/js/admin.js"></script>
</body>
</html>