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

1193 lines
50 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>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;
overflow: hidden;
}
.section h2 {
margin: 0;
padding: 11px 14px;
font-size: 15px;
border-bottom: 1px solid var(--line);
background: #fbfcfe;
}
.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; }
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; }
.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</label>
<input id="docxName" />
</div>
<div>
<label>match_mode</label>
<select id="matchMode">
<option value="upload">upload 传图</option>
<option value="id">id 传人员ID</option>
<option value="name">name 传名称</option>
<option value="auto">auto 自动匹配</option>
</select>
</div>
<div>
<label>auto 空 stamps 扫描全部</label>
<select id="autoScanAll">
<option value="false">否,使用下方 marker</option>
<option value="true">是,扫描全部 USER_ 标记</option>
</select>
</div>
</div>
<div class="grid" style="margin-top:10px">
<div>
<label>use_global_align</label>
<select id="useGlobalAlign"><option value="true">true</option><option value="false">false</option></select>
</div>
<div>
<label>全局 align_h</label>
<select id="alignH"><option>left</option><option>center</option><option>right</option></select>
</div>
<div>
<label>全局 align_v</label>
<select id="alignV"><option>center</option><option>top</option><option>bottom</option></select>
</div>
<div>
<label>use_global_height</label>
<select id="useGlobalHeight"><option value="false">false</option><option value="true">true</option></select>
</div>
<div>
<label>全局 height(cm)</label>
<input id="globalHeight" type="number" min="0.1" max="10" step="0.1" value="2.5" />
</div>
<div>
<label>use_global_noise</label>
<select id="useGlobalNoise"><option value="true">true</option><option value="false">false</option></select>
</div>
<div>
<label>全局 noise_level</label>
<input id="globalNoise" type="number" min="0" max="10" step="1" value="0" />
</div>
<div>
<label>use_global_rotate</label>
<select id="useGlobalRotate"><option value="true">true</option><option value="false">false</option></select>
</div>
<div>
<label>全局 rotate_min</label>
<input id="globalRotateMin" type="number" min="-180" max="180" step="1" value="0" />
</div>
<div>
<label>全局 rotate_max</label>
<input id="globalRotateMax" type="number" min="-180" max="180" step="1" value="0" />
</div>
</div>
<div class="actions">
<button id="addStamp">新增盖章项</button>
<button id="buildJson" class="primary">构建 JSON</button>
<button id="sendApi" class="primary">发送 API</button>
<button id="clearApi">清空结果</button>
<span id="apiResult" class="hint"></span>
</div>
</div>
</div>
<div class="section">
<h2>盖章项</h2>
<div class="body" id="stampRows"></div>
</div>
<div class="section">
<h2>完整请求与响应</h2>
<div class="body grid two">
<div>
<label>请求 JSON</label>
<pre id="jsonPreview" class="json-box"></pre>
</div>
<div>
<label>响应</label>
<pre id="responsePreview" class="json-box"></pre>
</div>
</div>
</div>
<div class="section">
<h2>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>
<input id="batchFolder" />
</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</label><input data-stamp-field="marker" value="${escapeHtml(stamp.marker || "")}" /></div>
<div><label>is_signature</label><select data-stamp-field="is_signature"><option value="true" ${stamp.is_signature ? "selected" : ""}>true</option><option value="false" ${!stamp.is_signature ? "selected" : ""}>false</option></select></div>
<div><label>image_id</label><input data-stamp-field="image_id" value="${escapeHtml(stamp.image_id || "")}" /></div>
<div><label>image_name</label><input data-stamp-field="image_name" value="${escapeHtml(stamp.image_name || "")}" /></div>
<div><label>上传图片</label><input type="file" accept="image/png,image/jpeg" data-stamp-image="${index}" /></div>
<div><label>align_h</label><select data-stamp-field="align_h"><option ${stamp.align_h === "left" ? "selected" : ""}>left</option><option ${stamp.align_h === "center" ? "selected" : ""}>center</option><option ${stamp.align_h === "right" ? "selected" : ""}>right</option></select></div>
<div><label>align_v</label><select data-stamp-field="align_v"><option ${stamp.align_v === "center" ? "selected" : ""}>center</option><option ${stamp.align_v === "top" ? "selected" : ""}>top</option><option ${stamp.align_v === "bottom" ? "selected" : ""}>bottom</option></select></div>
<div><label>height(cm)</label><input type="number" step="0.1" data-stamp-field="height" value="${escapeHtml(stamp.height ?? "")}" /></div>
<div><label>noise_level</label><input type="number" step="1" data-stamp-field="noise_level" value="${escapeHtml(stamp.noise_level ?? "")}" /></div>
<div><label>rotate_min</label><input type="number" step="1" data-stamp-field="rotate_min" value="${escapeHtml(stamp.rotate_min ?? "")}" /></div>
<div><label>rotate_max</label><input type="number" step="1" data-stamp-field="rotate_max" value="${escapeHtml(stamp.rotate_max ?? "")}" /></div>
<div><label>图片文件名</label><input data-stamp-field="image_filename" value="${escapeHtml(stamp.image_filename || "")}" /></div>
</div>
</div>
`).join("");
}
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),
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] : "";
}
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 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)
}
})
});
$("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 === "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 === "refreshMonitor") refreshMonitor();
if (target.dataset.monitorOpen) {
state.selectedMonitor = target.dataset.monitorOpen;
await refreshMonitor();
}
if (target.id === "dbRefresh") refreshDb().catch((error) => $("dbStatus").textContent = error.message);
if (target.id === "dbInit") {
try {
const data = await fetchJson("/api/db/init", { method: "POST", body: "{}" });
$("dbStatus").textContent = data.message;
} catch (error) {
$("dbStatus").textContent = error.message;
}
}
if (target.dataset.dbSelect) {
const id = target.dataset.dbSelect;
state.selectedUserId = id;
const row = target.closest("tr").children;
$("dbUserId").value = id;
$("dbUserName").value = row[1].textContent;
$("dbIsSignature").value = row[2].textContent.trim() === "true" ? "true" : "false";
state.dbImageBase64 = "";
$("dbImage").value = "";
showDbPreview(id);
}
if (target.id === "dbSave") saveDbRecord().catch((error) => $("dbStatus").textContent = error.message);
if (target.id === "dbDelete") deleteDbRecord().catch((error) => $("dbStatus").textContent = error.message);
if (target.id === "dbNew") clearDbForm();
if (target.id === "dbRunImport") runDbImport().catch((error) => $("dbImportStatus").textContent = error.message);
if (target.id === "dbClearImport") clearDbImport();
if (target.id === "clearLogs") { $("logs").innerHTML = ""; state.lastLogId = 0; }
});
["matchMode", "autoScanAll", "useGlobalAlign", "alignH", "alignV", "useGlobalHeight", "globalHeight", "useGlobalNoise", "globalNoise", "useGlobalRotate", "globalRotateMin", "globalRotateMax"].forEach((id) => {
$(id).addEventListener("change", buildPayload);
$(id).addEventListener("input", buildPayload);
});
$("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));
renderStamps();
renderDbImportRows();
buildPayload();
loadHealth();
loadRules();
loadSnippets();
refreshMonitor();
pollLogs();
if (location.hash) switchTab(location.hash.slice(1));
</script>
</body>
</html>