From 1ba6e96546cb82e4e5baff84ee4dbf3ecf10ea80 Mon Sep 17 00:00:00 2001 From: NeverlandYao <865373013@qq.com> Date: Fri, 5 Sep 2025 17:59:11 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E5=90=88=E5=B9=B6=E4=B8=8A=E6=B8=B8?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=B9=B6=E6=B7=BB=E5=8A=A0OCR=E5=92=8C?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E4=B8=8A=E4=BC=A0=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/ocr/route.ts | 155 ++++++++++++ app/api/upload/[fileId]/route.ts | 101 ++++++++ app/api/upload/route.ts | 178 +++++++++++++ components/file-upload-input.tsx | 304 ++++++++++++++++++++++ components/fragment-input.tsx | 25 +- docs/jina-ai-integration.md | 1 + lib/types.ts | 82 ++++++ package.json | 2 + package/file-storage/index.ts | 12 + package/file-storage/models.ts | 131 ++++++++++ package/file-storage/service.ts | 418 +++++++++++++++++++++++++++++++ package/file-upload/index.ts | 415 ++++++++++++++++++++++++++++++ package/ocr/index.ts | 237 ++++++++++++++++++ pnpm-lock.yaml | 166 +++++++++--- 14 files changed, 2189 insertions(+), 38 deletions(-) create mode 100644 app/api/ocr/route.ts create mode 100644 app/api/upload/[fileId]/route.ts create mode 100644 app/api/upload/route.ts create mode 100644 components/file-upload-input.tsx create mode 100644 docs/jina-ai-integration.md create mode 100644 package/file-storage/index.ts create mode 100644 package/file-storage/models.ts create mode 100644 package/file-storage/service.ts create mode 100644 package/file-upload/index.ts create mode 100644 package/ocr/index.ts diff --git a/app/api/ocr/route.ts b/app/api/ocr/route.ts new file mode 100644 index 0000000..1a29b1a --- /dev/null +++ b/app/api/ocr/route.ts @@ -0,0 +1,155 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { fileStorageService } from '@/package/file-storage'; +import { OCRResponse } from '@/lib/types'; +import { ocrService } from '@/package/ocr'; + +export async function POST(request: NextRequest): Promise> { + try { + const formData = await request.formData(); + const file = formData.get('file') as File; + const fileId = formData.get('fileId') as string; + const language = formData.get('language') as string || 'chi_sim+eng'; + + let imageData: Buffer; + let fileName: string; + let fileSize: number; + let fileType: string; + + if (fileId) { + // 从文件存储服务获取文件 + try { + const { file, data } = await fileStorageService.getFile(fileId); + + if (!file || !data) { + return NextResponse.json({ + success: false, + error: '文件不存在' + }, { status: 404 }); + } + + if (!file.mimetype.startsWith('image/')) { + return NextResponse.json({ + success: false, + error: '只支持图片文件的OCR识别' + }, { status: 400 }); + } + + imageData = data; + fileName = file.originalName; + fileSize = file.size; + fileType = file.mimetype; + + } catch (error) { + return NextResponse.json({ + success: false, + error: '文件不存在或无法访问' + }, { status: 404 }); + } + } else if (file) { + // 直接处理上传的文件 + if (!file.type.startsWith('image/')) { + return NextResponse.json({ + success: false, + error: '只支持图片文件的OCR识别' + }, { status: 400 }); + } + + // 验证文件大小 (最大10MB) + const MAX_FILE_SIZE = 10 * 1024 * 1024; + if (file.size > MAX_FILE_SIZE) { + return NextResponse.json({ + success: false, + error: '图片文件过大,请选择小于10MB的图片' + }, { status: 400 }); + } + + imageData = Buffer.from(await file.arrayBuffer()); + fileName = file.name; + fileSize = file.size; + fileType = file.type; + } else { + return NextResponse.json({ + success: false, + error: '没有找到要处理的图片文件' + }, { status: 400 }); + } + + const startTime = Date.now(); + + // 使用 OCR 服务进行文字识别 + try { + // 将 Buffer 转换为 Blob 以供 OCR 服务使用 + const blob = new Blob([imageData], { type: fileType }); + + const ocrResult = await ocrService.recognize(blob, { + language: language + }); + + const processingTime = Date.now() - startTime; + + return NextResponse.json({ + success: true, + data: { + text: ocrResult.text, + confidence: ocrResult.confidence, + language: language, + boundingBoxes: ocrResult.words.map(word => ({ + text: word.text, + x: word.bbox.x0, + y: word.bbox.y0, + width: word.bbox.x1 - word.bbox.x0, + height: word.bbox.y1 - word.bbox.y0, + confidence: word.confidence + })), + metadata: { + processingTime, + imageWidth: 0, + imageHeight: 0, + detectedLanguages: [language], + ocrEngine: 'tesseract.js', + version: '6.0.1' + } + } + }); + + } catch (ocrError) { + console.error('OCR 识别失败:', ocrError); + return NextResponse.json({ + success: false, + error: 'OCR 识别失败: ' + (ocrError instanceof Error ? ocrError.message : '未知错误') + }, { status: 500 }); + } + + } catch (error) { + console.error('OCR API错误:', error); + return NextResponse.json({ + success: false, + error: 'OCR服务暂时不可用,请稍后重试' + }, { status: 500 }); + } +} + +// 获取 OCR 统计信息的 API +export async function GET(): Promise { + try { + return NextResponse.json({ + success: true, + data: { + message: 'OCR 服务运行正常', + supportedLanguages: [ + { code: 'chi_sim+eng', name: '中英文' }, + { code: 'eng', name: '英文' }, + { code: 'chi_sim', name: '简体中文' }, + { code: 'chi_tra', name: '繁体中文' } + ], + version: '6.0.1' + } + }); + } catch (error) { + console.error('OCR 服务状态检查失败:', error); + return NextResponse.json({ + success: false, + error: 'OCR 服务不可用' + }, { status: 500 }); + } +} diff --git a/app/api/upload/[fileId]/route.ts b/app/api/upload/[fileId]/route.ts new file mode 100644 index 0000000..1c52939 --- /dev/null +++ b/app/api/upload/[fileId]/route.ts @@ -0,0 +1,101 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { fileStorageService } from '@/package/file-storage'; + +export async function GET( + request: NextRequest, + { params }: { params: { fileId: string } } +): Promise { + try { + const { fileId } = params; + + if (!fileId) { + return NextResponse.json({ + success: false, + error: '缺少文件ID' + }, { status: 400 }); + } + + // 获取文件 + const { file, data } = await fileStorageService.getFile(fileId); + + if (!data) { + return NextResponse.json({ + success: false, + error: '文件数据不存在' + }, { status: 404 }); + } + + // 设置响应头 + const headers = new Headers(); + headers.set('Content-Type', file.mimetype); + headers.set('Content-Length', file.size.toString()); + headers.set('Content-Disposition', `inline; filename="${encodeURIComponent(file.originalName)}"`); + + // 缓存控制 + headers.set('Cache-Control', 'public, max-age=31536000'); // 1年缓存 + headers.set('ETag', `"${file._id}"`); + + // 检查If-None-Match头(ETag缓存) + const ifNoneMatch = request.headers.get('if-none-match'); + if (ifNoneMatch === `"${file._id}"`) { + return new NextResponse(null, { status: 304, headers }); + } + + return new NextResponse(data, { + status: 200, + headers + }); + + } catch (error) { + console.error('文件下载错误:', error); + + if (error instanceof Error && error.message.includes('不存在')) { + return NextResponse.json({ + success: false, + error: '文件不存在' + }, { status: 404 }); + } + + return NextResponse.json({ + success: false, + error: '文件下载失败' + }, { status: 500 }); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: { fileId: string } } +): Promise { + try { + const { fileId } = params; + + if (!fileId) { + return NextResponse.json({ + success: false, + error: '缺少文件ID' + }, { status: 400 }); + } + + const success = await fileStorageService.deleteFile(fileId); + + if (success) { + return NextResponse.json({ + success: true, + message: '文件删除成功' + }); + } else { + return NextResponse.json({ + success: false, + error: '文件不存在或删除失败' + }, { status: 404 }); + } + + } catch (error) { + console.error('文件删除错误:', error); + return NextResponse.json({ + success: false, + error: '文件删除失败' + }, { status: 500 }); + } +} diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts new file mode 100644 index 0000000..4fde8d6 --- /dev/null +++ b/app/api/upload/route.ts @@ -0,0 +1,178 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { fileStorageService } from '@/package/file-storage'; +import { FileUploadResponse } from '@/lib/types'; + +const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB +const ALLOWED_TYPES = [ + 'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/bmp', 'image/webp', + 'text/plain', 'text/csv', 'text/html', + 'application/pdf' +]; + +export async function POST(request: NextRequest): Promise> { + try { + const formData = await request.formData(); + const file = formData.get('file') as File; + const uploadedBy = formData.get('uploadedBy') as string || undefined; + const description = formData.get('description') as string || undefined; + const tags = formData.get('tags') as string; + + if (!file) { + return NextResponse.json({ + success: false, + error: '没有找到上传的文件' + }, { status: 400 }); + } + + // 验证文件大小 + if (file.size > MAX_FILE_SIZE) { + return NextResponse.json({ + success: false, + error: `文件大小超过限制(${Math.round(MAX_FILE_SIZE / 1024 / 1024)}MB)` + }, { status: 400 }); + } + + // 验证文件类型 + if (!ALLOWED_TYPES.includes(file.type)) { + return NextResponse.json({ + success: false, + error: `不支持的文件类型: ${file.type}` + }, { status: 400 }); + } + + // 准备文件数据 + const fileBuffer = Buffer.from(await file.arrayBuffer()); + + // 准备元数据 + const metadata: Record = {}; + if (description) metadata.description = description; + if (tags) { + try { + metadata.tags = JSON.parse(tags); + } catch { + metadata.tags = tags.split(',').map((tag: string) => tag.trim()); + } + } + + // 如果是图片,尝试获取尺寸信息 + if (file.type.startsWith('image/')) { + // 这里可以添加图片尺寸检测逻辑 + // 为了简化,暂时跳过 + } + + // 上传文件到MongoDB + const savedFile = await fileStorageService.uploadFile( + fileBuffer, + file.name, + file.type, + { + maxSize: MAX_FILE_SIZE, + allowedMimeTypes: ALLOWED_TYPES, + metadata, + uploadedBy + } + ); + + const response: FileUploadResponse = { + success: true, + data: { + fileId: savedFile._id!, + fileName: savedFile.originalName, + fileSize: savedFile.size, + uploadedAt: savedFile.uploadedAt.toISOString(), + downloadUrl: `/api/upload/${savedFile._id}` // 下载链接 + } + }; + + return NextResponse.json(response); + + } catch (error) { + console.error('文件上传错误:', error); + return NextResponse.json({ + success: false, + error: error instanceof Error ? error.message : '文件上传失败,请稍后重试' + }, { status: 500 }); + } +} + +export async function GET(request: NextRequest): Promise { + try { + const { searchParams } = new URL(request.url); + const action = searchParams.get('action') || 'info'; + + if (action === 'list') { + // 获取文件列表 + const mimetype = searchParams.get('mimetype') || undefined; + const limit = parseInt(searchParams.get('limit') || '50'); + const skip = parseInt(searchParams.get('skip') || '0'); + + const files = await fileStorageService.queryFiles({ + mimetype, + limit, + skip, + sort: { uploadedAt: -1 } + }); + + return NextResponse.json({ + success: true, + data: { + files: files.map(file => ({ + fileId: file._id, + fileName: file.originalName, + mimetype: file.mimetype, + size: file.size, + uploadedAt: file.uploadedAt, + metadata: file.metadata + })), + count: files.length + } + }); + } + + if (action === 'stats') { + // 获取文件统计信息 + const stats = await fileStorageService.getFileStats(); + return NextResponse.json({ + success: true, + data: stats + }); + } + + // 默认获取文件信息 + const fileId = searchParams.get('fileId'); + if (!fileId) { + return NextResponse.json({ + success: false, + error: '缺少文件ID参数' + }, { status: 400 }); + } + + try { + const { file } = await fileStorageService.getFile(fileId); + return NextResponse.json({ + success: true, + data: { + fileId: file._id, + fileName: file.originalName, + mimetype: file.mimetype, + size: file.size, + uploadedAt: file.uploadedAt, + metadata: file.metadata, + status: file.status + } + }); + } catch (error) { + return NextResponse.json({ + success: false, + error: error instanceof Error ? error.message : '文件不存在' + }, { status: 404 }); + } + + } catch (error) { + console.error('文件查询错误:', error); + return NextResponse.json({ + success: false, + error: '文件查询失败' + }, { status: 500 }); + } +} diff --git a/components/file-upload-input.tsx b/components/file-upload-input.tsx new file mode 100644 index 0000000..55dd03b --- /dev/null +++ b/components/file-upload-input.tsx @@ -0,0 +1,304 @@ +"use client" + +import { useState, useRef } from "react" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Progress } from "@/components/ui/progress" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Upload, File, X, CheckCircle, AlertCircle } from "lucide-react" +import { useFragmentStore } from "@/lib/stores/fragment-store" +import { cn } from "@/package/utils/utils" + +interface UploadedFile { + file: File + progress: number + status: 'pending' | 'uploading' | 'success' | 'error' + error?: string + fileId?: string +} + +interface FileUploadInputProps { + onOCRResult?: (ocrText: string) => void +} + +export function FileUploadInput({ onOCRResult }: FileUploadInputProps) { + const [files, setFiles] = useState([]) + const [isDragOver, setIsDragOver] = useState(false) + const fileInputRef = useRef(null) + const { addFragment } = useFragmentStore() + + const handleFileSelect = (selectedFiles: FileList | null) => { + if (!selectedFiles) return + + const newFiles: UploadedFile[] = Array.from(selectedFiles).map(file => ({ + file, + progress: 0, + status: 'pending' + })) + + setFiles(prev => [...prev, ...newFiles]) + + // 自动开始上传 + newFiles.forEach((fileItem, index) => { + uploadFile(fileItem, files.length + index) + }) + } + + const uploadFile = async (fileItem: UploadedFile, index: number) => { + const formData = new FormData() + formData.append('file', fileItem.file) + + try { + // 更新状态为上传中 + setFiles(prev => prev.map((f, i) => + i === index ? { ...f, status: 'uploading', progress: 0 } : f + )) + + const response = await fetch('/api/upload', { + method: 'POST', + body: formData + }) + + const result = await response.json() + + if (result.success) { + // 上传成功,处理文件内容 + setFiles(prev => prev.map((f, i) => + i === index ? { + ...f, + status: 'success', + progress: 100, + fileId: result.data.fileId + } : f + )) + + // 如果是文本文件,尝试读取内容并创建知识碎片 + if (fileItem.file.type.startsWith('text/') || + fileItem.file.name.endsWith('.txt') || + fileItem.file.name.endsWith('.md')) { + + const reader = new FileReader() + reader.onload = async (e) => { + const content = e.target?.result as string + if (content) { + await createFragmentFromFile(fileItem.file.name, content, result.data.fileId) + } + } + reader.readAsText(fileItem.file) + } else if (fileItem.file.type.startsWith('image/')) { + // 如果是图片文件,进行 OCR 识别 + try { + const ocrResponse = await fetch('/api/ocr', { + method: 'POST', + body: (() => { + const formData = new FormData() + formData.append('fileId', result.data.fileId) + formData.append('language', 'chi_sim+eng') + return formData + })() + }) + + const ocrResult = await ocrResponse.json() + + if (ocrResult.success && ocrResult.data?.text) { + const ocrText = ocrResult.data.text.trim() + + // 如果有 OCR 结果且有回调函数,将文字传递给父组件 + if (ocrText && onOCRResult) { + onOCRResult(ocrText) + } + + // 创建包含 OCR 文字的知识碎片 + await createFragmentFromFile( + fileItem.file.name, + `图片识别文字:\n\n${ocrText}\n\n---\n文件: ${fileItem.file.name}\n类型: ${fileItem.file.type}\n大小: ${formatFileSize(fileItem.file.size)}\n上传时间: ${new Date().toLocaleString()}`, + result.data.fileId + ) + } else { + // OCR 识别失败,创建普通文件引用 + await createFragmentFromFile( + fileItem.file.name, + `文件: ${fileItem.file.name}\n类型: ${fileItem.file.type}\n大小: ${formatFileSize(fileItem.file.size)}\n上传时间: ${new Date().toLocaleString()}\n\n注意: 图片文字识别失败`, + result.data.fileId + ) + } + } catch (ocrError) { + console.error('OCR 识别失败:', ocrError) + // OCR 识别出错,创建普通文件引用 + await createFragmentFromFile( + fileItem.file.name, + `文件: ${fileItem.file.name}\n类型: ${fileItem.file.type}\n大小: ${formatFileSize(fileItem.file.size)}\n上传时间: ${new Date().toLocaleString()}\n\n注意: 图片文字识别出错`, + result.data.fileId + ) + } + } else { + // 非文本和图片文件,创建文件引用类型的知识碎片 + await createFragmentFromFile( + fileItem.file.name, + `文件: ${fileItem.file.name}\n类型: ${fileItem.file.type}\n大小: ${formatFileSize(fileItem.file.size)}\n上传时间: ${new Date().toLocaleString()}`, + result.data.fileId + ) + } + } else { + setFiles(prev => prev.map((f, i) => + i === index ? { + ...f, + status: 'error', + error: result.error || '上传失败' + } : f + )) + } + } catch (error) { + setFiles(prev => prev.map((f, i) => + i === index ? { + ...f, + status: 'error', + error: '网络错误,请重试' + } : f + )) + } + } + + const createFragmentFromFile = async (fileName: string, content: string, fileId: string) => { + try { + const fragmentData = { + title: fileName, + content: content, + tags: ['文件上传'], + category: '文件', + priority: 'medium' as const, + status: 'active' as const, + createdAt: new Date(), + updatedAt: new Date(), + metadata: { + wordCount: content.length, + readingTime: Math.ceil(content.length / 200), + fileId: fileId, + fileName: fileName + } + } + + await addFragment(fragmentData) + } catch (error) { + console.error('创建知识碎片失败:', error) + } + } + + const formatFileSize = (bytes: number): string => { + if (bytes === 0) return '0 Bytes' + const k = 1024 + const sizes = ['Bytes', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] + } + + const removeFile = (index: number) => { + setFiles(prev => prev.filter((_, i) => i !== index)) + } + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + setIsDragOver(true) + } + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault() + setIsDragOver(false) + } + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + setIsDragOver(false) + handleFileSelect(e.dataTransfer.files) + } + + return ( + + + 文件上传 + + + {/* 拖拽上传区域 */} +
fileInputRef.current?.click()} + > + +

拖拽文件到此处或点击选择

+

+ 支持文本文件、图片、PDF等格式,最大10MB +

+ handleFileSelect(e.target.files)} + accept=".txt,.md,.pdf,.doc,.docx,.jpg,.jpeg,.png,.gif" + /> +
+ + {/* 文件列表 */} + {files.length > 0 && ( +
+

上传文件

+ {files.map((fileItem, index) => ( +
+ + +
+

{fileItem.file.name}

+

+ {formatFileSize(fileItem.file.size)} +

+ + {fileItem.status === 'uploading' && ( + + )} + + {fileItem.status === 'error' && fileItem.error && ( + + + + {fileItem.error} + + + )} +
+ +
+ {fileItem.status === 'success' && ( + + )} + {fileItem.status === 'error' && ( + + )} + {fileItem.status === 'uploading' && ( +
+ )} + + +
+
+ ))} +
+ )} + + + ) +} \ No newline at end of file diff --git a/components/fragment-input.tsx b/components/fragment-input.tsx index 1159545..9b561fe 100644 --- a/components/fragment-input.tsx +++ b/components/fragment-input.tsx @@ -7,11 +7,12 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Label } from "@/components/ui/label" -import { FileText, Globe } from "lucide-react" +import { FileText, Globe, Upload } from "lucide-react" // API调用现在通过Next.js API路由处理,不再需要直接导入 import { useFragmentStore } from "@/lib/stores/fragment-store" import { useCategoryStore } from "@/lib/stores/category-store" import { WebFragmentInput } from "./web-fragment-input" +import { FileUploadInput } from "./file-upload-input" export function FragmentInput() { const [text, setText] = useState("") @@ -24,6 +25,18 @@ export function FragmentInput() { initializeStore() }, [initializeStore]) + // 处理 OCR 识别结果的回调函数 + const handleOCRResult = (ocrText: string) => { + setText(prevText => { + // 如果输入框为空,直接设置 OCR 文字 + if (!prevText.trim()) { + return ocrText + } + // 如果输入框有内容,在末尾添加 OCR 文字 + return prevText + '\n\n' + ocrText + }) + } + const handleAddFragment = async () => { if (!text.trim()) { alert("请输入知识碎片内容") @@ -145,7 +158,7 @@ export function FragmentInput() { return (
- + 文本输入 @@ -154,6 +167,10 @@ export function FragmentInput() { 网页解析 + + + 文件上传 + @@ -208,6 +225,10 @@ export function FragmentInput() { + + + +
) diff --git a/docs/jina-ai-integration.md b/docs/jina-ai-integration.md new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/docs/jina-ai-integration.md @@ -0,0 +1 @@ + diff --git a/lib/types.ts b/lib/types.ts index 1ef54f7..b05119e 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -119,3 +119,85 @@ export interface FragmentListResponse { sort: FragmentSort; } +// 文件上传相关类型定义 +export interface FileUploadResponse { + success: boolean; + data?: { + fileId: string; + fileName: string; + fileSize: number; + uploadedAt: string; + downloadUrl: string; + }; + error?: string; +} + +export interface FileListResponse { + success: boolean; + data?: { + files: Array<{ + fileId: string; + fileName: string; + mimetype: string; + size: number; + uploadedAt: Date; + metadata?: Record; + }>; + count: number; + }; + error?: string; +} + +export interface FileStatsResponse { + success: boolean; + data?: { + totalFiles: number; + totalSize: number; + filesByType: Record; + recentUploads: number; + }; + error?: string; +} + +export interface FileInfoResponse { + success: boolean; + data?: { + fileId: string; + fileName: string; + mimetype: string; + size: number; + uploadedAt: Date; + metadata?: Record; + status: string; + }; + error?: string; +} + +// OCR 识别相关类型定义 +export interface OCRResponse { + success: boolean; + data?: { + text: string; + confidence: number; + language: string; + boundingBoxes?: Array<{ + text: string; + x: number; + y: number; + width: number; + height: number; + confidence: number; + }>; + metadata?: { + processingTime: number; + imageWidth: number; + imageHeight: number; + detectedLanguages?: string[]; + ocrEngine: string; + version: string; + }; + }; + error?: string; + message?: string; +} + diff --git a/package.json b/package.json index e193ade..305dcac 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", "lucide-react": "^0.542.0", + "mongodb": "^6.19.0", "mongoose": "^8.18.0", "next": "15.5.2", "next-themes": "^0.4.6", @@ -58,6 +59,7 @@ "recharts": "2.15.4", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", + "tesseract.js": "^6.0.1", "vaul": "^1.1.2", "zod": "^4.1.5", "zustand": "^5.0.8" diff --git a/package/file-storage/index.ts b/package/file-storage/index.ts new file mode 100644 index 0000000..e49afdd --- /dev/null +++ b/package/file-storage/index.ts @@ -0,0 +1,12 @@ +/** + * 文件存储包入口 + * 提供MongoDB文件存储和OCR结果管理功能 + */ + +export * from './models'; +export * from './service'; + +// 便捷导出 +export { FileStorageService, fileStorageService } from './service'; +export { FileModel, OCRModel } from './models'; +export type { FileDocument, OCRDocument } from './models'; diff --git a/package/file-storage/models.ts b/package/file-storage/models.ts new file mode 100644 index 0000000..e9c398e --- /dev/null +++ b/package/file-storage/models.ts @@ -0,0 +1,131 @@ +import { Schema } from 'mongoose'; +import { getMongoModel } from '../mongo'; + +/** + * 文件存储模型 + * 使用GridFS存储大文件,小文件直接存储在文档中 + */ + +// 文件存储接口定义 +export interface FileDocument { + _id?: string; + filename: string; + originalName: string; + mimetype: string; + size: number; + encoding: string; + uploadedAt: Date; + uploadedBy?: string; // 用户ID + metadata: { + width?: number; + height?: number; + duration?: number; // 视频/音频时长 + description?: string; + tags?: string[]; + [key: string]: any; + }; + // 小文件直接存储 + data?: Buffer; + // 大文件使用GridFS + gridfsId?: string; + // 文件状态 + status: 'uploading' | 'completed' | 'error' | 'deleted'; + // 错误信息 + error?: string; +} + +// OCR结果存储接口 +export interface OCRDocument { + _id?: string; + fileId: string; // 关联的文件ID + text: string; // 识别的文本 + confidence: number; // 置信度 + language: string; // 识别语言 + boundingBoxes?: Array<{ + text: string; + x: number; + y: number; + width: number; + height: number; + confidence: number; + }>; + metadata: { + processingTime: number; + imageWidth: number; + imageHeight: number; + detectedLanguages: string[]; + ocrEngine: string; // 使用的OCR引擎 + version: string; // OCR引擎版本 + }; + processedAt: Date; + status: 'processing' | 'completed' | 'error'; + error?: string; +} + +// 文件存储Schema +const fileSchema = new Schema({ + filename: { type: String, required: true, index: true }, + originalName: { type: String, required: true }, + mimetype: { type: String, required: true, index: true }, + size: { type: Number, required: true }, + encoding: { type: String, required: true }, + uploadedAt: { type: Date, default: Date.now, index: true }, + uploadedBy: { type: String, index: true }, + metadata: { + type: Schema.Types.Mixed, + default: {} + }, + data: { type: Buffer }, // 小文件直接存储 + gridfsId: { type: String, index: true }, // 大文件GridFS ID + status: { + type: String, + enum: ['uploading', 'completed', 'error', 'deleted'], + default: 'uploading', + index: true + }, + error: { type: String } +}); + +// OCR结果Schema +const ocrSchema = new Schema({ + fileId: { type: String, required: true, index: true }, + text: { type: String, required: true }, + confidence: { type: Number, required: true }, + language: { type: String, required: true }, + boundingBoxes: [{ + text: String, + x: Number, + y: Number, + width: Number, + height: Number, + confidence: Number + }], + metadata: { + processingTime: { type: Number, required: true }, + imageWidth: { type: Number, required: true }, + imageHeight: { type: Number, required: true }, + detectedLanguages: [String], + ocrEngine: { type: String, required: true }, + version: { type: String, required: true } + }, + processedAt: { type: Date, default: Date.now, index: true }, + status: { + type: String, + enum: ['processing', 'completed', 'error'], + default: 'processing', + index: true + }, + error: { type: String } +}); + +// 创建索引 +fileSchema.index({ filename: 1, uploadedAt: -1 }); +fileSchema.index({ mimetype: 1, size: -1 }); +fileSchema.index({ status: 1, uploadedAt: -1 }); + +ocrSchema.index({ fileId: 1, processedAt: -1 }); +ocrSchema.index({ status: 1, processedAt: -1 }); + +// 导出模型 +export const FileModel = getMongoModel('File', fileSchema); +export const OCRModel = getMongoModel('OCR', ocrSchema); diff --git a/package/file-storage/service.ts b/package/file-storage/service.ts new file mode 100644 index 0000000..d24cee4 --- /dev/null +++ b/package/file-storage/service.ts @@ -0,0 +1,418 @@ +import { GridFSBucket } from 'mongodb'; +import { Readable } from 'stream'; +import { connectionMongo } from '../mongo'; +import { FileModel, OCRModel, type FileDocument, type OCRDocument } from './models'; +import { delay } from '../utils/utils'; + +/** + * 文件存储服务 + * 支持MongoDB GridFS和直接存储 + */ + +export interface FileUploadOptions { + maxSize?: number; // 最大文件大小,默认10MB + directStorageThreshold?: number; // 直接存储阈值,默认1MB + allowedMimeTypes?: string[]; // 允许的MIME类型 + metadata?: Record; // 额外的元数据 + uploadedBy?: string; // 上传用户ID +} + +export interface FileQueryOptions { + mimetype?: string; + uploadedBy?: string; + status?: FileDocument['status']; + limit?: number; + skip?: number; + sort?: Record; +} + +export class FileStorageService { + private gridFSBucket: GridFSBucket | null = null; + private readonly DEFAULT_MAX_SIZE = 10 * 1024 * 1024; // 10MB + private readonly DIRECT_STORAGE_THRESHOLD = 1 * 1024 * 1024; // 1MB + + constructor() { + this.initGridFS(); + } + + /** + * 初始化GridFS + */ + private async initGridFS(): Promise { + try { + if (!connectionMongo.connection.readyState) { + await connectionMongo.connect(process.env.MONGODB_URI!); + } + + this.gridFSBucket = new GridFSBucket(connectionMongo.connection.db, { + bucketName: 'uploads' + }); + } catch (error) { + console.error('GridFS初始化失败:', error); + } + } + + /** + * 上传文件 + */ + async uploadFile( + file: Buffer | Uint8Array, + originalName: string, + mimetype: string, + options: FileUploadOptions = {} + ): Promise { + const { + maxSize = this.DEFAULT_MAX_SIZE, + directStorageThreshold = this.DIRECT_STORAGE_THRESHOLD, + allowedMimeTypes, + metadata = {}, + uploadedBy + } = options; + + // 验证文件大小 + if (file.length > maxSize) { + throw new Error(`文件大小超过限制 (${Math.round(maxSize / 1024 / 1024)}MB)`); + } + + // 验证MIME类型 + if (allowedMimeTypes && !allowedMimeTypes.includes(mimetype)) { + throw new Error(`不支持的文件类型: ${mimetype}`); + } + + // 生成文件名 + const timestamp = Date.now(); + const randomStr = Math.random().toString(36).substring(2, 15); + const extension = this.getFileExtension(originalName); + const filename = `${timestamp}_${randomStr}${extension}`; + + // 创建文件文档 + const fileDoc: Partial = { + filename, + originalName, + mimetype, + size: file.length, + encoding: 'buffer', + uploadedAt: new Date(), + uploadedBy, + metadata, + status: 'uploading' + }; + + try { + // 小文件直接存储在MongoDB文档中 + if (file.length <= directStorageThreshold) { + fileDoc.data = Buffer.from(file); + fileDoc.status = 'completed'; + + const savedFile = await FileModel.create(fileDoc); + return savedFile.toObject(); + } + + // 大文件使用GridFS存储 + if (!this.gridFSBucket) { + await this.initGridFS(); + } + + if (!this.gridFSBucket) { + throw new Error('GridFS未初始化'); + } + + // 先创建文档记录 + const savedFile = await FileModel.create(fileDoc); + + // 上传到GridFS + return new Promise((resolve, reject) => { + const readableStream = new Readable(); + readableStream.push(file); + readableStream.push(null); + + const uploadStream = this.gridFSBucket!.openUploadStream(filename, { + metadata: { + fileDocId: savedFile._id, + originalName, + mimetype, + uploadedBy, + ...metadata + } + }); + + uploadStream.on('error', async (error) => { + // 更新文件状态为错误 + await FileModel.findByIdAndUpdate(savedFile._id, { + status: 'error', + error: error.message + }); + reject(error); + }); + + uploadStream.on('finish', async () => { + // 更新文件文档 + const updatedFile = await FileModel.findByIdAndUpdate( + savedFile._id, + { + gridfsId: uploadStream.id.toString(), + status: 'completed' + }, + { new: true } + ); + + if (updatedFile) { + resolve(updatedFile.toObject()); + } else { + reject(new Error('文件上传完成但更新记录失败')); + } + }); + + readableStream.pipe(uploadStream); + }); + + } catch (error) { + console.error('文件上传失败:', error); + throw new Error(`文件上传失败: ${error instanceof Error ? error.message : '未知错误'}`); + } + } + + /** + * 获取文件 + */ + async getFile(fileId: string): Promise<{ file: FileDocument; data?: Buffer }> { + const file = await FileModel.findById(fileId); + + if (!file) { + throw new Error('文件不存在'); + } + + if (file.status !== 'completed') { + throw new Error(`文件状态异常: ${file.status}`); + } + + // 直接存储的文件 + if (file.data) { + return { file: file.toObject(), data: file.data }; + } + + // GridFS存储的文件 + if (file.gridfsId) { + if (!this.gridFSBucket) { + await this.initGridFS(); + } + + if (!this.gridFSBucket) { + throw new Error('GridFS未初始化'); + } + + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + const downloadStream = this.gridFSBucket!.openDownloadStream( + connectionMongo.Types.ObjectId.createFromHexString(file.gridfsId!) + ); + + downloadStream.on('data', (chunk: Buffer) => { + chunks.push(chunk); + }); + + downloadStream.on('end', () => { + const data = Buffer.concat(chunks); + resolve({ file: file.toObject(), data }); + }); + + downloadStream.on('error', (error) => { + reject(new Error(`文件下载失败: ${error.message}`)); + }); + }); + } + + throw new Error('文件数据不存在'); + } + + /** + * 删除文件 + */ + async deleteFile(fileId: string): Promise { + const file = await FileModel.findById(fileId); + + if (!file) { + return false; + } + + try { + // 删除GridFS中的文件 + if (file.gridfsId && this.gridFSBucket) { + await this.gridFSBucket.delete( + connectionMongo.Types.ObjectId.createFromHexString(file.gridfsId) + ); + } + + // 删除OCR结果 + await OCRModel.deleteMany({ fileId }); + + // 标记文件为已删除 + await FileModel.findByIdAndUpdate(fileId, { status: 'deleted' }); + + return true; + } catch (error) { + console.error('文件删除失败:', error); + return false; + } + } + + /** + * 查询文件列表 + */ + async queryFiles(options: FileQueryOptions = {}): Promise { + const { + mimetype, + uploadedBy, + status = 'completed', + limit = 50, + skip = 0, + sort = { uploadedAt: -1 } + } = options; + + const query: any = { status }; + + if (mimetype) { + query.mimetype = new RegExp(mimetype, 'i'); + } + + if (uploadedBy) { + query.uploadedBy = uploadedBy; + } + + const files = await FileModel + .find(query) + .sort(sort) + .limit(limit) + .skip(skip) + .lean(); + + return files; + } + + /** + * 保存OCR结果 + */ + async saveOCRResult( + fileId: string, + text: string, + confidence: number, + language: string, + metadata: OCRDocument['metadata'], + boundingBoxes?: OCRDocument['boundingBoxes'] + ): Promise { + const ocrDoc: Partial = { + fileId, + text, + confidence, + language, + boundingBoxes, + metadata, + processedAt: new Date(), + status: 'completed' + }; + + const savedOCR = await OCRModel.create(ocrDoc); + return savedOCR.toObject(); + } + + /** + * 获取OCR结果 + */ + async getOCRResult(fileId: string): Promise { + const ocrResult = await OCRModel.findOne({ + fileId, + status: 'completed' + }).lean(); + + return ocrResult; + } + + /** + * 获取文件统计信息 + */ + async getFileStats(): Promise<{ + totalFiles: number; + totalSize: number; + filesByType: Record; + recentUploads: number; // 最近24小时 + }> { + const [totalFiles, totalSizeResult, filesByType, recentUploads] = await Promise.all([ + FileModel.countDocuments({ status: 'completed' }), + FileModel.aggregate([ + { $match: { status: 'completed' } }, + { $group: { _id: null, totalSize: { $sum: '$size' } } } + ]), + FileModel.aggregate([ + { $match: { status: 'completed' } }, + { $group: { _id: '$mimetype', count: { $sum: 1 } } } + ]), + FileModel.countDocuments({ + status: 'completed', + uploadedAt: { $gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } + }) + ]); + + const totalSize = totalSizeResult[0]?.totalSize || 0; + const typeStats: Record = {}; + + filesByType.forEach((item: any) => { + typeStats[item._id] = item.count; + }); + + return { + totalFiles, + totalSize, + filesByType: typeStats, + recentUploads + }; + } + + /** + * 清理过期的未完成上传 + */ + async cleanupExpiredUploads(hoursOld: number = 24): Promise { + const cutoffDate = new Date(Date.now() - hoursOld * 60 * 60 * 1000); + + const expiredFiles = await FileModel.find({ + status: 'uploading', + uploadedAt: { $lt: cutoffDate } + }); + + let cleanedCount = 0; + + for (const file of expiredFiles) { + try { + await this.deleteFile(file._id!.toString()); + cleanedCount++; + } catch (error) { + console.error(`清理过期文件失败 ${file._id}:`, error); + } + } + + return cleanedCount; + } + + /** + * 获取文件扩展名 + */ + private getFileExtension(filename: string): string { + const lastDot = filename.lastIndexOf('.'); + return lastDot >= 0 ? filename.substring(lastDot) : ''; + } + + /** + * 格式化文件大小 + */ + static formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B'; + + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } +} + +// 创建单例实例 +export const fileStorageService = new FileStorageService(); diff --git a/package/file-upload/index.ts b/package/file-upload/index.ts new file mode 100644 index 0000000..723c188 --- /dev/null +++ b/package/file-upload/index.ts @@ -0,0 +1,415 @@ +/** + * 文件上传工具封装 + * 支持单文件和多文件上传,文件预览,拖拽上传等功能 + */ + +export interface FileUploadOptions { + maxFileSize?: number; // 最大文件大小(bytes),默认10MB + allowedTypes?: string[]; // 允许的文件类型,默认支持常见图片和文本格式 + multiple?: boolean; // 是否支持多文件上传,默认false + autoUpload?: boolean; // 是否自动上传,默认false + quality?: number; // 图片压缩质量(0-1),默认0.8 + maxWidth?: number; // 图片最大宽度,默认1920 + maxHeight?: number; // 图片最大高度,默认1080 +} + +export interface FileValidationResult { + isValid: boolean; + error?: string; + warnings?: string[]; +} + +export interface FilePreview { + id: string; + file: File; + name: string; + size: number; + type: string; + previewUrl?: string; // 预览URL + thumbnail?: string; // 缩略图URL + isImage: boolean; + isText: boolean; + lastModified: number; +} + +export interface UploadProgress { + fileId: string; + fileName: string; + loaded: number; + total: number; + percentage: number; + status: 'pending' | 'uploading' | 'success' | 'error'; + error?: string; +} + +export class FileUploadService { + private options: Required; + private uploadProgressCallbacks: Map void> = new Map(); + + constructor(options: FileUploadOptions = {}) { + this.options = { + maxFileSize: options.maxFileSize || 10 * 1024 * 1024, // 10MB + allowedTypes: options.allowedTypes || [ + // 图片格式 + 'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/bmp', 'image/webp', 'image/svg+xml', + // 文本格式 + 'text/plain', 'text/csv', 'text/html', 'text/xml', + // 文档格式 + 'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ], + multiple: options.multiple || false, + autoUpload: options.autoUpload || false, + quality: options.quality || 0.8, + maxWidth: options.maxWidth || 1920, + maxHeight: options.maxHeight || 1080 + }; + } + + /** + * 验证文件是否符合要求 + */ + validateFile(file: File): FileValidationResult { + const result: FileValidationResult = { isValid: true, warnings: [] }; + + // 检查文件大小 + if (file.size > this.options.maxFileSize) { + result.isValid = false; + result.error = `文件大小超过限制(${this.formatFileSize(this.options.maxFileSize)})`; + return result; + } + + // 检查文件类型 + if (this.options.allowedTypes.length > 0 && !this.options.allowedTypes.includes(file.type)) { + result.isValid = false; + result.error = `不支持的文件类型: ${file.type}`; + return result; + } + + // 添加警告信息 + if (file.size > 5 * 1024 * 1024) { // 5MB + result.warnings?.push('文件较大,处理可能需要更多时间'); + } + + return result; + } + + /** + * 批量验证文件 + */ + validateFiles(files: File[]): { validFiles: File[]; invalidFiles: Array<{ file: File; error: string }> } { + const validFiles: File[] = []; + const invalidFiles: Array<{ file: File; error: string }> = []; + + for (const file of files) { + const validation = this.validateFile(file); + if (validation.isValid) { + validFiles.push(file); + } else { + invalidFiles.push({ file, error: validation.error || '未知错误' }); + } + } + + return { validFiles, invalidFiles }; + } + + /** + * 创建文件预览 + */ + async createFilePreview(file: File): Promise { + const id = this.generateFileId(); + const isImage = file.type.startsWith('image/'); + const isText = file.type.startsWith('text/'); + + const preview: FilePreview = { + id, + file, + name: file.name, + size: file.size, + type: file.type, + isImage, + isText, + lastModified: file.lastModified + }; + + // 为图片创建预览URL + if (isImage) { + try { + preview.previewUrl = await this.createImagePreview(file); + preview.thumbnail = await this.createThumbnail(file); + } catch (error) { + console.warn('创建图片预览失败:', error); + } + } + + return preview; + } + + /** + * 批量创建文件预览 + */ + async createFilePreviews(files: File[]): Promise { + const previews: FilePreview[] = []; + + for (const file of files) { + try { + const preview = await this.createFilePreview(file); + previews.push(preview); + } catch (error) { + console.error(`创建文件预览失败: ${file.name}`, error); + } + } + + return previews; + } + + /** + * 创建图片预览URL + */ + private async createImagePreview(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(file); + }); + } + + /** + * 创建缩略图 + */ + private async createThumbnail(file: File, maxSize: number = 200): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + if (!ctx) { + reject(new Error('无法创建Canvas上下文')); + return; + } + + img.onload = () => { + // 计算缩略图尺寸 + const { width, height } = this.calculateThumbnailSize(img.width, img.height, maxSize); + + canvas.width = width; + canvas.height = height; + + // 绘制缩略图 + ctx.drawImage(img, 0, 0, width, height); + + // 转换为数据URL + resolve(canvas.toDataURL('image/jpeg', 0.7)); + }; + + img.onerror = reject; + img.src = URL.createObjectURL(file); + }); + } + + /** + * 计算缩略图尺寸 + */ + private calculateThumbnailSize(originalWidth: number, originalHeight: number, maxSize: number): { width: number; height: number } { + if (originalWidth <= maxSize && originalHeight <= maxSize) { + return { width: originalWidth, height: originalHeight }; + } + + const ratio = Math.min(maxSize / originalWidth, maxSize / originalHeight); + return { + width: Math.round(originalWidth * ratio), + height: Math.round(originalHeight * ratio) + }; + } + + /** + * 压缩图片 + */ + async compressImage(file: File): Promise { + if (!file.type.startsWith('image/')) { + return file; + } + + return new Promise((resolve, reject) => { + const img = new Image(); + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + if (!ctx) { + reject(new Error('无法创建Canvas上下文')); + return; + } + + img.onload = () => { + // 计算压缩后的尺寸 + const { width, height } = this.calculateCompressedSize(img.width, img.height); + + canvas.width = width; + canvas.height = height; + + // 绘制压缩后的图片 + ctx.drawImage(img, 0, 0, width, height); + + // 转换为Blob + canvas.toBlob( + (blob) => { + if (blob) { + const compressedFile = new File([blob], file.name, { + type: file.type, + lastModified: Date.now() + }); + resolve(compressedFile); + } else { + reject(new Error('图片压缩失败')); + } + }, + file.type, + this.options.quality + ); + }; + + img.onerror = reject; + img.src = URL.createObjectURL(file); + }); + } + + /** + * 计算压缩后的图片尺寸 + */ + private calculateCompressedSize(originalWidth: number, originalHeight: number): { width: number; height: number } { + const maxWidth = this.options.maxWidth; + const maxHeight = this.options.maxHeight; + + if (originalWidth <= maxWidth && originalHeight <= maxHeight) { + return { width: originalWidth, height: originalHeight }; + } + + const ratio = Math.min(maxWidth / originalWidth, maxHeight / originalHeight); + return { + width: Math.round(originalWidth * ratio), + height: Math.round(originalHeight * ratio) + }; + } + + /** + * 读取文本文件内容 + */ + async readTextFile(file: File): Promise { + if (!file.type.startsWith('text/') && file.type !== 'application/json') { + throw new Error('不支持的文本文件类型'); + } + + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsText(file, 'utf-8'); + }); + } + + /** + * 生成文件ID + */ + private generateFileId(): string { + return `file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + } + + /** + * 格式化文件大小 + */ + formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B'; + + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } + + /** + * 清理预览URL + */ + cleanupPreviewUrl(url: string): void { + if (url && url.startsWith('blob:')) { + URL.revokeObjectURL(url); + } + } + + /** + * 批量清理预览URL + */ + cleanupPreviewUrls(previews: FilePreview[]): void { + previews.forEach(preview => { + if (preview.previewUrl) { + this.cleanupPreviewUrl(preview.previewUrl); + } + if (preview.thumbnail) { + this.cleanupPreviewUrl(preview.thumbnail); + } + }); + } + + /** + * 检查是否支持拖拽上传 + */ + static isDragDropSupported(): boolean { + const div = document.createElement('div'); + return ('draggable' in div || ('ondragstart' in div && 'ondrop' in div)) && + 'FormData' in window && 'FileReader' in window; + } + + /** + * 从拖拽事件中提取文件 + */ + static extractFilesFromDragEvent(event: DragEvent): File[] { + const files: File[] = []; + + if (event.dataTransfer?.files) { + for (let i = 0; i < event.dataTransfer.files.length; i++) { + const file = event.dataTransfer.files[i]; + if (file) { + files.push(file); + } + } + } + + return files; + } + + /** + * 从输入元素中提取文件 + */ + static extractFilesFromInput(input: HTMLInputElement): File[] { + const files: File[] = []; + + if (input.files) { + for (let i = 0; i < input.files.length; i++) { + const file = input.files[i]; + if (file) { + files.push(file); + } + } + } + + return files; + } +} + +// 创建默认实例 +export const fileUploadService = new FileUploadService(); + +// 便捷函数 +export const validateFile = (file: File, options?: FileUploadOptions): FileValidationResult => { + const service = new FileUploadService(options); + return service.validateFile(file); +}; + +export const createFilePreview = async (file: File, options?: FileUploadOptions): Promise => { + const service = new FileUploadService(options); + return service.createFilePreview(file); +}; + +export const readTextFile = async (file: File): Promise => { + return fileUploadService.readTextFile(file); +}; diff --git a/package/ocr/index.ts b/package/ocr/index.ts new file mode 100644 index 0000000..2454e6d --- /dev/null +++ b/package/ocr/index.ts @@ -0,0 +1,237 @@ +import { createWorker, Worker, PSM, OEM } from 'tesseract.js'; + +/** + * OCR工具封装 + * 基于Tesseract.js实现图片文字识别 + */ + +export interface OCROptions { + language?: string; // 识别语言,默认为'chi_sim+eng'(简体中文+英文) + psm?: PSM; // 页面分割模式 + oem?: OEM; // OCR引擎模式 + whitelist?: string; // 字符白名单 + blacklist?: string; // 字符黑名单 +} + +export interface OCRResult { + text: string; // 识别的文本 + confidence: number; // 置信度 0-100 + words?: Array<{ + text: string; + confidence: number; + bbox: { + x0: number; + y0: number; + x1: number; + y1: number; + }; + }>; + lines?: Array<{ + text: string; + confidence: number; + bbox: { + x0: number; + y0: number; + x1: number; + y1: number; + }; + }>; + paragraphs?: Array<{ + text: string; + confidence: number; + bbox: { + x0: number; + y0: number; + x1: number; + y1: number; + }; + }>; +} + +export interface OCRProgress { + status: string; + progress: number; // 0-1 + userJobId: string; +} + +export class OCRService { + private worker: Worker | null = null; + private isInitialized = false; + + /** + * 初始化OCR Worker + */ + async initialize(options: OCROptions = {}): Promise { + if (this.isInitialized && this.worker) { + return; + } + + try { + const language = options.language || 'chi_sim+eng'; + const oem = options.oem || 1; + + // 检查是否在服务端环境 + const isServer = typeof window === 'undefined'; + + // v6 API: createWorker(language, oem, options) + this.worker = await createWorker(language, oem, { + logger: (m: any) => { + console.log(`OCR进度: ${m.status} - ${Math.round((m.progress || 0) * 100)}%`); + }, + // 服务端环境配置 + ...(isServer && { + workerPath: require.resolve('tesseract.js/dist/worker.min.js'), + corePath: require.resolve('tesseract.js/dist/tesseract-core.wasm.js'), + }) + }); + + // 设置OCR参数 + if (options.psm !== undefined) { + await this.worker.setParameters({ + tessedit_pageseg_mode: options.psm, + }); + } + + if (options.whitelist) { + await this.worker.setParameters({ + tessedit_char_whitelist: options.whitelist, + }); + } + + if (options.blacklist) { + await this.worker.setParameters({ + tessedit_char_blacklist: options.blacklist, + }); + } + + this.isInitialized = true; + } catch (error) { + console.error('OCR初始化失败:', error); + throw new Error('OCR初始化失败'); + } + } + + /** + * 识别图片中的文字 + */ + async recognize( + imageSource: string | File | Blob | HTMLImageElement | HTMLCanvasElement, + options: OCROptions = {} + ): Promise { + if (!this.isInitialized || !this.worker) { + await this.initialize(options); + } + + if (!this.worker) { + throw new Error('OCR Worker未初始化'); + } + + try { + const startTime = Date.now(); + // v6 API: 需要指定输出格式 + const result = await this.worker.recognize(imageSource, {}, { + blocks: true, // 启用详细输出 + text: true + }); + const processingTime = Date.now() - startTime; + + console.log(`OCR识别完成,耗时: ${processingTime}ms`); + + return { + text: (result.data.text || '').trim(), + confidence: result.data.confidence || 0, + words: result.data.blocks?.[0]?.paragraphs?.[0]?.lines?.[0]?.words?.map((word: any) => ({ + text: word.text, + confidence: word.confidence, + bbox: word.bbox + })), + lines: result.data.blocks?.[0]?.paragraphs?.[0]?.lines?.map((line: any) => ({ + text: line.text, + confidence: line.confidence, + bbox: line.bbox + })), + paragraphs: result.data.blocks?.[0]?.paragraphs?.map((paragraph: any) => ({ + text: paragraph.text, + confidence: paragraph.confidence, + bbox: paragraph.bbox + })) + }; + } catch (error) { + console.error('OCR识别失败:', error); + throw new Error('图片文字识别失败'); + } + } + + /** + * 识别多个图片 + */ + async recognizeMultiple( + imageSources: Array, + options: OCROptions = {} + ): Promise { + const results: OCRResult[] = []; + + for (const imageSource of imageSources) { + const result = await this.recognize(imageSource, options); + results.push(result); + } + + return results; + } + + /** + * 获取支持的语言列表 + */ + static getSupportedLanguages(): string[] { + return [ + 'afr', 'amh', 'ara', 'asm', 'aze', 'aze_cyrl', 'bel', 'ben', 'bod', 'bos', + 'bul', 'cat', 'ceb', 'ces', 'chi_sim', 'chi_tra', 'chr', 'cym', 'dan', + 'deu', 'dzo', 'ell', 'eng', 'enm', 'epo', 'est', 'eus', 'fas', 'fin', + 'fra', 'frk', 'frm', 'gle', 'glg', 'grc', 'guj', 'hat', 'heb', 'hin', + 'hrv', 'hun', 'iku', 'ind', 'isl', 'ita', 'ita_old', 'jav', 'jpn', 'kan', + 'kat', 'kat_old', 'kaz', 'khm', 'kir', 'kor', 'kur', 'lao', 'lat', 'lav', + 'lit', 'mal', 'mar', 'mkd', 'mlt', 'mon', 'mri', 'msa', 'mya', 'nep', + 'nld', 'nor', 'ori', 'pan', 'pol', 'por', 'pus', 'ron', 'rus', 'san', + 'sin', 'slk', 'slv', 'spa', 'spa_old', 'sqi', 'srp', 'srp_latn', 'swa', + 'swe', 'syr', 'tam', 'tel', 'tgk', 'tgl', 'tha', 'tir', 'tur', 'uig', + 'ukr', 'urd', 'uzb', 'uzb_cyrl', 'vie', 'yid' + ]; + } + + /** + * 销毁Worker释放资源 + */ + async terminate(): Promise { + if (this.worker) { + await this.worker.terminate(); + this.worker = null; + this.isInitialized = false; + } + } + + /** + * 检查是否已初始化 + */ + isReady(): boolean { + return this.isInitialized && this.worker !== null; + } +} + +// 创建单例实例 +export const ocrService = new OCRService(); + +// 便捷函数 +export const recognizeText = async ( + imageSource: string | File | Blob | ImageData | HTMLImageElement | HTMLCanvasElement, + options: OCROptions = {} +): Promise => { + const result = await ocrService.recognize(imageSource, options); + return result.text; +}; + +export const recognizeTextWithDetails = async ( + imageSource: string | File | Blob | ImageData | HTMLImageElement | HTMLCanvasElement, + options: OCROptions = {} +): Promise => { + return await ocrService.recognize(imageSource, options); +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe5a1e1..ff25ab5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,6 +122,9 @@ importers: lucide-react: specifier: ^0.542.0 version: 0.542.0(react@19.1.0) + mongodb: + specifier: ^6.19.0 + version: 6.19.0 mongoose: specifier: ^8.18.0 version: 8.18.0 @@ -155,6 +158,9 @@ importers: tailwind-merge: specifier: ^3.3.1 version: 3.3.1 + tesseract.js: + specifier: ^6.0.1 + version: 6.0.1 vaul: specifier: ^1.1.2 version: 1.1.2(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -322,92 +328,78 @@ packages: resolution: {integrity: sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.0': resolution: {integrity: sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.0': resolution: {integrity: sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.0': resolution: {integrity: sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.0': resolution: {integrity: sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.0': resolution: {integrity: sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.0': resolution: {integrity: sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.3': resolution: {integrity: sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.3': resolution: {integrity: sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.3': resolution: {integrity: sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.3': resolution: {integrity: sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.3': resolution: {integrity: sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.3': resolution: {integrity: sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.3': resolution: {integrity: sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.3': resolution: {integrity: sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg==} @@ -481,28 +473,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@next/swc-linux-arm64-musl@15.5.2': resolution: {integrity: sha512-s6N8k8dF9YGc5T01UPQ08yxsK6fUow5gG1/axWc1HVVBYQBgOjca4oUZF7s4p+kwhkB1bDSGR8QznWrFZ/Rt5g==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@next/swc-linux-x64-gnu@15.5.2': resolution: {integrity: sha512-o1RV/KOODQh6dM6ZRJGZbc+MOAHww33Vbs5JC9Mp1gDk8cpEO+cYC/l7rweiEalkSm5/1WGa4zY7xrNwObN4+Q==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@next/swc-linux-x64-musl@15.5.2': resolution: {integrity: sha512-/VUnh7w8RElYZ0IV83nUcP/J4KJ6LLYliiBIri3p3aW2giF+PAVgZb6mk8jbQSB3WlTai8gEmCAr7kptFa1H6g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@next/swc-win32-arm64-msvc@15.5.2': resolution: {integrity: sha512-sMPyTvRcNKXseNQ/7qRfVRLa0VhR0esmQ29DD6pqvG71+JdVnESJaHPA8t7bc67KD5spP3+DOCNLhqlEI2ZgQg==} @@ -1194,28 +1182,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.12': resolution: {integrity: sha512-V8pAM3s8gsrXcCv6kCHSuwyb/gPsd863iT+v1PGXC4fSL/OJqsKhfK//v8P+w9ThKIoqNbEnsZqNy+WDnwQqCA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.12': resolution: {integrity: sha512-xYfqYLjvm2UQ3TZggTGrwxjYaLB62b1Wiysw/YE3Yqbh86sOMoTn0feF98PonP7LtjsWOWcXEbGqDL7zv0uW8Q==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.12': resolution: {integrity: sha512-ha0pHPamN+fWZY7GCzz5rKunlv9L5R8kdh+YNvP5awe3LtuXb5nRi/H27GeL2U+TdhDOptU7T6Is7mdwh5Ar3A==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.12': resolution: {integrity: sha512-4tSyu3dW+ktzdEpuk6g49KdEangu3eCYoqPhWNsZgUhyegEda3M9rG0/j1GV/JjVVsj+lG7jWAyrTlLzd/WEBg==} @@ -1418,49 +1402,41 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -1579,6 +1555,9 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + bmp-js@0.1.0: + resolution: {integrity: sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -2136,6 +2115,9 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + idb-keyval@6.2.2: + resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -2260,6 +2242,9 @@ packages: resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} engines: {node: '>= 0.4'} + is-url@1.2.4: + resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==} + is-weakmap@2.0.2: resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} engines: {node: '>= 0.4'} @@ -2357,28 +2342,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.30.1: resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.30.1: resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.30.1: resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.30.1: resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==} @@ -2494,6 +2475,33 @@ packages: socks: optional: true + mongodb@6.19.0: + resolution: {integrity: sha512-H3GtYujOJdeKIMLKBT9PwlDhGrQfplABNF1G904w6r5ZXKWyv77aB0X9B+rhmaAwjtllHzaEkvi9mkGVZxs2Bw==} + engines: {node: '>=16.20.1'} + peerDependencies: + '@aws-sdk/credential-providers': ^3.188.0 + '@mongodb-js/zstd': ^1.1.0 || ^2.0.0 + gcp-metadata: ^5.2.0 + kerberos: ^2.0.1 + mongodb-client-encryption: '>=6.0.0 <7' + snappy: ^7.3.2 + socks: ^2.7.1 + peerDependenciesMeta: + '@aws-sdk/credential-providers': + optional: true + '@mongodb-js/zstd': + optional: true + gcp-metadata: + optional: true + kerberos: + optional: true + mongodb-client-encryption: + optional: true + snappy: + optional: true + socks: + optional: true + mongoose@8.18.0: resolution: {integrity: sha512-3TixPihQKBdyaYDeJqRjzgb86KbilEH07JmzV8SoSjgoskNTpa6oTBmDxeoF9p8YnWQoz7shnCyPkSV/48y3yw==} engines: {node: '>=16.20.1'} @@ -2549,6 +2557,15 @@ packages: sass: optional: true + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -2581,6 +2598,10 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + opencollective-postinstall@2.0.3: + resolution: {integrity: sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==} + hasBin: true + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -2741,6 +2762,9 @@ packages: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} + regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + regexp.prototype.flags@1.5.4: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} @@ -2924,6 +2948,12 @@ packages: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} + tesseract.js-core@6.0.0: + resolution: {integrity: sha512-1Qncm/9oKM7xgrQXZXNB+NRh19qiXGhxlrR8EwFbK5SaUbPZnS5OMtP/ghtqfd23hsr1ZvZbZjeuAGcMxd/ooA==} + + tesseract.js@6.0.1: + resolution: {integrity: sha512-/sPvMvrCtgxnNRCjbTYbr7BRu0yfWDsMZQ2a/T5aN/L1t8wUQN6tTWv6p6FwzpoEBA0jrN2UD2SX4QQFRdoDbA==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -2935,6 +2965,9 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@5.1.1: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} @@ -3026,6 +3059,12 @@ packages: victory-vendor@36.9.2: resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + wasm-feature-detect@1.8.0: + resolution: {integrity: sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} @@ -3034,6 +3073,9 @@ packages: resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} engines: {node: '>=18'} + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -3067,6 +3109,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zlibjs@0.3.1: + resolution: {integrity: sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==} + zod@4.1.5: resolution: {integrity: sha512-rcUUZqlLJgBC33IT3PNMgsCq6TzLQEG/Ei/KTCU0PedSWRMAXoOUN+4t/0H+Q8bdnLPdqUYnvboJT0bn/229qg==} @@ -4477,6 +4522,8 @@ snapshots: balanced-match@1.0.2: {} + bmp-js@0.1.0: {} + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -4874,7 +4921,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.34.0(jiti@2.5.1)))(eslint@9.34.0(jiti@2.5.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.34.0(jiti@2.5.1)): dependencies: debug: 3.2.7 optionalDependencies: @@ -4896,7 +4943,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.34.0(jiti@2.5.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.34.0(jiti@2.5.1)))(eslint@9.34.0(jiti@2.5.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.34.0(jiti@2.5.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -5182,6 +5229,8 @@ snapshots: dependencies: function-bind: 1.1.2 + idb-keyval@6.2.2: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -5309,6 +5358,8 @@ snapshots: dependencies: which-typed-array: 1.1.19 + is-url@1.2.4: {} + is-weakmap@2.0.2: {} is-weakref@1.1.1: @@ -5486,6 +5537,12 @@ snapshots: bson: 6.10.4 mongodb-connection-string-url: 3.0.2 + mongodb@6.19.0: + dependencies: + '@mongodb-js/saslprep': 1.3.0 + bson: 6.10.4 + mongodb-connection-string-url: 3.0.2 + mongoose@8.18.0: dependencies: bson: 6.10.4 @@ -5549,6 +5606,10 @@ snapshots: - '@babel/core' - babel-plugin-macros + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -5591,6 +5652,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + opencollective-postinstall@2.0.3: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -5757,6 +5820,8 @@ snapshots: get-proto: 1.0.1 which-builtin-type: 1.2.1 + regenerator-runtime@0.13.11: {} + regexp.prototype.flags@1.5.4: dependencies: call-bind: 1.0.8 @@ -6004,6 +6069,22 @@ snapshots: mkdirp: 3.0.1 yallist: 5.0.0 + tesseract.js-core@6.0.0: {} + + tesseract.js@6.0.1: + dependencies: + bmp-js: 0.1.0 + idb-keyval: 6.2.2 + is-url: 1.2.4 + node-fetch: 2.7.0 + opencollective-postinstall: 2.0.3 + regenerator-runtime: 0.13.11 + tesseract.js-core: 6.0.0 + wasm-feature-detect: 1.8.0 + zlibjs: 0.3.1 + transitivePeerDependencies: + - encoding + tiny-invariant@1.3.3: {} tinyglobby@0.2.14: @@ -6015,6 +6096,8 @@ snapshots: dependencies: is-number: 7.0.0 + tr46@0.0.3: {} + tr46@5.1.1: dependencies: punycode: 2.3.1 @@ -6155,6 +6238,10 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 + wasm-feature-detect@1.8.0: {} + + webidl-conversions@3.0.1: {} + webidl-conversions@7.0.0: {} whatwg-url@14.2.0: @@ -6162,6 +6249,11 @@ snapshots: tr46: 5.1.1 webidl-conversions: 7.0.0 + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -6213,6 +6305,8 @@ snapshots: yocto-queue@0.1.0: {} + zlibjs@0.3.1: {} + zod@4.1.5: {} zustand@4.5.7(@types/react@19.1.12)(react@19.1.0): From 027bb776f820db515f809916d2b366d72d930b77 Mon Sep 17 00:00:00 2001 From: NeverlandYao <865373013@qq.com> Date: Mon, 8 Sep 2025 15:29:37 +0800 Subject: [PATCH 2/2] Fix fragment title overflow issues in different layouts --- components/fragment-card.tsx | 10 +++++----- components/fragment-detail.tsx | 2 +- components/fragment-flow/fragment-node.tsx | 6 +++--- lib/types.ts | 21 +-------------------- 4 files changed, 10 insertions(+), 29 deletions(-) diff --git a/components/fragment-card.tsx b/components/fragment-card.tsx index b2db241..b94ac2a 100644 --- a/components/fragment-card.tsx +++ b/components/fragment-card.tsx @@ -47,8 +47,8 @@ const statusLabels = { } export function FragmentCard({ fragment, onEdit, onDelete, onView }: FragmentCardProps) { - const { getCategoryById } = useCategoryStore() - const category = fragment.categoryId ? getCategoryById(fragment.categoryId) : null + const { categories } = useCategoryStore() + const category = fragment.category ? categories.find(c => c.name === fragment.category) : null const truncatedContent = fragment.content.length > 150 ? fragment.content.substring(0, 150) + "..." @@ -58,8 +58,8 @@ export function FragmentCard({ fragment, onEdit, onDelete, onView }: FragmentCar
-
- +
+ {fragment.title} @@ -82,7 +82,7 @@ export function FragmentCard({ fragment, onEdit, onDelete, onView }: FragmentCar diff --git a/components/fragment-detail.tsx b/components/fragment-detail.tsx index eba2257..2a061e6 100644 --- a/components/fragment-detail.tsx +++ b/components/fragment-detail.tsx @@ -148,7 +148,7 @@ export function FragmentDetail({ fragment, isOpen, onClose, onSave, readOnly = f placeholder="输入碎片标题" /> ) : ( -
{fragment.title}
+
{fragment.title}
)}
diff --git a/components/fragment-flow/fragment-node.tsx b/components/fragment-flow/fragment-node.tsx index 1866e55..83bfd60 100644 --- a/components/fragment-flow/fragment-node.tsx +++ b/components/fragment-flow/fragment-node.tsx @@ -95,7 +95,7 @@ export function FragmentNode({ data }: FragmentNodeProps) {
-
+
{fragment.title.substring(0, 6)}
@@ -159,13 +159,13 @@ export function FragmentNode({ data }: FragmentNodeProps) {
- + {fragment.title}