| <!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 }; |
| 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; |
| |
| 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); |
| |
| 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> |
|
|