209 lines
6.4 KiB
Vue
209 lines
6.4 KiB
Vue
<template>
|
||
<div class="page-wrap">
|
||
<h2 class="page-title">图片 OCR 识别</h2>
|
||
<p class="page-desc">
|
||
上传图片,使用 WASM 版 Tesseract 在浏览器本地识别中英文文字,图片不会上传服务器。
|
||
</p>
|
||
|
||
<t-card class="tool-section" title="上传图片">
|
||
<UploadDrop v-model:files="files" accept="image/*" tip="支持中文 / 英文识别" />
|
||
<img
|
||
v-if="previewUrl"
|
||
:src="previewUrl"
|
||
alt="待识别图片"
|
||
class="preview-img img-previewable"
|
||
title="点击预览大图"
|
||
@click="openViewer([previewUrl])"
|
||
/>
|
||
<div class="form-actions">
|
||
<t-button theme="primary" :loading="busy" :disabled="!file" @click="recognize">
|
||
开始识别
|
||
</t-button>
|
||
<t-button variant="outline" @click="clearAll">清空</t-button>
|
||
</div>
|
||
<t-progress v-if="busy" class="progress" :percentage="progress" :label="progressLabel" />
|
||
<t-alert
|
||
class="notice"
|
||
theme="info"
|
||
message="首次识别需从官方语言包源下载中英文语言数据(约 20MB,浏览器会缓存)。如需完全离线部署,可将语言包放入 public/traineddata 并修改 LANG_PATH 常量(见 agents.md)。"
|
||
/>
|
||
</t-card>
|
||
|
||
<ErrorAlert :message="error" @close="error = ''" />
|
||
|
||
<t-card v-if="resultText" class="tool-section" title="识别结果">
|
||
<t-textarea v-model="resultText" :autosize="{ minRows: 6, maxRows: 16 }" readonly />
|
||
<div class="form-actions">
|
||
<CopyButton :text="resultText" />
|
||
<t-button variant="outline" size="small" @click="downloadResult">
|
||
<template #icon><DownloadIcon /></template>
|
||
下载 TXT
|
||
</t-button>
|
||
</div>
|
||
</t-card>
|
||
|
||
<HistoryPanel
|
||
:items="history.items"
|
||
@select="(t: string) => (resultText = t)"
|
||
@remove="history.remove"
|
||
@clear="history.clear"
|
||
/>
|
||
|
||
<!-- 待识别图放大预览 -->
|
||
<AppImageViewer v-model:visible="viewerVisible" v-model:index="viewerIndex" :images="viewerImages" />
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||
import { DownloadIcon } from 'tdesign-icons-vue-next'
|
||
import { createWorker } from 'tesseract.js'
|
||
// 将 worker 与 wasm 核心打包进产物(依赖全部来自 npm,不引入 CDN 脚本)
|
||
import TesseractWorker from 'tesseract.js/dist/worker.min.js?url'
|
||
import TesseractCore from 'tesseract.js-core/tesseract-core.wasm.js?url'
|
||
import UploadDrop from '@/components/common/UploadDrop.vue'
|
||
import ErrorAlert from '@/components/common/ErrorAlert.vue'
|
||
import CopyButton from '@/components/common/CopyButton.vue'
|
||
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
||
import { useHistory } from '@/composables/useHistory'
|
||
import { useImageViewer } from '@/composables/useImageViewer'
|
||
import AppImageViewer from '@/components/common/AppImageViewer.vue'
|
||
import { downloadText } from '@/composables/useDownload'
|
||
import { genFilename } from '@/utils'
|
||
|
||
/**
|
||
* 语言包路径。语言数据属于运行时资源(非代码库),默认使用官方托管源;
|
||
* 如需完全离线:将 *.traineddata.gz 放入 public/traineddata/ 后改为
|
||
* `${window.location.origin}/traineddata`。
|
||
*/
|
||
const LANG_PATH = 'https://tessdata.projectnaptha.com/4.0.0'
|
||
|
||
/** Worker 实例类型(避免依赖内部类型导出,保证类型兼容) */
|
||
type WorkerInstance = Awaited<ReturnType<typeof createWorker>>
|
||
|
||
const files = ref<File[]>([])
|
||
const file = ref<File | null>(null)
|
||
const previewUrl = ref('')
|
||
|
||
/** 待识别图放大预览(TDesign ImageViewer) */
|
||
const { visible: viewerVisible, index: viewerIndex, images: viewerImages, open: openViewer } = useImageViewer()
|
||
const busy = ref(false)
|
||
const progress = ref(0)
|
||
const progressLabel = ref('')
|
||
const error = ref('')
|
||
const resultText = ref('')
|
||
|
||
const history = useHistory('ocr')
|
||
|
||
let worker: WorkerInstance | null = null
|
||
|
||
/** 懒创建并复用 worker,避免每次识别重复初始化 */
|
||
async function getWorker(): Promise<WorkerInstance> {
|
||
if (worker) return worker
|
||
worker = await createWorker(['chi_sim', 'eng'], 1, {
|
||
workerPath: TesseractWorker,
|
||
corePath: TesseractCore,
|
||
langPath: LANG_PATH,
|
||
gzip: true,
|
||
logger: (m: { status: string; progress: number }) => {
|
||
// 仅在识别阶段更新进度条
|
||
if (m.status === 'recognizing text') {
|
||
progress.value = Math.round(m.progress * 100)
|
||
progressLabel.value = `识别中 ${progress.value}%`
|
||
}
|
||
},
|
||
})
|
||
return worker
|
||
}
|
||
|
||
/** 文件变化时(含拖拽删除/清空):更新待识别图片与预览 */
|
||
watch(files, (list) => {
|
||
error.value = ''
|
||
resultText.value = ''
|
||
previewUrl.value && URL.revokeObjectURL(previewUrl.value)
|
||
previewUrl.value = ''
|
||
const raw = list[0]
|
||
if (raw instanceof File) {
|
||
file.value = raw
|
||
previewUrl.value = URL.createObjectURL(raw)
|
||
} else {
|
||
file.value = null
|
||
}
|
||
})
|
||
|
||
async function recognize() {
|
||
if (!file.value) {
|
||
error.value = '请先上传图片'
|
||
return
|
||
}
|
||
busy.value = true
|
||
progress.value = 0
|
||
progressLabel.value = '初始化引擎…'
|
||
error.value = ''
|
||
try {
|
||
const w = await getWorker()
|
||
const { data } = await w.recognize(file.value)
|
||
resultText.value = data.text.trim()
|
||
if (!resultText.value) {
|
||
error.value = '未识别到文字,请尝试更清晰的图片'
|
||
} else {
|
||
history.add(resultText.value.slice(0, 500))
|
||
}
|
||
} catch {
|
||
error.value = '识别失败:请检查网络(首次需下载语言包)或换一张图片重试'
|
||
} finally {
|
||
busy.value = false
|
||
progress.value = 0
|
||
}
|
||
}
|
||
|
||
function downloadResult() {
|
||
if (resultText.value) downloadText(resultText.value, genFilename('ocr', 'txt'))
|
||
}
|
||
|
||
function clearAll() {
|
||
files.value = []
|
||
file.value = null
|
||
error.value = ''
|
||
resultText.value = ''
|
||
previewUrl.value && URL.revokeObjectURL(previewUrl.value)
|
||
previewUrl.value = ''
|
||
}
|
||
|
||
onBeforeUnmount(async () => {
|
||
// 页面卸载时释放 worker 资源
|
||
if (worker) {
|
||
try {
|
||
await worker.terminate()
|
||
} catch {
|
||
/* 忽略终止异常 */
|
||
}
|
||
worker = null
|
||
}
|
||
previewUrl.value && URL.revokeObjectURL(previewUrl.value)
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.form-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-top: 16px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.preview-img {
|
||
display: block;
|
||
max-width: 100%;
|
||
max-height: 300px;
|
||
margin-top: 12px;
|
||
border-radius: 8px;
|
||
border: 1px solid var(--td-border-level-1-color);
|
||
}
|
||
.progress {
|
||
margin-top: 12px;
|
||
}
|
||
.notice {
|
||
margin-top: 12px;
|
||
}
|
||
</style>
|