Files
SignatureSystem/templates/ui_prototype.html

1193 lines
50 KiB
HTML
Raw Normal View History

2026-07-20 13:16:17 +08:00
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SEAL API测试</title>
<style>
:root {
--bg: #f5f7fb;
--panel: #ffffff;
--line: #d7dde8;
--text: #1f2937;
--muted: #667085;
--primary: #1769aa;
--primary-soft: #e8f2fb;
--ok: #16794c;
--warn: #a15c00;
--err: #b42318;
--shadow: 0 1px 3px rgba(16, 24, 40, 0.12);
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: "Microsoft YaHei", "Segoe UI", Arial, sans-serif;
font-size: 14px;
letter-spacing: 0;
}
header {
height: 58px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
border-bottom: 1px solid var(--line);
background: var(--panel);
position: sticky;
top: 0;
z-index: 10;
}
.brand { font-size: 18px; font-weight: 700; }
.status { display: flex; gap: 12px; color: var(--muted); align-items: center; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--ok); display: inline-block; }
nav {
display: flex;
gap: 8px;
padding: 12px 20px 0;
background: var(--bg);
}
nav button {
border: 1px solid var(--line);
background: var(--panel);
color: var(--text);
padding: 9px 14px;
border-radius: 6px;
cursor: pointer;
}
nav button.active {
background: var(--primary);
border-color: var(--primary);
color: #fff;
}
main {
display: grid;
grid-template-columns: minmax(680px, 1fr) 420px;
gap: 14px;
padding: 14px 20px 20px;
}
.workspace { min-width: 0; }
.page { display: none; }
.page.active { display: block; }
.section {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
margin-bottom: 12px;
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>