diff --git a/Dockerfile b/Dockerfile index 76f419e..a4d313d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,11 @@ FROM node:20-alpine AS base WORKDIR /app - + # 阶段1: 安装所有依赖(包括 devDependencies,因为构建需要) FROM base AS deps COPY package*.json ./ RUN npm ci - + # 阶段2: 构建应用 FROM base AS builder COPY --from=deps /app/node_modules ./node_modules @@ -14,15 +14,15 @@ RUN npx prisma generate ENV DATABASE_URL="file:./dev.db" RUN npx prisma db push RUN npm run build - + # 阶段3: 生产运行环境 FROM base AS runner WORKDIR /app ENV NODE_ENV=production - + RUN addgroup --system --gid 1001 nodejs && \ adduser --system --uid 1001 nextjs - + # 复制 standalone 构建输出 COPY --from=builder /app/.next/standalone ./ # 复制静态文件 @@ -31,7 +31,7 @@ COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/prisma ./prisma # 复制 public 静态文件 COPY --from=builder /app/public ./public - + RUN mkdir -p /app/data && chown nextjs:nodejs /app/data USER nextjs EXPOSE 3000 diff --git a/app/api/ocr/route.ts b/app/api/ocr/route.ts new file mode 100644 index 0000000..a720646 --- /dev/null +++ b/app/api/ocr/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from "next/server" + +const OCR_SERVICE_URL = "http://192.168.10.236:8000/ocr/predict-pdf-file" + +export async function POST(request: NextRequest) { + try { + const formData = await request.formData() + const file = formData.get("file") + + if (!file || !(file instanceof Blob)) { + return NextResponse.json({ error: "请上传文件" }, { status: 400 }) + } + + const ocrFormData = new FormData() + const fileName = file instanceof File ? file.name : "file" + ocrFormData.append("file", file, fileName) + + const response = await fetch(OCR_SERVICE_URL, { + method: "POST", + body: ocrFormData, + signal: AbortSignal.timeout(60000), + }) + + if (!response.ok) { + return NextResponse.json( + { error: `OCR 服务请求失败 (${response.status})` }, + { status: 502 } + ) + } + + const data = await response.json() + return NextResponse.json(data) + } catch (error) { + console.error("OCR API error:", error) + return NextResponse.json( + { error: "OCR 识别失败,请稍后重试" }, + { status: 500 } + ) + } +} + +export const config = { + api: { + bodyParser: false, + }, +} diff --git a/app/ocr/page.tsx b/app/ocr/page.tsx new file mode 100644 index 0000000..0ef2c64 --- /dev/null +++ b/app/ocr/page.tsx @@ -0,0 +1,453 @@ +'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(null) + const [imageUrl, setImageUrl] = useState(null) + const [isPdf, setIsPdf] = useState(false) + const [ocrData, setOcrData] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState("") + const [dragOver, setDragOver] = useState(false) + const [hoveredIndex, setHoveredIndex] = useState(null) + const [copied, setCopied] = useState(false) + const imageRef = useRef(null) + const containerRef = useRef(null) + const fileInputRef = useRef(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) => { + 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 ( +
+ + +
+
+
+

OCR 文字识别

+

上传图片或截图粘贴,自动识别图片中的文字内容

+
+ +
fileInputRef.current?.click()} + > + + {loading ? ( +
+
+

正在识别中...

+
+ ) : ( +
+
+ +
+
+

+ 点击上传或拖拽文件到此区域 +

+

+ 支持图片格式 (PNG, JPG) 或 Ctrl+V 粘贴截图 +

+
+
+ )} +
+ + {error && ( +
+ {error} +
+ )} + + {(imageUrl || isPdf) && ocrData && ( +
+ {imageUrl && ( +
+
+ + 图片预览 +
+
+
+ 上传图片 e.preventDefault()} + /> + {imgLoaded && + allTextBlocks.map((block, idx) => ( +
+ ))} +
+
+
+ )} + + {isPdf && !imageUrl && ( +
+
+ + PDF 文件 +
+
+ +

{file?.name}

+
+
+ )} + +
+
+
+ + 识别结果 + + ({ocrData?.message || ""}) + +
+ +
+
+ {allTextBlocks.length === 0 ? ( +

未识别到文字

+ ) : ( + allTextBlocks.map((block, idx) => ( +
setHoveredIndex(idx)} + onMouseLeave={() => setHoveredIndex(null)} + onClick={() => handleCopyBlock(block.text)} + > + + {idx + 1} + +
+

+ {block.text} +

+

+ 置信度: {(block.confidence * 100).toFixed(1)}% +

+
+ +
+ )) + )} +
+
+
+ )} + + {!file && !loading && ( +
+
+
+
+ +
+

图片上传

+

支持 PNG、JPG 等常见图片格式

+
+
+
+ +
+

截图粘贴

+

使用 Ctrl+V 快速粘贴截图识别

+
+
+
+ +
+

拖拽上传

+

拖拽文件到上传区域即可识别

+
+
+
+ )} +
+
+ +
+
+
+
+ AI +
+ 江苏冲浪软件科技有限公司 +
+
+ © 2026 江苏冲浪软件科技有限公司 · AI 智能体广场 +
+ +
+
+
+ ) +} diff --git a/app/page.tsx b/app/page.tsx index 8646039..1630b88 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -38,6 +38,7 @@ export default async function HomePage() { 首页 智能体广场 新闻动态 + OCR识别