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=所有盖章项缩放到统一高度(下方全局 height) ; false=每个盖章项用自己配置的高度(可各不相同)。< br > 留空 height = 自动根据插入位置字号/行高推算。< / span > < / span > < / label >
< select id = "useGlobalHeight" > < option value = "true" > true< / option > < option value = "false" > false< / option > < / select >
< / div >
< div >
< label > 全局 height(cm)< span class = "htip" tabindex = "0" > ?< span class = "htip-body" > 签名图等比例缩放后的最终高度,单位厘米。范围 0.1~10。< br > < b > 留空 = 自动尺寸(推荐)< / b > : 根据插入位置的字号( 2 倍) 或段落行高( 95%)自动推算。< br > 填了具体值 = 强制使用该高度,但若超过段落行高/单元格宽度仍会等比缩小。< / span > < / span > < / label >
< input id = "globalHeight" type = "number" min = "0.1" max = "10" step = "0.1" value = "" / >
< / div >
< div >
< label > use_global_noise< span class = "htip" tabindex = "0" > ?< span class = "htip-body" > true=所有盖章项共用下方全局噪声参数; false=各自配置(可各不相同)。< / span > < / span > < / label >
< select id = "useGlobalNoise" > < option value = "true" > true< / option > < option value = "false" > false< / option > < / select >
< / div >
< div >
< label > 全局 noise_level< span class = "htip" tabindex = "0" > ?< span class = "htip-body" > 0~10, 在笔画边缘撒随机噪点模拟印章/签字不均匀感( 防伪) 。0=关闭。数值越大噪点越密。仅在笔画边缘 ±2 像素范围内、且只撒到透明像素上,不会糊掉笔画。< / span > < / span > < / label >
< input id = "globalNoise" type = "number" min = "0" max = "10" step = "1" value = "0" / >
< / div >
< div >
< label > use_global_rotate< span class = "htip" tabindex = "0" > ?< span class = "htip-body" > true=所有盖章项共用下方全局旋转参数; false=各自配置(可各不相同)。< / span > < / span > < / label >
< select id = "useGlobalRotate" > < option value = "true" > true< / option > < option value = "false" > false< / option > < / select >
< / div >
< div >
< label > 全局 rotate_min< span class = "htip" tabindex = "0" > ?< span class = "htip-body" > 随机旋转角度的最小值(度,-180~180) 。rotate_min 和 rotate_max 都是 0 则不旋转。如 -3 表示可能左旋到 -3°。旋转后由"紧致裁剪"切掉新增的透明边。< / span > < / span > < / label >
< input id = "globalRotateMin" type = "number" min = "-180" max = "180" step = "1" value = "0" / >
< / div >
< div >
< label > 全局 rotate_max< span class = "htip" tabindex = "0" > ?< span class = "htip-body" > 随机旋转角度的最大值(度,-180~180) 。如 3 表示可能右旋到 3°。两个都是 0 则不旋转。< / span > < / span > < / label >
< input id = "globalRotateMax" type = "number" min = "-180" max = "180" step = "1" value = "0" / >
< / div >
< div >
< label > 放宽段落布局< span class = "htip" tabindex = "0" > ?< span class = "htip-body" > true( 默认) : 当签名位置段落有固定行高( lineRule=exact) 或表格行有固定行高( hRule=exact) 小于签名高度时, 自动把约束改为 atLeast 并上调数值,让签名按设定尺寸完整显示,避免被 Word 硬裁剪。仅作用于段落直接格式, 不修改样式。false=严格保留原文档布局,签名被缩放到约束内(可能很小)。< / span > < / span > < / label >
< select id = "relaxLayout" > < option value = "true" > true< / option > < option value = "false" > false< / option > < / select >
< / div >
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) => ({ "& ": "& ", "< ": "< ", ">": "> ", '"': "" ", "'": "' " }[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 >