1552 lines
70 KiB
HTML
1552 lines
70 KiB
HTML
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>SEAL API测试</title>
|
||
<style>
|
||
:root {
|
||
--bg: #f5f7fb;
|
||
--panel: #ffffff;
|
||
--line: #d7dde8;
|
||
--text: #1f2937;
|
||
--muted: #667085;
|
||
--primary: #1769aa;
|
||
--primary-soft: #e8f2fb;
|
||
--ok: #16794c;
|
||
--warn: #a15c00;
|
||
--err: #b42318;
|
||
--shadow: 0 1px 3px rgba(16, 24, 40, 0.12);
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
font-family: "Microsoft YaHei", "Segoe UI", Arial, sans-serif;
|
||
font-size: 14px;
|
||
letter-spacing: 0;
|
||
}
|
||
header {
|
||
height: 58px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0 20px;
|
||
border-bottom: 1px solid var(--line);
|
||
background: var(--panel);
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 10;
|
||
}
|
||
.brand { font-size: 18px; font-weight: 700; }
|
||
.status { display: flex; gap: 12px; color: var(--muted); align-items: center; }
|
||
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--ok); display: inline-block; }
|
||
nav {
|
||
display: flex;
|
||
gap: 8px;
|
||
padding: 12px 20px 0;
|
||
background: var(--bg);
|
||
}
|
||
nav button {
|
||
border: 1px solid var(--line);
|
||
background: var(--panel);
|
||
color: var(--text);
|
||
padding: 9px 14px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
}
|
||
nav button.active {
|
||
background: var(--primary);
|
||
border-color: var(--primary);
|
||
color: #fff;
|
||
}
|
||
main {
|
||
display: grid;
|
||
grid-template-columns: minmax(680px, 1fr) 420px;
|
||
gap: 14px;
|
||
padding: 14px 20px 20px;
|
||
}
|
||
.workspace { min-width: 0; }
|
||
.page { display: none; }
|
||
.page.active { display: block; }
|
||
.section {
|
||
background: var(--panel);
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
box-shadow: var(--shadow);
|
||
margin-bottom: 12px;
|
||
}
|
||
.section h2 {
|
||
margin: 0;
|
||
padding: 11px 14px;
|
||
font-size: 15px;
|
||
border-bottom: 1px solid var(--line);
|
||
background: #fbfcfe;
|
||
border-radius: 8px 8px 0 0;
|
||
}
|
||
.body { padding: 14px; }
|
||
.grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 10px;
|
||
}
|
||
.grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||
label { display: block; color: var(--muted); font-size: 12px; margin-bottom: 4px; }
|
||
.htip {
|
||
display: inline-block;
|
||
width: 14px;
|
||
height: 14px;
|
||
line-height: 14px;
|
||
text-align: center;
|
||
border-radius: 50%;
|
||
background: var(--primary);
|
||
color: #fff;
|
||
font-size: 10px;
|
||
font-weight: bold;
|
||
cursor: help;
|
||
vertical-align: middle;
|
||
margin-left: 5px;
|
||
position: relative;
|
||
user-select: none;
|
||
}
|
||
.htip-body {
|
||
display: none;
|
||
position: absolute;
|
||
top: 20px;
|
||
left: -3px;
|
||
background: #1f2937;
|
||
color: #fff;
|
||
padding: 8px 11px;
|
||
border-radius: 6px;
|
||
font-size: 12px;
|
||
font-weight: normal;
|
||
line-height: 1.6;
|
||
width: 280px;
|
||
max-width: 280px;
|
||
z-index: 1000;
|
||
white-space: normal;
|
||
text-align: left;
|
||
box-shadow: 0 4px 14px rgba(0,0,0,0.22);
|
||
pointer-events: none;
|
||
word-wrap: break-word;
|
||
overflow-wrap: break-word;
|
||
}
|
||
.htip-body.flip-left {
|
||
left: auto;
|
||
right: -3px;
|
||
}
|
||
.htip-body::before {
|
||
content: "";
|
||
position: absolute;
|
||
bottom: 100%;
|
||
left: 6px;
|
||
border: 5px solid transparent;
|
||
border-bottom-color: #1f2937;
|
||
}
|
||
.htip-body.flip-left::before {
|
||
left: auto;
|
||
right: 6px;
|
||
}
|
||
.htip:hover .htip-body,
|
||
.htip:focus .htip-body { display: block; }
|
||
input, select, textarea {
|
||
width: 100%;
|
||
border: 1px solid var(--line);
|
||
border-radius: 6px;
|
||
padding: 8px 9px;
|
||
color: var(--text);
|
||
background: #fff;
|
||
font: inherit;
|
||
}
|
||
input[type="checkbox"] { width: auto; vertical-align: middle; }
|
||
textarea { min-height: 110px; resize: vertical; font-family: Consolas, "Microsoft YaHei", monospace; }
|
||
button {
|
||
border: 1px solid var(--line);
|
||
background: #fff;
|
||
color: var(--text);
|
||
padding: 8px 12px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font: inherit;
|
||
white-space: nowrap;
|
||
}
|
||
button.primary { background: var(--primary); color: #fff; border-color: var(--primary); }
|
||
button.danger { color: var(--err); border-color: #f1b8b2; }
|
||
button:disabled { opacity: .55; cursor: not-allowed; }
|
||
.actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 12px; }
|
||
.input-row { display: flex; gap: 6px; align-items: stretch; }
|
||
.input-row input { flex: 1; min-width: 0; }
|
||
.input-row button { flex-shrink: 0; }
|
||
.row {
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
margin-bottom: 10px;
|
||
background: #fff;
|
||
}
|
||
.row-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
margin-bottom: 10px;
|
||
align-items: center;
|
||
}
|
||
.row-title { font-weight: 700; }
|
||
.hint { color: var(--muted); font-size: 12px; line-height: 1.55; }
|
||
.sr-only {
|
||
position: absolute;
|
||
width: 1px;
|
||
height: 1px;
|
||
padding: 0;
|
||
margin: -1px;
|
||
overflow: hidden;
|
||
clip: rect(0, 0, 0, 0);
|
||
white-space: nowrap;
|
||
border: 0;
|
||
}
|
||
.pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 2px 7px;
|
||
border-radius: 999px;
|
||
border: 1px solid var(--line);
|
||
background: #fff;
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
}
|
||
.ok { color: var(--ok); }
|
||
.warn { color: var(--warn); }
|
||
.err { color: var(--err); }
|
||
table { width: 100%; border-collapse: collapse; }
|
||
th, td {
|
||
border-bottom: 1px solid var(--line);
|
||
padding: 8px;
|
||
text-align: left;
|
||
vertical-align: top;
|
||
word-break: break-word;
|
||
}
|
||
th { color: var(--muted); font-size: 12px; background: #fbfcfe; }
|
||
.scroll { max-height: 360px; overflow: auto; border: 1px solid var(--line); border-radius: 8px; }
|
||
.json-box {
|
||
min-height: 220px;
|
||
white-space: pre;
|
||
overflow: auto;
|
||
background: #0f172a;
|
||
color: #e5edf8;
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
font-family: Consolas, monospace;
|
||
font-size: 12px;
|
||
}
|
||
.side {
|
||
position: sticky;
|
||
top: 72px;
|
||
height: calc(100vh - 92px);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
.log-toolbar {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 8px;
|
||
padding: 10px;
|
||
border-bottom: 1px solid var(--line);
|
||
}
|
||
.logs {
|
||
flex: 1;
|
||
overflow: auto;
|
||
padding: 10px;
|
||
background: #101828;
|
||
color: #d0d5dd;
|
||
font-family: Consolas, "Microsoft YaHei", monospace;
|
||
font-size: 12px;
|
||
line-height: 1.55;
|
||
}
|
||
.log-line {
|
||
padding: 5px 0;
|
||
border-bottom: 1px solid rgba(255, 255, 255, .06);
|
||
}
|
||
.log-line .meta { color: #98a2b3; }
|
||
.log-line.WARN { color: #ffd18a; }
|
||
.log-line.ERROR { color: #ffb4aa; }
|
||
.preview-images {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 10px;
|
||
}
|
||
.preview-box {
|
||
min-height: 150px;
|
||
border: 1px dashed var(--line);
|
||
border-radius: 8px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: #f8fafc;
|
||
}
|
||
.preview-box img { max-width: 100%; max-height: 220px; object-fit: contain; }
|
||
.template-list {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 8px;
|
||
margin-top: 8px;
|
||
}
|
||
.template-list button { text-align: left; white-space: normal; line-height: 1.35; }
|
||
@media (max-width: 1180px) {
|
||
main { grid-template-columns: 1fr; }
|
||
.side { position: static; height: 520px; }
|
||
.grid, .grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||
}
|
||
@media (max-width: 720px) {
|
||
nav { overflow-x: auto; }
|
||
main { padding: 10px; }
|
||
.grid, .grid.two, .preview-images, .template-list { grid-template-columns: 1fr; }
|
||
header { align-items: flex-start; flex-direction: column; height: auto; padding: 10px 14px; gap: 8px; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<div class="brand">SEAL Word 签章服务</div>
|
||
<div class="status"><span><span class="dot"></span> <span id="healthText">检查中</span></span><span id="limitText"></span></div>
|
||
</header>
|
||
<nav>
|
||
<button data-tab="api" class="active">API测试</button>
|
||
<button data-tab="batch">批量处理</button>
|
||
<button data-tab="monitor">请求监控</button>
|
||
<button data-tab="db">数据库管理</button>
|
||
</nav>
|
||
<main>
|
||
<div class="workspace">
|
||
<section id="page-api" class="page active">
|
||
<div class="section">
|
||
<h2>请求构建</h2>
|
||
<div class="body">
|
||
<div class="grid">
|
||
<div>
|
||
<label>Word .docx 文件</label>
|
||
<input id="docxFile" type="file" accept=".docx" />
|
||
</div>
|
||
<div>
|
||
<label>docx_name<span class="htip" tabindex="0">?<span class="htip-body">上传的 .docx 文件名(含扩展名)。后端用这个名字决定输出文件名(xxx_signed.docx),并校验扩展名必须是 .docx。</span></span></label>
|
||
<input id="docxName" />
|
||
</div>
|
||
<div>
|
||
<label>match_mode<span class="htip" tabindex="0">?<span class="htip-body">决定如何找到签名图片。<br>• upload: 手动上传图片<br>• id: 按人员 ID 从数据库查<br>• name: 按姓名从数据库查<br>• auto: 扫描文档里 SET USER_姓名 域代码自动匹配</span></span></label>
|
||
<select id="matchMode">
|
||
<option value="upload" title="用户手动上传图片,在下方盖章项里上传图片文件">upload 传图</option>
|
||
<option value="id" title="按人员 ID 从数据库查(下方填 image_id)">id 传人员ID</option>
|
||
<option value="name" title="按姓名从数据库查(下方填 image_name)">name 传名称</option>
|
||
<option value="auto" title="自动扫描文档里 SET USER_姓名 域代码(下方填 marker 或开启扫描全部)">auto 自动匹配</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label>auto 空 stamps 扫描全部<span class="htip" tabindex="0">?<span class="htip-body">仅当 match_mode=auto 时生效。<br>• true: 忽略下方 marker 列表,扫描文档里全部 USER_ 标记<br>• false: 只处理下方 marker 列表里指定的标记</span></span></label>
|
||
<select id="autoScanAll">
|
||
<option value="true" title="忽略下方 marker 列表,扫描文档里全部 USER_ 标记">是,扫描全部 USER_ 标记</option>
|
||
<option value="false" title="只处理下方 marker 列表里指定的标记">否,使用下方 marker</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="grid" style="margin-top:10px">
|
||
<div>
|
||
<label>use_global_align<span class="htip" tabindex="0">?<span class="htip-body">true=所有盖章项共用下方全局对齐参数;false=每个盖章项用自己的对齐参数(可各不相同)。</span></span></label>
|
||
<select id="useGlobalAlign"><option value="true">true</option><option value="false">false</option></select>
|
||
</div>
|
||
<div>
|
||
<label>全局 align_h<span class="htip" tabindex="0">?<span class="htip-body">图片在段落内的水平对齐方式。预留接口(当前版本未启用)。</span></span></label>
|
||
<select id="alignH">
|
||
<option title="左对齐">left</option>
|
||
<option title="居中">center</option>
|
||
<option title="右对齐">right</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label>全局 align_v<span class="htip" tabindex="0">?<span class="htip-body">图片在段落内的垂直对齐方式。预留接口(当前版本未启用)。</span></span></label>
|
||
<select id="alignV">
|
||
<option title="居中">center</option>
|
||
<option title="顶对齐">top</option>
|
||
<option title="底对齐">bottom</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label>use_global_height<span class="htip" tabindex="0">?<span class="htip-body">true=所有盖章项缩放到统一高度(下方全局 height);false=每个盖章项用自己配置的高度(可各不相同)。<br>留空 height = 自动根据插入位置字号/行高推算。</span></span></label>
|
||
<select id="useGlobalHeight"><option value="true">true</option><option value="false">false</option></select>
|
||
</div>
|
||
<div>
|
||
<label>全局 height(cm)<span class="htip" tabindex="0">?<span class="htip-body">签名图等比例缩放后的最终高度,单位厘米。范围 0.1~10。<br><b>留空 = 自动尺寸(推荐)</b>:根据插入位置的字号(2 倍)或段落行高(95%)自动推算。<br>填了具体值 = 强制使用该高度,但若超过段落行高/单元格宽度仍会等比缩小。</span></span></label>
|
||
<input id="globalHeight" type="number" min="0.1" max="10" step="0.1" value="" />
|
||
</div>
|
||
<div>
|
||
<label>use_global_noise<span class="htip" tabindex="0">?<span class="htip-body">true=所有盖章项共用下方全局噪声参数;false=各自配置(可各不相同)。</span></span></label>
|
||
<select id="useGlobalNoise"><option value="true">true</option><option value="false">false</option></select>
|
||
</div>
|
||
<div>
|
||
<label>全局 noise_level<span class="htip" tabindex="0">?<span class="htip-body">0~10,在笔画边缘撒随机噪点模拟印章/签字不均匀感(防伪)。0=关闭。数值越大噪点越密。仅在笔画边缘 ±2 像素范围内、且只撒到透明像素上,不会糊掉笔画。</span></span></label>
|
||
<input id="globalNoise" type="number" min="0" max="10" step="1" value="0" />
|
||
</div>
|
||
<div>
|
||
<label>use_global_rotate<span class="htip" tabindex="0">?<span class="htip-body">true=所有盖章项共用下方全局旋转参数;false=各自配置(可各不相同)。</span></span></label>
|
||
<select id="useGlobalRotate"><option value="true">true</option><option value="false">false</option></select>
|
||
</div>
|
||
<div>
|
||
<label>全局 rotate_min<span class="htip" tabindex="0">?<span class="htip-body">随机旋转角度的最小值(度,-180~180)。rotate_min 和 rotate_max 都是 0 则不旋转。如 -3 表示可能左旋到 -3°。旋转后由"紧致裁剪"切掉新增的透明边。</span></span></label>
|
||
<input id="globalRotateMin" type="number" min="-180" max="180" step="1" value="0" />
|
||
</div>
|
||
<div>
|
||
<label>全局 rotate_max<span class="htip" tabindex="0">?<span class="htip-body">随机旋转角度的最大值(度,-180~180)。如 3 表示可能右旋到 3°。两个都是 0 则不旋转。</span></span></label>
|
||
<input id="globalRotateMax" type="number" min="-180" max="180" step="1" value="0" />
|
||
</div>
|
||
<div>
|
||
<label>放宽段落布局<span class="htip" tabindex="0">?<span class="htip-body">true(默认):当签名位置段落有固定行高(lineRule=exact)或表格行有固定行高(hRule=exact)小于签名高度时,自动把约束改为 atLeast 并上调数值,让签名按设定尺寸完整显示,避免被 Word 硬裁剪。仅作用于段落直接格式,不修改样式。false=严格保留原文档布局,签名被缩放到约束内(可能很小)。</span></span></label>
|
||
<select id="relaxLayout"><option value="true">true</option><option value="false">false</option></select>
|
||
</div>
|
||
</div>
|
||
<div class="actions">
|
||
<button id="addStamp">新增盖章项</button>
|
||
<button id="buildJson" class="primary">构建 JSON</button>
|
||
<button id="sendApi" class="primary">发送 API</button>
|
||
<button id="clearApi">清空结果</button>
|
||
<span id="apiResult" class="hint"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="section">
|
||
<h2>盖章项</h2>
|
||
<div class="body" id="stampRows"></div>
|
||
</div>
|
||
<div class="section">
|
||
<h2>完整请求与响应</h2>
|
||
<div class="body grid two">
|
||
<div>
|
||
<label>请求 JSON</label>
|
||
<pre id="jsonPreview" class="json-box"></pre>
|
||
</div>
|
||
<div>
|
||
<label>响应</label>
|
||
<pre id="responsePreview" class="json-box"></pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="section">
|
||
<h2>签名图片预览(原图 vs 处理后)</h2>
|
||
<div class="body">
|
||
<div class="hint" style="margin-bottom:10px">
|
||
选择一个盖章项,预览原图与最终插入文档的处理后图片(走完整流水线:二值化/抠白/裁剪/缩放/噪声/旋转/紧致裁剪)。
|
||
<span id="previewAutoHint"></span>
|
||
</div>
|
||
<div class="grid">
|
||
<div>
|
||
<label>选择盖章项</label>
|
||
<select id="previewStampIndex"></select>
|
||
</div>
|
||
<div>
|
||
<label>当前匹配模式</label>
|
||
<input id="previewMatchMode" readonly />
|
||
</div>
|
||
<div>
|
||
<label>解析后姓名 / ID</label>
|
||
<input id="previewResolved" readonly placeholder="—" />
|
||
</div>
|
||
<div>
|
||
<label> </label>
|
||
<div class="actions" style="margin-top:0">
|
||
<button id="refreshPreview" class="primary">刷新预览</button>
|
||
<button id="scanDocMarkers">扫描文档标记</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="preview-images" style="margin-top:12px">
|
||
<div>
|
||
<label>原签名图</label>
|
||
<div class="preview-box"><img id="previewOriginal" alt="原图预览" style="display:none" /><div id="previewOriginalPlaceholder" class="hint">待加载</div></div>
|
||
</div>
|
||
<div>
|
||
<label>处理后(实际插入到文档)</label>
|
||
<div class="preview-box"><img id="previewProcessed" alt="处理后预览" style="display:none" /><div id="previewProcessedPlaceholder" class="hint">待加载</div></div>
|
||
</div>
|
||
</div>
|
||
<div id="previewInfo" class="hint" style="margin-top:8px"></div>
|
||
</div>
|
||
</div>
|
||
<div class="section">
|
||
<h2>OA Action 兼容</h2>
|
||
<div class="body hint">
|
||
成功响应为 HTTP 200、Word MIME 和原始 .docx 二进制;失败响应为非 2xx 和 JSON。OA Action 将成功字节写入具体 Action 配置的附件或文档字段。
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="page-batch" class="page">
|
||
<div class="section">
|
||
<h2>批量文件处理</h2>
|
||
<div class="body">
|
||
<div class="grid">
|
||
<div>
|
||
<label>Word 根目录路径</label>
|
||
<div class="input-row">
|
||
<input id="batchFolder" />
|
||
<button id="pickBatchFolder" type="button">浏览...</button>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label>递归子文件夹</label>
|
||
<select id="batchRecursive"><option value="true">true</option><option value="false">false</option></select>
|
||
</div>
|
||
<div>
|
||
<label>自动签名</label>
|
||
<select id="batchAutoSign"><option value="true">true</option><option value="false">false</option></select>
|
||
</div>
|
||
<div>
|
||
<label>规则文件路径</label>
|
||
<input id="rulesPath" value="rules.config" />
|
||
</div>
|
||
</div>
|
||
<div class="actions">
|
||
<button id="loadRules">加载规则</button>
|
||
<button id="saveRules">保存规则</button>
|
||
<button id="addRule">新增规则</button>
|
||
<button id="runBatch" class="primary">开始处理</button>
|
||
<span id="batchStatus" class="hint"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="section">
|
||
<h2>正则规则</h2>
|
||
<div class="body">
|
||
<div id="ruleRows"></div>
|
||
<label>常用提示</label>
|
||
<div id="snippetButtons" class="template-list"></div>
|
||
</div>
|
||
</div>
|
||
<div class="section">
|
||
<h2>测试与预览</h2>
|
||
<div class="body grid two">
|
||
<div>
|
||
<label>测试文本</label>
|
||
<textarea id="ruleTestText"></textarea>
|
||
<div class="actions"><button id="testRules" class="primary">测试正则</button></div>
|
||
</div>
|
||
<div>
|
||
<label>匹配结果</label>
|
||
<pre id="ruleTestResult" class="json-box"></pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="section">
|
||
<h2>批量结果</h2>
|
||
<div class="body">
|
||
<div class="scroll"><table><thead><tr><th>文件</th><th>状态</th><th>标记</th><th>输出</th><th>告警</th></tr></thead><tbody id="batchRows"></tbody></table></div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="page-monitor" class="page">
|
||
<div class="section">
|
||
<h2>OA/API 请求监控</h2>
|
||
<div class="body">
|
||
<div class="grid">
|
||
<div>
|
||
<label>状态</label>
|
||
<select id="monitorStatus"><option value="">全部</option><option value="running">running</option><option value="success">success</option><option value="error">error</option></select>
|
||
</div>
|
||
<div>
|
||
<label>搜索 trace_id / IP</label>
|
||
<input id="monitorSearch" />
|
||
</div>
|
||
</div>
|
||
<div class="actions"><button id="refreshMonitor" class="primary">刷新监控</button></div>
|
||
</div>
|
||
</div>
|
||
<div class="section">
|
||
<h2>请求列表</h2>
|
||
<div class="body"><div class="scroll"><table><thead><tr><th>时间</th><th>IP</th><th>trace_id</th><th>状态</th><th>响应</th><th>耗时</th></tr></thead><tbody id="monitorRows"></tbody></table></div></div>
|
||
</div>
|
||
<div class="section">
|
||
<h2>请求详情</h2>
|
||
<div class="body"><pre id="monitorDetail" class="json-box"></pre></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="page-db" class="page">
|
||
<div class="section">
|
||
<h2>图片数据库</h2>
|
||
<div class="body">
|
||
<div class="actions" style="margin-top:0">
|
||
<label class="sr-only" for="dbSearch">搜索 user_id / user_name</label>
|
||
<input id="dbSearch" />
|
||
<button id="dbRefresh" class="primary">刷新</button>
|
||
<button id="dbInit">初始化 MySQL 表</button>
|
||
</div>
|
||
<div class="scroll" style="margin-top:10px"><table><thead><tr><th>ID</th><th>姓名</th><th>is_signature</th><th>图片大小</th><th>操作</th></tr></thead><tbody id="dbRows"></tbody></table></div>
|
||
</div>
|
||
</div>
|
||
<div class="section">
|
||
<h2>新增 / 编辑</h2>
|
||
<div class="body">
|
||
<div class="grid">
|
||
<div>
|
||
<label>user_id</label>
|
||
<input id="dbUserId" />
|
||
</div>
|
||
<div>
|
||
<label>user_name</label>
|
||
<input id="dbUserName" />
|
||
</div>
|
||
<div>
|
||
<label>is_signature</label>
|
||
<select id="dbIsSignature"><option value="false">false 普通签章</option><option value="true">true 手写签字</option></select>
|
||
</div>
|
||
<div>
|
||
<label>图片</label>
|
||
<input id="dbImage" type="file" accept="image/png,image/jpeg" />
|
||
</div>
|
||
</div>
|
||
<div class="actions">
|
||
<button id="dbSave" class="primary">保存记录</button>
|
||
<button id="dbDelete" class="danger">删除记录</button>
|
||
<button id="dbNew">清空新增</button>
|
||
<span id="dbStatus" class="hint"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="section">
|
||
<h2>批量导入图片</h2>
|
||
<div class="body">
|
||
<div class="grid">
|
||
<div>
|
||
<label>选择图片</label>
|
||
<input id="dbImportFiles" type="file" accept="image/png,image/jpeg" multiple />
|
||
</div>
|
||
<div>
|
||
<label>自动 ID 前缀</label>
|
||
<input id="dbImportPrefix" value="U" />
|
||
</div>
|
||
<div>
|
||
<label>起始序号</label>
|
||
<input id="dbImportStart" type="number" min="1" step="1" value="1" />
|
||
</div>
|
||
<div>
|
||
<label>序号位数</label>
|
||
<input id="dbImportPadding" type="number" min="1" max="20" step="1" value="4" />
|
||
</div>
|
||
<div>
|
||
<label>默认 is_signature</label>
|
||
<select id="dbImportIsSignature"><option value="false">false 普通签章</option><option value="true">true 手写签字</option></select>
|
||
</div>
|
||
</div>
|
||
<div class="actions">
|
||
<button id="dbRunImport" class="primary">批量导入</button>
|
||
<button id="dbClearImport">清空队列</button>
|
||
<span id="dbImportStatus" class="hint"></span>
|
||
</div>
|
||
<div class="scroll" style="margin-top:10px"><table><thead><tr><th>文件</th><th>姓名</th><th>大小</th><th>状态</th></tr></thead><tbody id="dbImportRows"></tbody></table></div>
|
||
<pre id="dbImportResult" class="json-box" style="min-height:90px"></pre>
|
||
</div>
|
||
</div>
|
||
<div class="section">
|
||
<h2>原图 / 处理后预览</h2>
|
||
<div class="body preview-images">
|
||
<div class="preview-box"><img id="dbOriginalPreview" alt="原图预览" /></div>
|
||
<div class="preview-box"><img id="dbProcessedPreview" alt="处理后预览" /></div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<aside class="section side">
|
||
<h2>实时日志</h2>
|
||
<div class="log-toolbar">
|
||
<select id="logLevel"><option value="">全部级别</option><option>INFO</option><option>WARN</option><option>ERROR</option></select>
|
||
<label class="sr-only" for="logSearch">搜索日志</label>
|
||
<input id="logSearch" />
|
||
<select id="logSource"><option value="">全部来源</option><option value="api">api</option><option value="frontend">frontend</option><option value="db">db</option><option value="batch">batch</option></select>
|
||
<button id="clearLogs">清空显示</button>
|
||
</div>
|
||
<div id="logs" class="logs"></div>
|
||
</aside>
|
||
</main>
|
||
|
||
<script>
|
||
const state = {
|
||
docxBase64: "",
|
||
stamps: [
|
||
{ marker: "", is_signature: false, image_filename: "", image_base64: "", image_id: "", image_name: "", align_h: "left", align_v: "center", height: "2.5", noise_level: "0", rotate_min: "0", rotate_max: "0" }
|
||
],
|
||
rules: [],
|
||
snippets: [],
|
||
activeRule: 0,
|
||
selectedMonitor: null,
|
||
selectedUserId: "",
|
||
dbImageBase64: "",
|
||
dbImportItems: [],
|
||
lastLogId: 0,
|
||
fastPollUntil: 0,
|
||
ruleAutosaveTimer: null
|
||
};
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
const boolValue = (id) => $(id).value === "true";
|
||
const numberOrNull = (value) => value === "" || value == null ? null : Number(value);
|
||
const pretty = (value) => JSON.stringify(value, null, 2);
|
||
|
||
function addLocalLog(level, source, message, data) {
|
||
const row = document.createElement("div");
|
||
row.className = "log-line " + level;
|
||
row.innerHTML = `<span class="meta">[本地] [${level}] [${source}]</span> ${escapeHtml(message)}${data ? " " + escapeHtml(JSON.stringify(data)) : ""}`;
|
||
$("logs").appendChild(row);
|
||
$("logs").scrollTop = $("logs").scrollHeight;
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
return String(text).replace(/[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ch]));
|
||
}
|
||
|
||
function fileToBase64(file) {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => resolve(String(reader.result).split(",", 2)[1] || "");
|
||
reader.onerror = () => reject(reader.error);
|
||
reader.readAsDataURL(file);
|
||
});
|
||
}
|
||
|
||
function renderStamps() {
|
||
$("stampRows").innerHTML = state.stamps.map((stamp, index) => `
|
||
<div class="row" data-stamp="${index}">
|
||
<div class="row-head">
|
||
<div class="row-title">盖章项 ${index + 1} <span class="pill">${escapeHtml(stamp.marker || "未设置 marker")}</span></div>
|
||
<button class="danger" data-remove-stamp="${index}">删除</button>
|
||
</div>
|
||
<div class="grid">
|
||
<div><label>marker<span class="htip" tabindex="0">?<span class="htip-body">盖章定位标记,对应文档里 SET USER_姓名 的 USER_姓名 部分。auto 模式下若开启"扫描全部"可空。</span></span></label><input data-stamp-field="marker" value="${escapeHtml(stamp.marker || "")}" /></div>
|
||
<div><label>is_signature<span class="htip" tabindex="0">?<span class="htip-body">决定图片处理流水线要不要先做黑白二值化。<br>• true: 手写签字(先二值化,把浅色噪点和灰背景一刀切清理掉)<br>• false: 普通签章/印章(保留原色彩,只做透明背景处理)</span></span></label><select data-stamp-field="is_signature"><option value="true" title="手写签字(先二值化,把浅色噪点和灰背景一刀切清理掉)" ${stamp.is_signature ? "selected" : ""}>true</option><option value="false" title="普通签章/印章(保留原色彩,只做透明背景处理)" ${!stamp.is_signature ? "selected" : ""}>false</option></select></div>
|
||
<div><label>image_id<span class="htip" tabindex="0">?<span class="htip-body">仅 match_mode=id 时使用。数据库里的人员 ID。</span></span></label><input data-stamp-field="image_id" value="${escapeHtml(stamp.image_id || "")}" /></div>
|
||
<div><label>image_name<span class="htip" tabindex="0">?<span class="htip-body">仅 match_mode=name 时使用。数据库里的姓名(重名取第一条)。</span></span></label><input data-stamp-field="image_name" value="${escapeHtml(stamp.image_name || "")}" /></div>
|
||
<div><label>上传图片<span class="htip" tabindex="0">?<span class="htip-body">仅 match_mode=upload 时使用。支持 PNG/JPEG,建议透明背景或纯白背景。</span></span></label><input type="file" accept="image/png,image/jpeg" data-stamp-image="${index}" /></div>
|
||
<div><label>align_h<span class="htip" tabindex="0">?<span class="htip-body">单项水平对齐。仅 use_global_align=false 时生效。预留接口(当前版本未启用)。</span></span></label><select data-stamp-field="align_h"><option title="左对齐" ${stamp.align_h === "left" ? "selected" : ""}>left</option><option title="居中" ${stamp.align_h === "center" ? "selected" : ""}>center</option><option title="右对齐" ${stamp.align_h === "right" ? "selected" : ""}>right</option></select></div>
|
||
<div><label>align_v<span class="htip" tabindex="0">?<span class="htip-body">单项垂直对齐。仅 use_global_align=false 时生效。预留接口(当前版本未启用)。</span></span></label><select data-stamp-field="align_v"><option title="居中" ${stamp.align_v === "center" ? "selected" : ""}>center</option><option title="顶对齐" ${stamp.align_v === "top" ? "selected" : ""}>top</option><option title="底对齐" ${stamp.align_v === "bottom" ? "selected" : ""}>bottom</option></select></div>
|
||
<div><label>height(cm)<span class="htip" tabindex="0">?<span class="htip-body">单项高度(厘米),范围 0.1~10。仅 use_global_height=false 时生效。<br><b>留空 = 自动</b>:根据插入位置字号/段落行高推算。</span></span></label><input type="number" step="0.1" data-stamp-field="height" value="${escapeHtml(stamp.height ?? "")}" /></div>
|
||
<div><label>noise_level<span class="htip" tabindex="0">?<span class="htip-body">单项噪声 0~10。仅 use_global_noise=false 时生效。</span></span></label><input type="number" step="1" data-stamp-field="noise_level" value="${escapeHtml(stamp.noise_level ?? "")}" /></div>
|
||
<div><label>rotate_min<span class="htip" tabindex="0">?<span class="htip-body">单项旋转角度最小值(度)。仅 use_global_rotate=false 时生效。两个都空或 0 则不旋转。</span></span></label><input type="number" step="1" data-stamp-field="rotate_min" value="${escapeHtml(stamp.rotate_min ?? "")}" /></div>
|
||
<div><label>rotate_max<span class="htip" tabindex="0">?<span class="htip-body">单项旋转角度最大值(度)。仅 use_global_rotate=false 时生效。</span></span></label><input type="number" step="1" data-stamp-field="rotate_max" value="${escapeHtml(stamp.rotate_max ?? "")}" /></div>
|
||
<div><label>图片文件名<span class="htip" tabindex="0">?<span class="htip-body">仅 upload 模式用,记录原图文件名,仅用于日志追踪,不影响处理。</span></span></label><input data-stamp-field="image_filename" value="${escapeHtml(stamp.image_filename || "")}" /></div>
|
||
</div>
|
||
</div>
|
||
`).join("");
|
||
updatePreviewStampDropdown();
|
||
}
|
||
|
||
function stampPayload(stamp) {
|
||
const item = {
|
||
marker: stamp.marker,
|
||
is_signature: !!stamp.is_signature,
|
||
align_h: stamp.align_h,
|
||
align_v: stamp.align_v,
|
||
height: numberOrNull(stamp.height),
|
||
noise_level: numberOrNull(stamp.noise_level),
|
||
rotate_min: numberOrNull(stamp.rotate_min),
|
||
rotate_max: numberOrNull(stamp.rotate_max)
|
||
};
|
||
const mode = $("matchMode").value;
|
||
if (mode === "upload") {
|
||
item.image_filename = stamp.image_filename || "";
|
||
item.image_base64 = stamp.image_base64 || "";
|
||
} else if (mode === "id") {
|
||
item.image_id = stamp.image_id || "";
|
||
} else if (mode === "name") {
|
||
item.image_name = stamp.image_name || "";
|
||
}
|
||
return item;
|
||
}
|
||
|
||
function buildPayload() {
|
||
const mode = $("matchMode").value;
|
||
const payload = {
|
||
docx_name: $("docxName").value,
|
||
docx_base64: state.docxBase64 || "",
|
||
match_mode: mode,
|
||
use_global_align: boolValue("useGlobalAlign"),
|
||
align_h: $("alignH").value,
|
||
align_v: $("alignV").value,
|
||
use_global_height: boolValue("useGlobalHeight"),
|
||
height: numberOrNull($("globalHeight").value),
|
||
use_global_noise: boolValue("useGlobalNoise"),
|
||
noise_level: numberOrNull($("globalNoise").value),
|
||
use_global_rotate: boolValue("useGlobalRotate"),
|
||
rotate_min: numberOrNull($("globalRotateMin").value),
|
||
rotate_max: numberOrNull($("globalRotateMax").value),
|
||
relax_layout: boolValue("relaxLayout"),
|
||
stamps: mode === "auto" && boolValue("autoScanAll") ? [] : state.stamps.map(stampPayload)
|
||
};
|
||
$("jsonPreview").textContent = pretty(payload);
|
||
return payload;
|
||
}
|
||
|
||
async function sendApi() {
|
||
const payload = buildPayload();
|
||
if (!state.docxBase64) {
|
||
$("apiResult").textContent = "请先选择 .docx 文件";
|
||
addLocalLog("WARN", "frontend", "未选择 Word 文件");
|
||
return;
|
||
}
|
||
state.fastPollUntil = Date.now() + 5000;
|
||
$("sendApi").disabled = true;
|
||
$("apiResult").textContent = "请求处理中";
|
||
try {
|
||
const response = await fetch("/api/word/sign", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const type = response.headers.get("Content-Type") || "";
|
||
if (response.ok && type.includes("wordprocessingml.document")) {
|
||
const blob = await response.blob();
|
||
const name = contentDispositionName(response.headers.get("Content-Disposition")) || "signed.docx";
|
||
const url = URL.createObjectURL(blob);
|
||
$("responsePreview").innerHTML = `成功返回 .docx\\n文件名: ${name}\\n大小: ${blob.size} bytes\\ntrace_id: ${response.headers.get("X-SEAL-Trace-Id") || ""}\\nwarnings: ${response.headers.get("X-SEAL-Warnings") || "0"}\\n`;
|
||
const link = document.createElement("a");
|
||
link.href = url;
|
||
link.download = name;
|
||
link.textContent = "下载签章结果";
|
||
link.style.color = "#8ecbff";
|
||
$("responsePreview").appendChild(link);
|
||
$("apiResult").textContent = "成功";
|
||
addLocalLog("INFO", "frontend", "API 返回签章 Word", { name, size: blob.size });
|
||
} else {
|
||
const data = await response.json().catch(() => ({ message: "无法解析错误响应" }));
|
||
$("responsePreview").textContent = pretty(data);
|
||
$("apiResult").textContent = "失败";
|
||
addLocalLog("ERROR", "frontend", "API 返回错误", data);
|
||
}
|
||
await refreshMonitor();
|
||
} catch (error) {
|
||
$("responsePreview").textContent = String(error);
|
||
addLocalLog("ERROR", "frontend", "API 请求失败", { error: String(error) });
|
||
} finally {
|
||
$("sendApi").disabled = false;
|
||
}
|
||
}
|
||
|
||
function contentDispositionName(value) {
|
||
if (!value) return "";
|
||
const utf8 = value.match(/filename\*=UTF-8''([^;]+)/i);
|
||
if (utf8) return decodeURIComponent(utf8[1]);
|
||
const match = value.match(/filename="([^"]+)"/);
|
||
return match ? match[1] : "";
|
||
}
|
||
|
||
function updatePreviewStampDropdown() {
|
||
const sel = $("previewStampIndex");
|
||
const prevValue = sel.value;
|
||
const mode = $("matchMode").value;
|
||
$("previewMatchMode").value = mode + (mode === "auto" && boolValue("autoScanAll") ? " (扫描全部)" : "");
|
||
$("previewAutoHint").textContent = "";
|
||
|
||
if (!state.stamps.length) {
|
||
sel.innerHTML = `<option value="">(无盖章项)</option>`;
|
||
$("previewResolved").value = "";
|
||
return;
|
||
}
|
||
sel.innerHTML = state.stamps.map((s, i) => {
|
||
let label;
|
||
if (mode === "upload") label = s.image_filename || s.marker || `#${i + 1}`;
|
||
else if (mode === "id") label = s.image_id || s.marker || `#${i + 1}`;
|
||
else if (mode === "name") label = s.image_name || s.marker || `#${i + 1}`;
|
||
else label = s.marker || `#${i + 1}`;
|
||
return `<option value="${i}">#${i + 1} ${escapeHtml(label)}</option>`;
|
||
}).join("");
|
||
if (prevValue && Number(prevValue) < state.stamps.length) {
|
||
sel.value = prevValue;
|
||
}
|
||
}
|
||
|
||
function buildPreviewPayload() {
|
||
const mode = $("matchMode").value;
|
||
const idx = Number($("previewStampIndex").value);
|
||
if (isNaN(idx) || !state.stamps[idx]) {
|
||
return { error: "未选择盖章项" };
|
||
}
|
||
const stamp = state.stamps[idx];
|
||
|
||
const useGHeight = boolValue("useGlobalHeight");
|
||
const useGNoise = boolValue("useGlobalNoise");
|
||
const useGRotate = boolValue("useGlobalRotate");
|
||
|
||
const height = useGHeight ? numberOrNull($("globalHeight").value) : numberOrNull(stamp.height);
|
||
const noise = useGNoise ? numberOrNull($("globalNoise").value) : numberOrNull(stamp.noise_level);
|
||
const rmin = useGRotate ? numberOrNull($("globalRotateMin").value) : numberOrNull(stamp.rotate_min);
|
||
const rmax = useGRotate ? numberOrNull($("globalRotateMax").value) : numberOrNull(stamp.rotate_max);
|
||
|
||
const payload = {
|
||
match_mode: mode,
|
||
is_signature: !!stamp.is_signature,
|
||
height_cm: height,
|
||
noise_level: noise,
|
||
rotate_min: rmin,
|
||
rotate_max: rmax,
|
||
};
|
||
|
||
let resolved = "";
|
||
if (mode === "upload") {
|
||
payload.image_base64 = stamp.image_base64 || "";
|
||
resolved = stamp.image_filename || "(上传图)";
|
||
} else if (mode === "id") {
|
||
payload.image_id = stamp.image_id || "";
|
||
resolved = stamp.image_id || "(空)";
|
||
} else if (mode === "name") {
|
||
payload.image_name = stamp.image_name || "";
|
||
resolved = stamp.image_name || "(空)";
|
||
} else if (mode === "auto") {
|
||
payload.marker = stamp.marker || "";
|
||
resolved = stamp.marker || "(空)";
|
||
}
|
||
return { payload, resolved };
|
||
}
|
||
|
||
async function refreshPreview() {
|
||
const built = buildPreviewPayload();
|
||
if (built.error) {
|
||
$("previewInfo").textContent = built.error;
|
||
return;
|
||
}
|
||
const { payload, resolved } = built;
|
||
$("previewResolved").value = resolved;
|
||
|
||
// 校验必填项
|
||
const mode = payload.match_mode;
|
||
if (mode === "upload" && !payload.image_base64) {
|
||
$("previewInfo").textContent = "upload 模式下该盖章项未上传图片";
|
||
return;
|
||
}
|
||
if (mode === "id" && !payload.image_id) {
|
||
$("previewInfo").textContent = "id 模式下该盖章项未填 image_id";
|
||
return;
|
||
}
|
||
if (mode === "name" && !payload.image_name) {
|
||
$("previewInfo").textContent = "name 模式下该盖章项未填 image_name";
|
||
return;
|
||
}
|
||
if (mode === "auto" && !payload.marker) {
|
||
$("previewInfo").textContent = "auto 模式下该盖章项未填 marker";
|
||
return;
|
||
}
|
||
|
||
$("previewInfo").textContent = "加载中...";
|
||
$("refreshPreview").disabled = true;
|
||
try {
|
||
const resp = await fetch("/api/preview/sign-image", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const data = await resp.json();
|
||
if (!resp.ok || !data.success) {
|
||
$("previewInfo").textContent = "失败: " + (data.message || resp.status);
|
||
addLocalLog("WARN", "frontend", "图片预览失败", { message: data.message });
|
||
return;
|
||
}
|
||
$("previewOriginal").src = data.original;
|
||
$("previewOriginal").style.display = "";
|
||
$("previewOriginalPlaceholder").style.display = "none";
|
||
$("previewProcessed").src = data.processed;
|
||
$("previewProcessed").style.display = "";
|
||
$("previewProcessedPlaceholder").style.display = "none";
|
||
const fmtSize = (b) => b < 1024 ? `${b} B` : `${(b / 1024).toFixed(1)} KB`;
|
||
const dims = (arr) => arr && arr[0] ? `${arr[0]}×${arr[1]} px` : "—";
|
||
const p = data.params || {};
|
||
const paramStr = [`is_signature=${data.is_signature}`,
|
||
`height=${p.height_cm ?? "默认"}`, `noise=${p.noise_level ?? 0}`,
|
||
`rotate=[${p.rotate_min ?? 0}, ${p.rotate_max ?? 0}]`].join(" / ");
|
||
$("previewInfo").innerHTML = `原图: ${dims(data.original_dimensions)}, ${fmtSize(data.original_size)} → 处理后: ${dims(data.processed_dimensions)}, ${fmtSize(data.processed_size)}<br>参数: ${escapeHtml(paramStr)}`;
|
||
addLocalLog("INFO", "frontend", "图片预览已刷新", { resolved, processed_dims: data.processed_dimensions });
|
||
} catch (error) {
|
||
$("previewInfo").textContent = "请求失败: " + String(error);
|
||
} finally {
|
||
$("refreshPreview").disabled = false;
|
||
}
|
||
}
|
||
|
||
async function scanDocMarkers() {
|
||
if (!state.docxBase64) {
|
||
$("previewInfo").textContent = "请先选择 .docx 文件";
|
||
return;
|
||
}
|
||
$("previewInfo").textContent = "扫描中...";
|
||
$("scanDocMarkers").disabled = true;
|
||
try {
|
||
const resp = await fetch("/api/doc/scan-markers", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ docx_base64: state.docxBase64 })
|
||
});
|
||
const data = await resp.json();
|
||
if (!resp.ok || !data.success) {
|
||
$("previewInfo").textContent = "扫描失败: " + (data.message || resp.status);
|
||
return;
|
||
}
|
||
const markers = data.markers || [];
|
||
if (!markers.length) {
|
||
$("previewInfo").textContent = "未在文档中扫描到 SET USER_ 标记";
|
||
return;
|
||
}
|
||
state.stamps = markers.map((m) => ({
|
||
marker: m.marker, is_signature: false, image_filename: "", image_base64: "",
|
||
image_id: "", image_name: m.name, align_h: "left", align_v: "center",
|
||
height: "", noise_level: "", rotate_min: "", rotate_max: ""
|
||
}));
|
||
renderStamps();
|
||
updatePreviewStampDropdown();
|
||
buildPayload();
|
||
$("previewInfo").textContent = `扫描到 ${markers.length} 个标记,已填入盖章项`;
|
||
addLocalLog("INFO", "frontend", "扫描文档标记完成", { count: markers.length });
|
||
} catch (error) {
|
||
$("previewInfo").textContent = "请求失败: " + String(error);
|
||
} finally {
|
||
$("scanDocMarkers").disabled = false;
|
||
}
|
||
}
|
||
|
||
async function loadRules() {
|
||
const qs = $("rulesPath").value ? `?path=${encodeURIComponent($("rulesPath").value)}` : "";
|
||
const data = await fetchJson("/api/batch/rules" + qs);
|
||
state.rules = data.rules || [];
|
||
renderRules();
|
||
$("batchStatus").textContent = data.warning || `已加载 ${state.rules.length} 条规则`;
|
||
addLocalLog(data.warning ? "WARN" : "INFO", "frontend", $("batchStatus").textContent);
|
||
}
|
||
|
||
function renderRules() {
|
||
$("ruleRows").innerHTML = state.rules.map((rule, index) => `
|
||
<div class="row" data-rule="${index}">
|
||
<div class="row-head">
|
||
<div class="row-title">规则 ${index + 1} <span class="pill">${escapeHtml(rule.name || "")}</span></div>
|
||
<button class="danger" data-remove-rule="${index}">删除</button>
|
||
</div>
|
||
<div class="grid">
|
||
<div><label>启用</label><select data-rule-field="enabled"><option value="true" ${rule.enabled !== false ? "selected" : ""}>true</option><option value="false" ${rule.enabled === false ? "selected" : ""}>false</option></select></div>
|
||
<div><label>名称</label><input data-rule-field="name" value="${escapeHtml(rule.name || "")}" /></div>
|
||
<div><label>捕获组</label><input type="number" min="1" step="1" data-rule-field="capture_group" value="${escapeHtml(rule.capture_group || 1)}" /></div>
|
||
<div><label>表格方式</label><select data-rule-field="cell_mode"><option value="single" ${rule.cell_mode === "single" ? "selected" : ""}>single 单段/单格</option><option value="horizontal" ${rule.cell_mode === "horizontal" ? "selected" : ""}>horizontal 横向两格</option><option value="vertical" ${rule.cell_mode === "vertical" ? "selected" : ""}>vertical 纵向两格</option></select></div>
|
||
<div><label>匹配方式</label><select data-rule-field="cell_match"><option value="partial" ${rule.cell_match !== "whole" ? "selected" : ""}>partial 部分匹配</option><option value="whole" ${rule.cell_match === "whole" ? "selected" : ""}>whole 整格匹配</option></select></div>
|
||
<div style="grid-column: span 3"><label>正则表达式</label><input data-rule-field="pattern" value="${escapeHtml(rule.pattern || "")}" /></div>
|
||
</div>
|
||
</div>
|
||
`).join("");
|
||
}
|
||
|
||
function renderSnippets() {
|
||
$("snippetButtons").innerHTML = state.snippets.map((item) => `
|
||
<button data-snippet="${item.id}"><b>${escapeHtml(item.label)}</b><br><span class="hint">${escapeHtml(item.help)}</span><br><code>${escapeHtml(item.pattern)}</code></button>
|
||
`).join("");
|
||
}
|
||
|
||
async function loadSnippets() {
|
||
const data = await fetchJson("/api/batch/regex-snippets");
|
||
state.snippets = data.snippets || [];
|
||
renderSnippets();
|
||
}
|
||
|
||
async function saveRules() {
|
||
const payload = { rules: state.rules };
|
||
if ($("rulesPath").value) payload.path = $("rulesPath").value;
|
||
const data = await fetchJson("/api/batch/rules", { method: "POST", body: JSON.stringify(payload) });
|
||
state.rules = data.rules || state.rules;
|
||
renderRules();
|
||
$("batchStatus").textContent = `已保存到 ${data.path}`;
|
||
addLocalLog("INFO", "frontend", $("batchStatus").textContent);
|
||
}
|
||
|
||
function scheduleRuleAutosave() {
|
||
clearTimeout(state.ruleAutosaveTimer);
|
||
state.ruleAutosaveTimer = setTimeout(async () => {
|
||
try {
|
||
const payload = { rules: state.rules };
|
||
if ($("rulesPath").value) payload.path = $("rulesPath").value;
|
||
const data = await fetchJson("/api/batch/rules", { method: "POST", body: JSON.stringify(payload) });
|
||
$("batchStatus").textContent = `规则已自动保存到 ${data.path}`;
|
||
} catch (error) {
|
||
$("batchStatus").textContent = error.message;
|
||
}
|
||
}, 900);
|
||
}
|
||
|
||
async function testRules() {
|
||
const data = await fetchJson("/api/batch/rules/test", {
|
||
method: "POST",
|
||
body: JSON.stringify({ rules: state.rules, text: $("ruleTestText").value })
|
||
});
|
||
$("ruleTestResult").textContent = pretty(data);
|
||
addLocalLog(data.errors && data.errors.length ? "WARN" : "INFO", "frontend", "正则测试完成", { matches: data.match_count, errors: data.errors });
|
||
}
|
||
|
||
async function pickBatchFolder() {
|
||
const btn = $("pickBatchFolder");
|
||
btn.disabled = true;
|
||
const oldText = btn.textContent;
|
||
btn.textContent = "选择中...";
|
||
try {
|
||
const params = new URLSearchParams();
|
||
params.set("title", "选择 Word 根目录");
|
||
if ($("batchFolder").value.trim()) params.set("initialdir", $("batchFolder").value.trim());
|
||
const data = await fetchJson("/api/batch/pick-folder?" + params.toString());
|
||
if (!data.success) throw new Error(data.message || "选择目录失败");
|
||
if (data.folder) $("batchFolder").value = data.folder;
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = oldText;
|
||
}
|
||
}
|
||
|
||
async function runBatch() {
|
||
const folder = $("batchFolder").value.trim();
|
||
if (!folder) {
|
||
$("batchStatus").textContent = "请填写 Word 根目录路径";
|
||
return;
|
||
}
|
||
state.fastPollUntil = Date.now() + 8000;
|
||
$("runBatch").disabled = true;
|
||
$("batchStatus").textContent = "批量处理中";
|
||
try {
|
||
const data = await fetchJson("/api/batch/process", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
folder,
|
||
recursive: boolValue("batchRecursive"),
|
||
auto_sign: boolValue("batchAutoSign"),
|
||
rules: state.rules,
|
||
request_options: {
|
||
use_global_align: boolValue("useGlobalAlign"),
|
||
align_h: $("alignH").value,
|
||
align_v: $("alignV").value,
|
||
use_global_height: boolValue("useGlobalHeight"),
|
||
height: numberOrNull($("globalHeight").value),
|
||
use_global_noise: boolValue("useGlobalNoise"),
|
||
noise_level: numberOrNull($("globalNoise").value),
|
||
use_global_rotate: boolValue("useGlobalRotate"),
|
||
rotate_min: numberOrNull($("globalRotateMin").value),
|
||
rotate_max: numberOrNull($("globalRotateMax").value),
|
||
relax_layout: boolValue("relaxLayout")
|
||
}
|
||
})
|
||
});
|
||
$("batchStatus").textContent = `扫描 ${data.scanned},处理 ${data.processed},跳过 ${data.skipped},失败 ${data.failed}`;
|
||
renderBatchRows(data.files || []);
|
||
addLocalLog(data.success ? "INFO" : "WARN", "frontend", "批量处理完成", data);
|
||
} finally {
|
||
$("runBatch").disabled = false;
|
||
}
|
||
}
|
||
|
||
function renderBatchRows(files) {
|
||
$("batchRows").innerHTML = files.map((item) => `
|
||
<tr>
|
||
<td>${escapeHtml(item.path || "")}</td>
|
||
<td>${escapeHtml(item.status || "")}</td>
|
||
<td>${item.markers || 0}</td>
|
||
<td>${escapeHtml(item.output || item.output_path || "")}</td>
|
||
<td>${escapeHtml((item.warnings || []).join(";"))}</td>
|
||
</tr>
|
||
`).join("");
|
||
}
|
||
|
||
async function refreshMonitor() {
|
||
const params = new URLSearchParams();
|
||
if ($("monitorStatus").value) params.set("status", $("monitorStatus").value);
|
||
if ($("monitorSearch").value) params.set("q", $("monitorSearch").value);
|
||
const data = await fetchJson("/api/monitor/requests?" + params.toString());
|
||
$("monitorRows").innerHTML = (data.requests || []).map((item) => `
|
||
<tr data-monitor="${item.trace_id}">
|
||
<td>${escapeHtml(item.request_time || "")}</td>
|
||
<td>${escapeHtml(item.client_ip || "")}</td>
|
||
<td><button data-monitor-open="${item.trace_id}">${escapeHtml(item.trace_id || "")}</button></td>
|
||
<td class="${item.status === "error" ? "err" : item.status === "success" ? "ok" : "warn"}">${escapeHtml(item.status || "")}</td>
|
||
<td>${escapeHtml(String(item.response_status || ""))}</td>
|
||
<td>${item.elapsed_ms == null ? "" : item.elapsed_ms + " ms"}</td>
|
||
</tr>
|
||
`).join("");
|
||
if (state.selectedMonitor) {
|
||
const found = (data.requests || []).find((item) => item.trace_id === state.selectedMonitor);
|
||
if (found) $("monitorDetail").textContent = pretty(found);
|
||
}
|
||
}
|
||
|
||
async function refreshDb() {
|
||
const params = new URLSearchParams();
|
||
if ($("dbSearch").value) params.set("q", $("dbSearch").value);
|
||
const data = await fetchJson("/api/db/records?" + params.toString());
|
||
$("dbRows").innerHTML = (data.records || []).map((item) => `
|
||
<tr>
|
||
<td>${escapeHtml(item.user_id)}</td>
|
||
<td>${escapeHtml(item.user_name)}</td>
|
||
<td>${item.is_signature ? "true" : "false"}</td>
|
||
<td>${item.image_size}</td>
|
||
<td><button data-db-select="${escapeHtml(item.user_id)}">选择</button></td>
|
||
</tr>
|
||
`).join("");
|
||
}
|
||
|
||
function nameFromFile(fileName) {
|
||
const base = String(fileName || "").split(/[\\/]/).pop() || "";
|
||
return base.replace(/\.[^.]+$/, "");
|
||
}
|
||
|
||
function renderDbImportRows() {
|
||
$("dbImportRows").innerHTML = state.dbImportItems.map((item) => `
|
||
<tr>
|
||
<td>${escapeHtml(item.image_filename)}</td>
|
||
<td>${escapeHtml(item.user_name || nameFromFile(item.image_filename))}</td>
|
||
<td>${item.size || 0}</td>
|
||
<td class="${item.error ? "err" : "ok"}">${escapeHtml(item.error || "待导入")}</td>
|
||
</tr>
|
||
`).join("");
|
||
$("dbImportStatus").textContent = state.dbImportItems.length ? `队列 ${state.dbImportItems.length} 张图片` : "未选择图片";
|
||
}
|
||
|
||
async function readDbImportFiles(files) {
|
||
state.dbImportItems = [];
|
||
for (const file of Array.from(files || [])) {
|
||
state.dbImportItems.push({
|
||
image_filename: file.name,
|
||
user_name: nameFromFile(file.name),
|
||
image_base64: await fileToBase64(file),
|
||
size: file.size
|
||
});
|
||
}
|
||
renderDbImportRows();
|
||
addLocalLog("INFO", "frontend", "已读取批量导入图片", { count: state.dbImportItems.length });
|
||
}
|
||
|
||
async function runDbImport() {
|
||
if (!state.dbImportItems.length) {
|
||
$("dbImportStatus").textContent = "请先选择图片";
|
||
return;
|
||
}
|
||
const payload = {
|
||
auto_id_prefix: $("dbImportPrefix").value.trim() || "U",
|
||
auto_id_start: Number($("dbImportStart").value || 1),
|
||
auto_id_padding: Number($("dbImportPadding").value || 4),
|
||
default_is_signature: boolValue("dbImportIsSignature"),
|
||
items: state.dbImportItems.map((item) => ({
|
||
image_filename: item.image_filename,
|
||
image_base64: item.image_base64,
|
||
is_signature: boolValue("dbImportIsSignature")
|
||
}))
|
||
};
|
||
const data = await fetchJson("/api/db/records/import", {
|
||
method: "POST",
|
||
body: JSON.stringify(payload)
|
||
});
|
||
$("dbImportResult").textContent = pretty(data);
|
||
const failedIndexes = new Set((data.failures || []).map((item) => item.index));
|
||
const failureByIndex = Object.fromEntries((data.failures || []).map((item) => [item.index, item.message || item.code]));
|
||
state.dbImportItems = state.dbImportItems
|
||
.map((item, index) => ({ ...item, error: failureByIndex[index] || "" }))
|
||
.filter((item, index) => failedIndexes.has(index));
|
||
renderDbImportRows();
|
||
$("dbImportStatus").textContent = `导入成功 ${data.imported || 0},失败 ${data.failed || 0}`;
|
||
addLocalLog(data.failed ? "WARN" : "INFO", "frontend", "批量导入图片完成", data);
|
||
await refreshDb();
|
||
}
|
||
|
||
function clearDbImport() {
|
||
state.dbImportItems = [];
|
||
$("dbImportFiles").value = "";
|
||
$("dbImportResult").textContent = "";
|
||
renderDbImportRows();
|
||
}
|
||
|
||
async function saveDbRecord() {
|
||
const payload = {
|
||
user_id: $("dbUserId").value.trim(),
|
||
user_name: $("dbUserName").value.trim(),
|
||
is_signature: boolValue("dbIsSignature")
|
||
};
|
||
if (state.dbImageBase64) payload.image_base64 = state.dbImageBase64;
|
||
const editing = state.selectedUserId && state.selectedUserId === payload.user_id;
|
||
const data = await fetchJson(editing ? `/api/db/records/${encodeURIComponent(payload.user_id)}` : "/api/db/records", {
|
||
method: editing ? "PUT" : "POST",
|
||
body: JSON.stringify(payload)
|
||
});
|
||
state.selectedUserId = data.record.user_id;
|
||
state.dbImageBase64 = "";
|
||
$("dbImage").value = "";
|
||
$("dbStatus").textContent = `已保存 ${data.record.user_id}`;
|
||
await refreshDb();
|
||
showDbPreview(data.record.user_id);
|
||
}
|
||
|
||
async function deleteDbRecord() {
|
||
const userId = $("dbUserId").value.trim();
|
||
if (!userId) return;
|
||
await fetchJson(`/api/db/records/${encodeURIComponent(userId)}`, { method: "DELETE" });
|
||
clearDbForm();
|
||
await refreshDb();
|
||
$("dbStatus").textContent = `已删除 ${userId}`;
|
||
}
|
||
|
||
function showDbPreview(userId) {
|
||
const cache = Date.now();
|
||
$("dbOriginalPreview").src = `/api/db/records/${encodeURIComponent(userId)}/image?t=${cache}`;
|
||
$("dbProcessedPreview").src = `/api/db/records/${encodeURIComponent(userId)}/image?processed=true&t=${cache}`;
|
||
}
|
||
|
||
function clearDbForm() {
|
||
state.selectedUserId = "";
|
||
state.dbImageBase64 = "";
|
||
$("dbUserId").value = "";
|
||
$("dbUserName").value = "";
|
||
$("dbIsSignature").value = "false";
|
||
$("dbImage").value = "";
|
||
$("dbOriginalPreview").removeAttribute("src");
|
||
$("dbProcessedPreview").removeAttribute("src");
|
||
}
|
||
|
||
async function fetchJson(url, options = {}) {
|
||
const response = await fetch(url, {
|
||
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
|
||
...options
|
||
});
|
||
const data = await response.json().catch(() => ({ success: false, message: "响应不是 JSON" }));
|
||
if (!response.ok || data.success === false) {
|
||
addLocalLog("ERROR", "frontend", data.message || "请求失败", data);
|
||
throw new Error(data.message || "请求失败");
|
||
}
|
||
return data;
|
||
}
|
||
|
||
async function pollLogs() {
|
||
const params = new URLSearchParams({ after_id: String(state.lastLogId), limit: "200" });
|
||
if ($("logLevel").value) params.set("level", $("logLevel").value);
|
||
if ($("logSource").value) params.set("source", $("logSource").value);
|
||
if ($("logSearch").value) params.set("q", $("logSearch").value);
|
||
try {
|
||
const data = await fetchJson("/api/logs?" + params.toString());
|
||
state.lastLogId = data.last_id || state.lastLogId;
|
||
for (const item of data.logs || []) {
|
||
const row = document.createElement("div");
|
||
row.className = "log-line " + item.level;
|
||
row.innerHTML = `<span class="meta">[${escapeHtml(item.time)}] [${escapeHtml(item.level)}] [${escapeHtml(item.source)}]</span> ${escapeHtml(item.message)} ${item.trace_id ? `<span class="pill">${escapeHtml(item.trace_id)}</span>` : ""}`;
|
||
$("logs").appendChild(row);
|
||
}
|
||
if ((data.logs || []).length) $("logs").scrollTop = $("logs").scrollHeight;
|
||
} catch (_) {
|
||
// log polling must not interrupt operator work
|
||
} finally {
|
||
setTimeout(pollLogs, Date.now() < state.fastPollUntil ? 500 : 2000);
|
||
}
|
||
}
|
||
|
||
async function loadHealth() {
|
||
try {
|
||
const data = await fetchJson("/api/health");
|
||
$("healthText").textContent = data.mysql_enabled ? "MySQL 已启用" : "MySQL 未启用";
|
||
$("limitText").textContent = `docx ${Math.round(data.docx_max_bytes / 1024)}KB / image ${Math.round(data.image_max_bytes / 1024)}KB`;
|
||
} catch (_) {
|
||
$("healthText").textContent = "服务不可用";
|
||
}
|
||
}
|
||
|
||
function switchTab(tab) {
|
||
document.querySelectorAll("nav button").forEach((button) => button.classList.toggle("active", button.dataset.tab === tab));
|
||
document.querySelectorAll(".page").forEach((page) => page.classList.remove("active"));
|
||
$(`page-${tab}`).classList.add("active");
|
||
location.hash = tab;
|
||
if (tab === "monitor") refreshMonitor();
|
||
if (tab === "db") refreshDb().catch((error) => $("dbStatus").textContent = error.message);
|
||
}
|
||
|
||
function updateDynamicInput(event) {
|
||
const stampRow = event.target.closest("[data-stamp]");
|
||
if (stampRow && event.target.dataset.stampField) {
|
||
const stamp = state.stamps[Number(stampRow.dataset.stamp)];
|
||
const field = event.target.dataset.stampField;
|
||
stamp[field] = field === "is_signature" ? event.target.value === "true" : event.target.value;
|
||
buildPayload();
|
||
}
|
||
const ruleRow = event.target.closest("[data-rule]");
|
||
if (ruleRow && event.target.dataset.ruleField) {
|
||
const rule = state.rules[Number(ruleRow.dataset.rule)];
|
||
const field = event.target.dataset.ruleField;
|
||
if (field === "capture_group") rule[field] = Number(event.target.value);
|
||
else if (field === "enabled") rule[field] = event.target.value === "true";
|
||
else rule[field] = event.target.value;
|
||
state.activeRule = Number(ruleRow.dataset.rule);
|
||
scheduleRuleAutosave();
|
||
}
|
||
}
|
||
|
||
document.addEventListener("input", updateDynamicInput);
|
||
|
||
document.addEventListener("change", async (event) => {
|
||
updateDynamicInput(event);
|
||
if (event.target.id === "docxFile" && event.target.files[0]) {
|
||
const file = event.target.files[0];
|
||
$("docxName").value = file.name;
|
||
state.docxBase64 = await fileToBase64(file);
|
||
buildPayload();
|
||
}
|
||
if (event.target.dataset.stampImage) {
|
||
const index = Number(event.target.dataset.stampImage);
|
||
const file = event.target.files[0];
|
||
if (file) {
|
||
state.stamps[index].image_filename = file.name;
|
||
state.stamps[index].image_base64 = await fileToBase64(file);
|
||
renderStamps();
|
||
buildPayload();
|
||
}
|
||
}
|
||
if (event.target.id === "dbImage" && event.target.files[0]) {
|
||
state.dbImageBase64 = await fileToBase64(event.target.files[0]);
|
||
}
|
||
});
|
||
|
||
document.addEventListener("click", async (event) => {
|
||
const target = event.target;
|
||
if (target.matches("nav button")) switchTab(target.dataset.tab);
|
||
if (target.id === "addStamp") {
|
||
state.stamps.push({ marker: "USER_姓名", is_signature: false, image_filename: "", image_base64: "", image_id: "", image_name: "", align_h: "left", align_v: "center", height: "", noise_level: "", rotate_min: "", rotate_max: "" });
|
||
renderStamps();
|
||
buildPayload();
|
||
}
|
||
if (target.dataset.removeStamp) {
|
||
state.stamps.splice(Number(target.dataset.removeStamp), 1);
|
||
renderStamps();
|
||
buildPayload();
|
||
}
|
||
if (target.id === "buildJson") buildPayload();
|
||
if (target.id === "sendApi") sendApi();
|
||
if (target.id === "clearApi") { $("responsePreview").textContent = ""; $("apiResult").textContent = ""; }
|
||
if (target.id === "refreshPreview") refreshPreview();
|
||
if (target.id === "scanDocMarkers") scanDocMarkers();
|
||
if (target.id === "loadRules") loadRules();
|
||
if (target.id === "saveRules") saveRules().catch((error) => $("batchStatus").textContent = error.message);
|
||
if (target.id === "addRule") {
|
||
state.rules.push({ name: "新规则", pattern: "([\\u4e00-\\u9fa5A-Za-z0-9._-]{1,20})", capture_group: 1, cell_mode: "single", cell_match: "partial", enabled: true });
|
||
state.activeRule = state.rules.length - 1;
|
||
renderRules();
|
||
scheduleRuleAutosave();
|
||
}
|
||
if (target.dataset.removeRule) {
|
||
state.rules.splice(Number(target.dataset.removeRule), 1);
|
||
renderRules();
|
||
scheduleRuleAutosave();
|
||
}
|
||
const snippetButton = target.closest("[data-snippet]");
|
||
if (snippetButton) {
|
||
const item = state.snippets.find((row) => row.id === snippetButton.dataset.snippet);
|
||
if (item && state.rules[state.activeRule]) {
|
||
state.rules[state.activeRule].pattern = (state.rules[state.activeRule].pattern || "") + item.pattern;
|
||
renderRules();
|
||
scheduleRuleAutosave();
|
||
await testRules();
|
||
}
|
||
}
|
||
if (target.id === "testRules") testRules();
|
||
if (target.id === "runBatch") runBatch().catch((error) => $("batchStatus").textContent = error.message);
|
||
if (target.id === "pickBatchFolder") pickBatchFolder().catch((error) => $("batchStatus").textContent = error.message);
|
||
if (target.id === "refreshMonitor") refreshMonitor();
|
||
if (target.dataset.monitorOpen) {
|
||
state.selectedMonitor = target.dataset.monitorOpen;
|
||
await refreshMonitor();
|
||
}
|
||
if (target.id === "dbRefresh") refreshDb().catch((error) => $("dbStatus").textContent = error.message);
|
||
if (target.id === "dbInit") {
|
||
try {
|
||
const data = await fetchJson("/api/db/init", { method: "POST", body: "{}" });
|
||
$("dbStatus").textContent = data.message;
|
||
} catch (error) {
|
||
$("dbStatus").textContent = error.message;
|
||
}
|
||
}
|
||
if (target.dataset.dbSelect) {
|
||
const id = target.dataset.dbSelect;
|
||
state.selectedUserId = id;
|
||
const row = target.closest("tr").children;
|
||
$("dbUserId").value = id;
|
||
$("dbUserName").value = row[1].textContent;
|
||
$("dbIsSignature").value = row[2].textContent.trim() === "true" ? "true" : "false";
|
||
state.dbImageBase64 = "";
|
||
$("dbImage").value = "";
|
||
showDbPreview(id);
|
||
}
|
||
if (target.id === "dbSave") saveDbRecord().catch((error) => $("dbStatus").textContent = error.message);
|
||
if (target.id === "dbDelete") deleteDbRecord().catch((error) => $("dbStatus").textContent = error.message);
|
||
if (target.id === "dbNew") clearDbForm();
|
||
if (target.id === "dbRunImport") runDbImport().catch((error) => $("dbImportStatus").textContent = error.message);
|
||
if (target.id === "dbClearImport") clearDbImport();
|
||
if (target.id === "clearLogs") { $("logs").innerHTML = ""; state.lastLogId = 0; }
|
||
});
|
||
|
||
["matchMode", "autoScanAll", "useGlobalAlign", "alignH", "alignV", "useGlobalHeight", "globalHeight", "useGlobalNoise", "globalNoise", "useGlobalRotate", "globalRotateMin", "globalRotateMax", "relaxLayout"].forEach((id) => {
|
||
$(id).addEventListener("change", buildPayload);
|
||
$(id).addEventListener("input", buildPayload);
|
||
});
|
||
["matchMode", "autoScanAll"].forEach((id) => {
|
||
$(id).addEventListener("change", updatePreviewStampDropdown);
|
||
});
|
||
|
||
$("monitorSearch").addEventListener("input", () => refreshMonitor());
|
||
$("monitorStatus").addEventListener("change", () => refreshMonitor());
|
||
$("dbSearch").addEventListener("input", () => refreshDb().catch(() => {}));
|
||
$("dbImportFiles").addEventListener("change", (event) => readDbImportFiles(event.target.files).catch((error) => $("dbImportStatus").textContent = error.message));
|
||
$("previewStampIndex").addEventListener("change", () => refreshPreview());
|
||
|
||
renderStamps();
|
||
updatePreviewStampDropdown();
|
||
renderDbImportRows();
|
||
buildPayload();
|
||
loadHealth();
|
||
loadRules();
|
||
loadSnippets();
|
||
refreshMonitor();
|
||
pollLogs();
|
||
if (location.hash) switchTab(location.hash.slice(1));
|
||
|
||
// ============= Tooltip 智能定位 =============
|
||
// 图标靠左 → 向右展开(默认);图标靠右 → 向左展开(flip-left);
|
||
// 任意一侧空间不够 → 缩窄宽度(maxWidth),文字自动换行。
|
||
function adjustTipPosition(tip) {
|
||
const body = tip.querySelector('.htip-body');
|
||
if (!body) return;
|
||
body.classList.remove('flip-left');
|
||
body.style.maxWidth = '';
|
||
|
||
const tipRect = tip.getBoundingClientRect();
|
||
const viewportWidth = document.documentElement.clientWidth;
|
||
const margin = 12;
|
||
const preferred = 280;
|
||
|
||
const spaceRight = viewportWidth - tipRect.left - margin;
|
||
const spaceLeft = tipRect.right - margin;
|
||
|
||
if (spaceRight >= preferred) {
|
||
return; // 默认向右,够用
|
||
}
|
||
if (spaceLeft >= preferred) {
|
||
body.classList.add('flip-left'); // 翻转向左
|
||
return;
|
||
}
|
||
// 两侧都不够:挑宽的一侧并缩窄
|
||
if (spaceRight >= spaceLeft) {
|
||
body.style.maxWidth = Math.max(120, spaceRight) + 'px';
|
||
} else {
|
||
body.classList.add('flip-left');
|
||
body.style.maxWidth = Math.max(120, spaceLeft) + 'px';
|
||
}
|
||
}
|
||
document.addEventListener('mouseover', (e) => {
|
||
const tip = e.target.closest && e.target.closest('.htip');
|
||
if (tip) adjustTipPosition(tip);
|
||
});
|
||
document.addEventListener('focusin', (e) => {
|
||
const tip = e.target.closest && e.target.closest('.htip');
|
||
if (tip) adjustTipPosition(tip);
|
||
});
|
||
window.addEventListener('resize', () => {
|
||
document.querySelectorAll('.htip:hover, .htip:focus').forEach(adjustTipPosition);
|
||
});
|
||
</script>
|
||
</body>
|
||
</html> |