Files
SignatureSystem/templates/index.html

1564 lines
71 KiB
HTML
Raw Normal View History

2026-07-20 13:16:17 +08:00
<!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>
2026-07-20 16:43:08 +08:00
<div>
<label>签名图模式<span class="htip" tabindex="0">?<span class="htip-body">控制 is_signature=True 的签名图如何插入文档:嵌入式(默认):签名占文档流位置,超出区域时撑大当前行(不影响邻行);浮动覆盖:签名浮在文字上,超出区域不撑大当前行(可能覆盖邻行),适合签名抠图后较大、不想撑破文档布局的场景。</span></span></label>
<select id="signatureMode"><option value="inline">嵌入式(默认)</option><option value="anchor">浮动覆盖</option></select>
</div>
<div>
<label>盖章图模式<span class="htip" tabindex="0">?<span class="htip-body">控制 is_signature=False 的盖章图如何插入文档:浮动覆盖(默认):公章盖住原文字 + 上下相邻行的文字,模拟真人盖章效果;嵌入式:公章占文档流位置(旧行为,不盖文字)。</span></span></label>
<select id="stampMode"><option value="anchor">浮动覆盖(默认)</option><option value="inline">嵌入式</option></select>
</div>
2026-07-20 13:16:17 +08:00
</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"),
2026-07-20 16:43:08 +08:00
signature_mode: $("signatureMode").value,
stamp_mode: $("stampMode").value,
2026-07-20 13:16:17 +08:00
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),
2026-07-20 16:43:08 +08:00
relax_layout: boolValue("relaxLayout"),
signature_mode: $("signatureMode").value,
stamp_mode: $("stampMode").value
2026-07-20 13:16:17 +08:00
}
})
});
$("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; }
});
2026-07-20 16:43:08 +08:00
["matchMode", "autoScanAll", "useGlobalAlign", "alignH", "alignV", "useGlobalHeight", "globalHeight", "useGlobalNoise", "globalNoise", "useGlobalRotate", "globalRotateMin", "globalRotateMax", "relaxLayout", "signatureMode", "stampMode"].forEach((id) => {
2026-07-20 13:16:17 +08:00
$(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>