Files
ai-portal/app/ocr/page.tsx
T

454 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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"> PNGJPG </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>
)
}