454 lines
18 KiB
TypeScript
454 lines
18 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useRef, useEffect, useCallback } from "react"
|
||
import Link from "next/link"
|
||
|
||
interface OcrBlock {
|
||
page: number
|
||
ocr_result: Array<
|
||
[
|
||
[[number, number], [number, number], [number, number], [number, number]],
|
||
[string, number],
|
||
]
|
||
>
|
||
}
|
||
|
||
interface OcrResponse {
|
||
resultcode: number
|
||
message: string
|
||
data: OcrBlock[]
|
||
}
|
||
|
||
interface BoundingBox {
|
||
left: number
|
||
top: number
|
||
width: number
|
||
height: number
|
||
}
|
||
|
||
interface TextBlock {
|
||
text: string
|
||
confidence: number
|
||
box: BoundingBox
|
||
}
|
||
|
||
export default function OcrPage() {
|
||
const [file, setFile] = useState<File | null>(null)
|
||
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
||
const [isPdf, setIsPdf] = useState(false)
|
||
const [ocrData, setOcrData] = useState<OcrResponse | null>(null)
|
||
const [loading, setLoading] = useState(false)
|
||
const [error, setError] = useState("")
|
||
const [dragOver, setDragOver] = useState(false)
|
||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
|
||
const [copied, setCopied] = useState(false)
|
||
const imageRef = useRef<HTMLImageElement>(null)
|
||
const containerRef = useRef<HTMLDivElement>(null)
|
||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||
const [scale, setScale] = useState({ x: 1, y: 1 })
|
||
const [imgLoaded, setImgLoaded] = useState(false)
|
||
|
||
const resetState = useCallback(() => {
|
||
setOcrData(null)
|
||
setError("")
|
||
setCopied(false)
|
||
setHoveredIndex(null)
|
||
}, [])
|
||
|
||
const handleFile = useCallback((f: File) => {
|
||
resetState()
|
||
setFile(f)
|
||
setLoading(true)
|
||
|
||
const isImage = f.type.startsWith("image/")
|
||
const isPdfFile = f.type === "application/pdf"
|
||
|
||
setIsPdf(isPdfFile)
|
||
|
||
if (isImage) {
|
||
if (imageUrl) URL.revokeObjectURL(imageUrl)
|
||
const url = URL.createObjectURL(f)
|
||
setImageUrl(url)
|
||
setImgLoaded(false)
|
||
} else {
|
||
setImageUrl(null)
|
||
setImgLoaded(false)
|
||
}
|
||
|
||
const formData = new FormData()
|
||
formData.append("file", f)
|
||
|
||
fetch("/api/ocr", {
|
||
method: "POST",
|
||
body: formData,
|
||
})
|
||
.then((res) => res.json().catch(() => null))
|
||
.then((data: OcrResponse | null) => {
|
||
if (!data || data.resultcode !== 200) {
|
||
throw new Error(data?.message || "OCR 识别失败")
|
||
}
|
||
setOcrData(data)
|
||
})
|
||
.catch((err) => {
|
||
setError(err.message || "OCR 识别失败,请稍后重试")
|
||
})
|
||
.finally(() => {
|
||
setLoading(false)
|
||
})
|
||
}, [resetState, imageUrl])
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (imageUrl) URL.revokeObjectURL(imageUrl)
|
||
}
|
||
}, [imageUrl])
|
||
|
||
useEffect(() => {
|
||
const handlePaste = (e: ClipboardEvent) => {
|
||
const items = e.clipboardData?.items
|
||
if (!items) return
|
||
|
||
for (const item of items) {
|
||
if (item.type.startsWith("image/")) {
|
||
e.preventDefault()
|
||
const blob = item.getAsFile()
|
||
if (blob) {
|
||
handleFile(blob)
|
||
}
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
document.addEventListener("paste", handlePaste)
|
||
return () => document.removeEventListener("paste", handlePaste)
|
||
}, [handleFile])
|
||
|
||
const handleDrop = useCallback(
|
||
(e: React.DragEvent) => {
|
||
e.preventDefault()
|
||
setDragOver(false)
|
||
const f = e.dataTransfer.files[0]
|
||
if (f) handleFile(f)
|
||
},
|
||
[handleFile],
|
||
)
|
||
|
||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||
e.preventDefault()
|
||
setDragOver(true)
|
||
}, [])
|
||
|
||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||
e.preventDefault()
|
||
setDragOver(false)
|
||
}, [])
|
||
|
||
const handleFileSelect = useCallback(
|
||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const f = e.target.files?.[0]
|
||
if (f) handleFile(f)
|
||
},
|
||
[handleFile],
|
||
)
|
||
|
||
const handleImageLoad = useCallback(() => {
|
||
const img = imageRef.current
|
||
if (!img) return
|
||
setScale({
|
||
x: img.clientWidth / img.naturalWidth,
|
||
y: img.clientHeight / img.naturalHeight,
|
||
})
|
||
setImgLoaded(true)
|
||
}, [])
|
||
|
||
const allTextBlocks: TextBlock[] =
|
||
ocrData?.data?.flatMap((page) =>
|
||
page.ocr_result.map(([coords, [text, confidence]]) => {
|
||
const xs = coords.map((c) => c[0])
|
||
const ys = coords.map((c) => c[1])
|
||
const left = Math.min(...xs)
|
||
const top = Math.min(...ys)
|
||
const right = Math.max(...xs)
|
||
const bottom = Math.max(...ys)
|
||
return {
|
||
text,
|
||
confidence,
|
||
box: {
|
||
left,
|
||
top,
|
||
width: right - left,
|
||
height: bottom - top,
|
||
},
|
||
}
|
||
}),
|
||
) ?? []
|
||
|
||
const handleCopyAll = useCallback(async () => {
|
||
const text = allTextBlocks.map((b) => b.text).join("\n")
|
||
await navigator.clipboard.writeText(text)
|
||
setCopied(true)
|
||
setTimeout(() => setCopied(false), 2000)
|
||
}, [allTextBlocks])
|
||
|
||
const handleCopyBlock = useCallback(async (text: string) => {
|
||
await navigator.clipboard.writeText(text)
|
||
setCopied(true)
|
||
setTimeout(() => setCopied(false), 2000)
|
||
}, [])
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gray-50">
|
||
<nav className="bg-white border-b border-gray-200 fixed top-0 left-0 right-0 z-50 shadow-sm">
|
||
<div className="max-w-6xl mx-auto px-6 py-3">
|
||
<div className="flex items-center justify-between">
|
||
<Link href="/" className="flex items-center gap-2">
|
||
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
|
||
<span className="text-white font-bold text-sm">AI</span>
|
||
</div>
|
||
<span className="font-bold text-gray-900">冲浪智能体广场</span>
|
||
</Link>
|
||
<div className="flex items-center gap-6">
|
||
<Link href="/" className="text-gray-600 hover:text-blue-600 transition">首页</Link>
|
||
<Link href="/agents" className="text-gray-600 hover:text-blue-600 transition">智能体广场</Link>
|
||
<Link href="/news" className="text-gray-600 hover:text-blue-600 transition">新闻动态</Link>
|
||
<Link href="/ocr" className="text-blue-600 font-medium transition">OCR识别</Link>
|
||
<Link
|
||
href="/admin/login"
|
||
className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700 transition"
|
||
>
|
||
登录
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
|
||
<main className="pt-20 pb-12">
|
||
<div className="max-w-6xl mx-auto px-6">
|
||
<div className="text-center mb-8">
|
||
<h1 className="text-2xl font-bold text-gray-900 mb-2">OCR 文字识别</h1>
|
||
<p className="text-gray-500">上传图片或截图粘贴,自动识别图片中的文字内容</p>
|
||
</div>
|
||
|
||
<div
|
||
className={`relative border-2 border-dashed rounded-2xl p-10 text-center cursor-pointer transition-all duration-300 mb-6 ${
|
||
dragOver
|
||
? "border-blue-400 bg-blue-50"
|
||
: "border-gray-300 bg-white hover:border-blue-300 hover:bg-gray-50"
|
||
}`}
|
||
onDrop={handleDrop}
|
||
onDragOver={handleDragOver}
|
||
onDragLeave={handleDragLeave}
|
||
onClick={() => fileInputRef.current?.click()}
|
||
>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/*,.pdf"
|
||
className="hidden"
|
||
onChange={handleFileSelect}
|
||
/>
|
||
{loading ? (
|
||
<div className="flex flex-col items-center gap-3">
|
||
<div className="w-10 h-10 border-4 border-blue-200 border-t-blue-600 rounded-full animate-spin" />
|
||
<p className="text-gray-500">正在识别中...</p>
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-col items-center gap-3">
|
||
<div className="w-14 h-14 bg-blue-100 rounded-full flex items-center justify-center">
|
||
<i className="fa-solid fa-file-image text-2xl text-blue-600" />
|
||
</div>
|
||
<div>
|
||
<p className="text-gray-700 font-medium">
|
||
点击上传或拖拽文件到此区域
|
||
</p>
|
||
<p className="text-gray-400 text-sm mt-1">
|
||
支持图片格式 (PNG, JPG) 或 Ctrl+V 粘贴截图
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-xl text-sm mb-6">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{(imageUrl || isPdf) && ocrData && (
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||
{imageUrl && (
|
||
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm overflow-hidden">
|
||
<div className="px-5 py-3 border-b border-gray-100 bg-gray-50 flex items-center gap-2">
|
||
<i className="fa-solid fa-image text-blue-600" />
|
||
<span className="font-medium text-gray-700 text-sm">图片预览</span>
|
||
</div>
|
||
<div className="p-4">
|
||
<div ref={containerRef} className="relative inline-block max-w-full">
|
||
<img
|
||
ref={imageRef}
|
||
src={imageUrl}
|
||
alt="上传图片"
|
||
className="max-w-full h-auto rounded-lg"
|
||
onLoad={handleImageLoad}
|
||
onDragStart={(e) => e.preventDefault()}
|
||
/>
|
||
{imgLoaded &&
|
||
allTextBlocks.map((block, idx) => (
|
||
<div
|
||
key={idx}
|
||
className="absolute border-2 pointer-events-none transition-all duration-200"
|
||
style={{
|
||
left: block.box.left * scale.x,
|
||
top: block.box.top * scale.y,
|
||
width: block.box.width * scale.x,
|
||
height: block.box.height * scale.y,
|
||
borderColor:
|
||
hoveredIndex === idx
|
||
? "rgba(59, 130, 246, 0.9)"
|
||
: "rgba(59, 130, 246, 0.4)",
|
||
backgroundColor:
|
||
hoveredIndex === idx
|
||
? "rgba(59, 130, 246, 0.15)"
|
||
: "rgba(59, 130, 246, 0.08)",
|
||
zIndex: hoveredIndex === idx ? 10 : 1,
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{isPdf && !imageUrl && (
|
||
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm overflow-hidden">
|
||
<div className="px-5 py-3 border-b border-gray-100 bg-gray-50 flex items-center gap-2">
|
||
<i className="fa-solid fa-file-pdf text-red-500" />
|
||
<span className="font-medium text-gray-700 text-sm">PDF 文件</span>
|
||
</div>
|
||
<div className="p-12 flex flex-col items-center justify-center text-gray-400">
|
||
<i className="fa-solid fa-file-pdf text-5xl mb-4 text-red-300" />
|
||
<p className="text-sm">{file?.name}</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm overflow-hidden flex flex-col">
|
||
<div className="px-5 py-3 border-b border-gray-100 bg-gray-50 flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<i className="fa-solid fa-list text-blue-600" />
|
||
<span className="font-medium text-gray-700 text-sm">识别结果</span>
|
||
<span className="text-xs text-gray-400">
|
||
({ocrData?.message || ""})
|
||
</span>
|
||
</div>
|
||
<button
|
||
onClick={handleCopyAll}
|
||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition ${
|
||
copied
|
||
? "bg-green-50 text-green-600"
|
||
: "bg-gray-100 text-gray-600 hover:bg-blue-50 hover:text-blue-600"
|
||
}`}
|
||
>
|
||
<i className={`fa-solid ${copied ? "fa-check" : "fa-copy"}`} />
|
||
{copied ? "已复制" : "复制全部"}
|
||
</button>
|
||
</div>
|
||
<div className="flex-1 overflow-y-auto max-h-[500px] p-4 space-y-2">
|
||
{allTextBlocks.length === 0 ? (
|
||
<p className="text-gray-400 text-sm text-center py-8">未识别到文字</p>
|
||
) : (
|
||
allTextBlocks.map((block, idx) => (
|
||
<div
|
||
key={idx}
|
||
className={`group flex items-start gap-3 p-3 rounded-xl cursor-pointer transition-colors ${
|
||
hoveredIndex === idx
|
||
? "bg-blue-50 ring-1 ring-blue-200"
|
||
: "hover:bg-gray-50"
|
||
}`}
|
||
onMouseEnter={() => setHoveredIndex(idx)}
|
||
onMouseLeave={() => setHoveredIndex(null)}
|
||
onClick={() => handleCopyBlock(block.text)}
|
||
>
|
||
<span className="flex-shrink-0 w-6 h-6 bg-gray-100 rounded-full flex items-center justify-center text-xs font-medium text-gray-500 group-hover:bg-blue-100 group-hover:text-blue-600 transition-colors">
|
||
{idx + 1}
|
||
</span>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm text-gray-800 leading-relaxed break-all">
|
||
{block.text}
|
||
</p>
|
||
<p className="text-xs text-gray-400 mt-1">
|
||
置信度: {(block.confidence * 100).toFixed(1)}%
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
handleCopyBlock(block.text)
|
||
}}
|
||
className="flex-shrink-0 w-7 h-7 rounded-lg flex items-center justify-center text-gray-400 opacity-0 group-hover:opacity-100 hover:bg-blue-100 hover:text-blue-600 transition-all"
|
||
>
|
||
<i className="fa-solid fa-copy text-xs" />
|
||
</button>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{!file && !loading && (
|
||
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm p-8">
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||
<div className="text-center p-4">
|
||
<div className="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center mx-auto mb-3">
|
||
<i className="fa-solid fa-upload text-blue-600 text-xl" />
|
||
</div>
|
||
<h3 className="font-medium text-gray-900 mb-1">图片上传</h3>
|
||
<p className="text-xs text-gray-400">支持 PNG、JPG 等常见图片格式</p>
|
||
</div>
|
||
<div className="text-center p-4">
|
||
<div className="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center mx-auto mb-3">
|
||
<i className="fa-solid fa-paste text-green-600 text-xl" />
|
||
</div>
|
||
<h3 className="font-medium text-gray-900 mb-1">截图粘贴</h3>
|
||
<p className="text-xs text-gray-400">使用 Ctrl+V 快速粘贴截图识别</p>
|
||
</div>
|
||
<div className="text-center p-4">
|
||
<div className="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center mx-auto mb-3">
|
||
<i className="fa-solid fa-arrows-alt text-purple-600 text-xl" />
|
||
</div>
|
||
<h3 className="font-medium text-gray-900 mb-1">拖拽上传</h3>
|
||
<p className="text-xs text-gray-400">拖拽文件到上传区域即可识别</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</main>
|
||
|
||
<div className="border-t border-gray-200 py-8 mt-auto">
|
||
<div className="max-w-6xl mx-auto px-6 flex flex-col md:flex-row items-center justify-between gap-4">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-7 h-7 bg-blue-600 rounded-lg flex items-center justify-center">
|
||
<span className="text-white font-bold text-xs">AI</span>
|
||
</div>
|
||
<span className="font-semibold text-gray-700 text-sm">江苏冲浪软件科技有限公司</span>
|
||
</div>
|
||
<div className="text-gray-400 text-sm text-center">
|
||
© 2026 江苏冲浪软件科技有限公司 · AI 智能体广场
|
||
</div>
|
||
<div className="flex gap-4 text-xs text-gray-400">
|
||
<a className="hover:text-gray-600 cursor-pointer transition">关于我们</a>
|
||
<a className="hover:text-gray-600 cursor-pointer transition">隐私政策</a>
|
||
<a className="hover:text-gray-600 cursor-pointer transition">联系我们</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|