Files
SignatureSystem/templates/index.html
2026-07-20 13:16:17 +08:00

1552 lines
70 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>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=所有盖章项缩放到统一高度(下方全局 heightfalse=每个盖章项用自己配置的高度(可各不相同)。<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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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>