message / plugins /ocr /frontend /index.html
hunian
refactor(plugins): 插件短名并统一 MCP tool 为 {plugin}-{tool}
cc826a1
Raw
History Blame Contribute Delete
13 kB
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OCR 识别</title>
<link rel="stylesheet" href="/static/plugin-ui.css">
<style>
.preview-wrapper { position: relative; display: inline-block; max-width: 100%; }
.preview-wrapper img { max-width: 100%; border-radius: 4px; border: 1px solid var(--border); display: block; }
.preview-wrapper canvas {
position: absolute; left: 0; top: 0;
width: 100%; height: 100%;
pointer-events: none;
}
.dropzone {
padding: 16px; text-align: center;
border: 2px dashed var(--border); border-radius: 6px;
color: var(--text-muted); cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.dropzone.dragover { background: var(--bg-muted); border-color: var(--primary); }
.tabs { display: flex; gap: 4px; margin-bottom: 8px; }
.tab { padding: 6px 12px; border-radius: 4px 4px 0 0; cursor: pointer; background: var(--bg-muted); color: var(--text-muted); border: 1px solid var(--border); border-bottom: none; }
.tab.active { background: var(--bg); color: var(--text); font-weight: 600; }
.tab-content { display: none; }
.tab-content.active { display: block; }
.result-actions { display: flex; gap: 8px; margin-bottom: 8px; }
.spinner {
display: inline-block; width: 14px; height: 14px;
border: 2px solid var(--border); border-top-color: var(--primary);
border-radius: 50%; animation: spin 0.8s linear infinite;
vertical-align: middle; margin-right: 6px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.result-list { list-style: none; padding: 0; margin: 0; max-height: 240px; overflow: auto; }
.result-list li {
padding: 6px 10px; border-bottom: 1px solid var(--border);
cursor: pointer; display: flex; justify-content: space-between; gap: 8px;
}
.result-list li:hover { background: var(--bg-muted); }
.result-list .score { color: var(--text-muted); font-size: 0.85em; flex-shrink: 0; }
.panel-collapsed > .plugin-panel-body { display: none; }
.panel-toggle { cursor: pointer; user-select: none; }
</style>
</head>
<body>
<main class="plugin-shell">
<header class="plugin-header">
<div>
<h1 class="plugin-title">OCR 识别</h1>
<p class="plugin-subtitle">上传图片并识别其中的文字。基于 PP-OCRv6 medium 模型。</p>
</div>
<span id="statusPill" class="plugin-status">检查中</span>
</header>
<div id="message" class="plugin-message"></div>
<!-- 模型信息(默认折叠,避免首屏被占) -->
<div id="modelInfo" class="plugin-panel panel-collapsed">
<h3 class="plugin-panel-title panel-toggle" onclick="togglePanel('modelInfo')">模型信息 ▸</h3>
<div class="plugin-panel-body plugin-muted" id="modelInfoContent">点击展开</div>
</div>
<section class="plugin-grid">
<div class="plugin-panel">
<h2 class="plugin-panel-title">图片</h2>
<div class="plugin-stack">
<div id="dropzone" class="dropzone">将图片拖到此处,或点击选择文件</div>
<input id="file" type="file" accept="image/*" hidden>
<div id="preview" class="preview-wrapper"></div>
<button id="ocrBtn" class="plugin-button" onclick="runOCR()">开始识别</button>
<div id="ocrProgress" class="plugin-muted" style="display:none;">
<span class="spinner"></span><span id="ocrProgressText">正在识别...</span>
</div>
</div>
</div>
<div id="logPanel"></div>
</section>
<!-- 结果区 -->
<section id="resultSection" style="display:none;">
<div class="plugin-panel">
<h2 class="plugin-panel-title">识别结果</h2>
<div class="result-actions">
<button class="plugin-button plugin-button-secondary" onclick="copyResult()">复制文本</button>
<button class="plugin-button plugin-button-secondary" onclick="downloadResult()">下载 .txt</button>
</div>
<div class="tabs">
<div class="tab active" data-tab="text" onclick="switchTab('text')">文本</div>
<div class="tab" data-tab="lines" onclick="switchTab('lines')">分行</div>
<div class="tab" data-tab="raw" onclick="switchTab('raw')">原始结果</div>
</div>
<div id="tab-text" class="tab-content active">
<pre id="outputText" class="plugin-output"></pre>
</div>
<div id="tab-lines" class="tab-content">
<ul id="outputLines" class="result-list"></ul>
</div>
<div id="tab-raw" class="tab-content">
<pre id="outputRaw" class="plugin-output"></pre>
</div>
</div>
</section>
</main>
<script src="/static/plugin-ui.js"></script>
<script>
const API = "/plugins/ocr/api";
const POLL_PLUGIN = "ocr";
const IMG_ORIG = { w: 0, h: 0 }; // 原图尺寸,用于 canvas 坐标缩放
let currentLogPanel = null;
let lastResult = null;
// ---- 面板折叠 ----
function togglePanel(id) {
document.getElementById(id).classList.toggle("panel-collapsed");
}
// ---- 状态与模型信息 ----
function renderStatus(data) {
const pill = document.getElementById("statusPill");
if (!data.enabled) {
pill.textContent = "插件未启用";
pill.className = "plugin-status warn";
return;
}
if (!data.engine_ready) {
pill.textContent = "引擎未就绪";
pill.className = "plugin-status warn";
return;
}
pill.textContent = `就绪 · ${data.model_id || ""}`;
pill.className = "plugin-status ok";
}
async function loadStatus() {
try {
renderStatus(await PluginUI.request(`${API}/status`));
} catch {
document.getElementById("statusPill").textContent = "状态未知";
}
}
async function loadModelInfo() {
try {
const data = await PluginUI.request(`${API}/model-info`);
const info = data.model_info;
const providers = data.providers;
document.getElementById("modelInfoContent").innerHTML =
`<div>Det: <strong>${info.det_model}</strong> (${providers.det || "?"})</div>` +
`<div>Rec: <strong>${info.rec_model}</strong> (${providers.rec || "?"})</div>`;
// 加载完成后自动展开,避免每次进入都要点
document.getElementById("modelInfo").classList.remove("panel-collapsed");
} catch (err) {
console.error("加载模型信息失败:", err);
}
}
// ---- 文件选择 / 拖拽 ----
function bindDropzone() {
const dz = document.getElementById("dropzone");
const input = document.getElementById("file");
dz.addEventListener("click", () => input.click());
dz.addEventListener("dragover", (e) => {
e.preventDefault();
dz.classList.add("dragover");
});
dz.addEventListener("dragleave", () => dz.classList.remove("dragover"));
dz.addEventListener("drop", (e) => {
e.preventDefault();
dz.classList.remove("dragover");
const file = e.dataTransfer.files[0];
if (!file) return;
// 通过 DataTransfer 同步给 input,方便后续读取
const dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
previewImage();
});
}
function previewImage() {
const file = document.getElementById("file").files[0];
const preview = document.getElementById("preview");
if (!file) {
preview.innerHTML = "";
IMG_ORIG.w = IMG_ORIG.h = 0;
return;
}
const url = URL.createObjectURL(file);
// 先清旧图,强制 image 重建后再读取原始尺寸
preview.innerHTML = `<img id="previewImg" src="${url}" alt="预览">`;
const img = document.getElementById("previewImg");
img.onload = () => {
IMG_ORIG.w = img.naturalWidth;
IMG_ORIG.h = img.naturalHeight;
};
}
function switchTab(name) {
document.querySelectorAll(".tab").forEach((t) => t.classList.remove("active"));
document.querySelectorAll(".tab-content").forEach((c) => c.classList.remove("active"));
document.querySelector(`[data-tab="${name}"]`).classList.add("active");
document.getElementById(`tab-${name}`).classList.add("active");
}
// ---- 检测框叠加渲染 ----
function renderBoxes(boxes) {
const preview = document.getElementById("preview");
const existing = document.getElementById("previewCanvas");
if (existing) existing.remove();
if (!boxes || boxes.length === 0 || IMG_ORIG.w === 0) return;
const canvas = document.createElement("canvas");
canvas.id = "previewCanvas";
canvas.width = IMG_ORIG.w;
canvas.height = IMG_ORIG.h;
const ctx = canvas.getContext("2d");
ctx.strokeStyle = "rgba(255, 80, 0, 0.85)";
ctx.fillStyle = "rgba(255, 80, 0, 0.85)";
ctx.lineWidth = Math.max(2, Math.round(IMG_ORIG.w / 400));
boxes.forEach((b) => {
if (!b.poly || b.poly.length < 3) return;
ctx.beginPath();
b.poly.forEach((pt, i) => {
const [x, y] = pt;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.closePath();
ctx.stroke();
});
preview.appendChild(canvas);
}
// ---- 结果展示 ----
function renderResult(runResult) {
lastResult = runResult;
document.getElementById("outputText").textContent = runResult.text || "无识别结果";
const linesEl = document.getElementById("outputLines");
linesEl.innerHTML = "";
(runResult.lines || []).forEach((line, idx) => {
const li = document.createElement("li");
const text = document.createElement("span");
text.textContent = `${idx + 1}. ${line}`;
const score = document.createElement("span");
score.className = "score";
const box = (runResult.boxes || [])[idx];
score.textContent = box && box.score != null ? box.score.toFixed(2) : "";
li.appendChild(text);
li.appendChild(score);
linesEl.appendChild(li);
});
PluginUI.setJson("outputRaw", runResult.raw_result || "无原始结果");
renderBoxes(runResult.boxes || []);
document.getElementById("resultSection").style.display = "block";
}
async function copyResult() {
if (!lastResult) return;
await PluginUI.copyText(lastResult.text || "");
PluginUI.showMessage("message", "已复制到剪贴板", "success");
}
function downloadResult() {
if (!lastResult) return;
const blob = new Blob([lastResult.text || ""], { type: "text/plain;charset=utf-8" });
PluginUI.downloadBlob(blob, "ocr-result.txt");
}
// ---- 主流程:异步识别 + 轮询 ----
async function runOCR() {
const btn = document.getElementById("ocrBtn");
const progress = document.getElementById("ocrProgress");
btn.disabled = true;
progress.style.display = "block";
document.getElementById("resultSection").style.display = "none";
PluginUI.clearMessage("message");
// 清理旧日志
if (currentLogPanel) {
PluginUI.stopPolling(currentLogPanel);
currentLogPanel.remove();
}
try {
const file = document.getElementById("file").files[0];
if (!file) throw new Error("请选择图片");
const form = new FormData();
form.append("file", file);
const data = await PluginUI.request(`${API}/upload`, { method: "POST", body: form });
if (!data.run_id) throw new Error("未返回 run_id");
currentLogPanel = PluginUI.createRunLog({ title: "OCR 日志" });
document.getElementById("logPanel").appendChild(currentLogPanel);
await new Promise((resolve, reject) => {
PluginUI.pollRunEvents(POLL_PLUGIN, data.run_id, currentLogPanel, {
onRunComplete: (run) => {
if (run.status === "succeeded" && run.result) {
renderResult(run.result);
} else if (run.status === "failed") {
PluginUI.showMessage("message", run.error || "识别失败", "error");
}
resolve();
},
onError: (err) => {
PluginUI.showMessage("message", `轮询错误: ${err.message}`, "error");
reject(err);
},
});
});
} catch (err) {
PluginUI.showMessage("message", err.message || String(err), "error");
} finally {
btn.disabled = false;
progress.style.display = "none";
}
}
// ---- 初始化 ----
bindDropzone();
loadStatus();
loadModelInfo();
</script>
</body>
</html>