diff --git a/backend/src/controllers/image.controller.ts b/backend/src/controllers/image.controller.ts index a68cb39..0c8dce5 100644 --- a/backend/src/controllers/image.controller.ts +++ b/backend/src/controllers/image.controller.ts @@ -26,6 +26,7 @@ export class ImageController { path: file?.path, size: file?.size, mimetype: file?.mimetype, + document_id, }); if (!file) { @@ -36,12 +37,15 @@ export class ImageController { return; } + // 处理 document_id:空字符串转换为 null + const processedDocumentId = document_id && document_id.trim() !== '' ? document_id : null; + const image = await ImageService.create({ user_id: userId, file_path: `/uploads/${file.filename}`, file_size: file.size, mime_type: file.mimetype, - document_id, + document_id: processedDocumentId, }); // 触发异步 OCR 处理(不等待完成) @@ -190,6 +194,76 @@ export class ImageController { } } + /** + * Convert image to todo + * POST /api/images/:id/convert-to-todo + */ + static async convertToTodo(req: Request, res: Response): Promise { + try { + const userId = req.user!.user_id; + const { id } = req.params as { id: string }; + const { title, description, priority } = req.body; + + // 获取图片信息 + const image = await ImageService.findById(id, userId); + if (!image) { + res.status(404).json({ + success: false, + error: '图片不存在', + }); + return; + } + + // 检查是否有 OCR 结果 + if (!image.ocr_result || image.processing_status !== 'completed') { + res.status(400).json({ + success: false, + error: '图片尚未完成 OCR 识别,无法转换为待办', + }); + return; + } + + // 动态导入服务避免循环依赖 + const { TodoService } = await import('../services/todo.service'); + const { DocumentService } = await import('../services/document.service'); + + // 如果图片还没有关联文档,先创建文档 + let documentId = image.document_id; + if (!documentId) { + const doc = await DocumentService.create({ + user_id: userId, + title: title || `从图片提取: ${image.file_path.split('/').pop()}`, + content: image.ocr_result, + }); + documentId = doc.id; + + // 将图片关联到文档 + await ImageService.linkToDocument(id, userId, documentId); + } + + // 从文档创建待办事项 + const todo = await TodoService.create({ + user_id: userId, + title: title || `待办: ${image.ocr_result.slice(0, 50)}${image.ocr_result.length > 50 ? '...' : ''}`, + description: description || image.ocr_result, + priority: priority || 'medium', + document_id: documentId, + }); + + res.status(201).json({ + success: true, + data: todo, + message: '已将 OCR 结果转换为待办事项', + }); + } catch (error) { + const message = error instanceof Error ? error.message : '转换失败'; + res.status(400).json({ + success: false, + error: message, + }); + } + } + /** * Delete image * DELETE /api/images/:id diff --git a/backend/src/routes/image.routes.ts b/backend/src/routes/image.routes.ts index 7780bef..1591b15 100644 --- a/backend/src/routes/image.routes.ts +++ b/backend/src/routes/image.routes.ts @@ -137,6 +137,13 @@ router.put('/:id/ocr', authenticate, ImageController.updateOCR); */ router.put('/:id/link', authenticate, ImageController.linkToDocument); +/** + * @route POST /api/images/:id/convert-to-todo + * @desc Convert image OCR result to todo + * @access Private + */ +router.post('/:id/convert-to-todo', authenticate, ImageController.convertToTodo); + /** * @route DELETE /api/images/:id * @desc Delete image diff --git a/backend/src/services/ocr-providers/paddleocr.provider.ts b/backend/src/services/ocr-providers/paddleocr.provider.ts index 344fe76..51b9d2b 100644 --- a/backend/src/services/ocr-providers/paddleocr.provider.ts +++ b/backend/src/services/ocr-providers/paddleocr.provider.ts @@ -32,7 +32,7 @@ export class PaddleOCRProvider extends BaseOCRProvider { constructor(config: OCRProviderConfig & { apiUrl?: string } = {}) { super(config); - this.apiUrl = config.apiUrl || process.env.PADDLEOCR_API_URL || 'http://localhost:8866'; + this.apiUrl = config.apiUrl || process.env.PADDLEOCR_API_URL || 'http://localhost:13059'; } getName(): string { @@ -45,15 +45,15 @@ export class PaddleOCRProvider extends BaseOCRProvider { /** * 检查 PaddleOCR 服务是否可用 - * PaddleOCR 通过 Docker Compose 运行,默认假设可用 */ async isAvailable(): Promise { try { const response = await fetch(`${this.apiUrl}/`, { signal: AbortSignal.timeout(3000) }); - return response.status === 200; + // PaddleOCR 服务正常时返回 200 + return response.ok || response.status === 200; } catch { - // 即使健康检查失败,也返回 true(因为服务在 Docker 网络中运行) - return true; + // 服务不可用 + return false; } } diff --git a/backend/src/services/ocr-providers/rapidocr.provider.ts b/backend/src/services/ocr-providers/rapidocr.provider.ts index 26325cc..8fe67fa 100644 --- a/backend/src/services/ocr-providers/rapidocr.provider.ts +++ b/backend/src/services/ocr-providers/rapidocr.provider.ts @@ -24,21 +24,12 @@ interface RapidOCRResponse { }>; } -interface RapidOCRRequest { - images: string[]; - options?: { - use_dilation?: boolean; - use_cls?: boolean; - use_tensorrt?: boolean; - }; -} - export class RapidOCRProvider extends BaseOCRProvider { private apiUrl: string; constructor(config: OCRProviderConfig & { apiUrl?: string } = {}) { super(config); - this.apiUrl = config.apiUrl || process.env.RAPIDOCR_API_URL || 'http://localhost:8080'; + this.apiUrl = config.apiUrl || process.env.RAPIDOCR_API_URL || 'http://localhost:13058'; } getName(): string { @@ -73,21 +64,18 @@ export class RapidOCRProvider extends BaseOCRProvider { ): Promise { const startTime = Date.now(); - // 获取图片 Base64 - const imageBase64 = await this.getImageBase64(source); + // 获取图片 Buffer + const imageBuffer = await this.getImageBuffer(source); + + // 使用 FormData 发送图片 + const formData = new FormData(); + formData.append('file', new Blob([imageBuffer]), 'image.png'); // 调用 RapidOCR API const response = await this.withTimeout( fetch(`${this.apiUrl}/ocr`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - images: [imageBase64], - options: { - use_dilation: true, // 使用膨胀增强识别 - use_cls: true, // 使用文字方向分类 - }, - } as RapidOCRRequest), + body: formData, }), options?.timeout || this.config.timeout || 15000 ); @@ -95,12 +83,12 @@ export class RapidOCRProvider extends BaseOCRProvider { const data = (await response.json()) as RapidOCRResponse; const duration = Date.now() - startTime; - // 检查错误(支持两种错误格式) - if (data.code !== 200 && 'error' in data) { - throw new Error(`RapidOCR 错误: ${(data as any).error || data.msg} (${data.code})`); + // 检查错误 + if (data.code !== 200) { + throw new Error(`RapidOCR 错误: ${data.msg} (${data.code})`); } - // 提取文本和置信度(确保 data.data 存在) + // 提取文本和置信度 const ocrResults = Array.isArray(data.data) ? data.data : []; const text = ocrResults.map((r) => r.text).join('\n'); @@ -129,23 +117,23 @@ export class RapidOCRProvider extends BaseOCRProvider { } /** - * 获取图片 Base64 + * 获取图片 Buffer */ - private async getImageBase64(source: IImageSource): Promise { - if (source.base64) { - // 移除 data URL 前缀 - return source.base64.replace(/^data:image\/\w+;base64,/, ''); - } - + private async getImageBuffer(source: IImageSource): Promise { if (source.buffer) { - return source.buffer.toString('base64'); + return source.buffer; } if (source.path) { // 使用基类的路径解析方法 const fullPath = this.resolveImagePath(source.path); - const buffer = fs.readFileSync(fullPath); - return buffer.toString('base64'); + return fs.readFileSync(fullPath); + } + + if (source.base64) { + // 移除 data URL 前缀并转换为 Buffer + const base64 = source.base64.replace(/^data:image\/\w+;base64,/, ''); + return Buffer.from(base64, 'base64'); } throw new Error('无效的图片来源'); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e63f138..ac41d3d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import LoginPage from './pages/LoginPage'; import RegisterPage from './pages/RegisterPage'; import DashboardPage from './pages/DashboardPage'; import DocumentsPage from './pages/DocumentsPage'; +import DocumentDetailPage from './pages/DocumentDetailPage'; import TodosPage from './pages/TodosPage'; import ImagesPage from './pages/ImagesPage'; import SettingsPage from './pages/SettingsPage'; @@ -47,6 +48,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/hooks/useImages.ts b/frontend/src/hooks/useImages.ts index cb81499..693dfe9 100644 --- a/frontend/src/hooks/useImages.ts +++ b/frontend/src/hooks/useImages.ts @@ -101,3 +101,16 @@ export function useOCRProviders() { staleTime: 5 * 60 * 1000, // 5 分钟内不重新获取 }); } + +export function useConvertImageToTodo() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, options }: { id: string; options?: { title?: string; description?: string; priority?: string } }) => + ImageService.convertToTodo(id, options), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['images'] }); + queryClient.invalidateQueries({ queryKey: ['todos'] }); + }, + }); +} diff --git a/frontend/src/pages/DocumentDetailPage.tsx b/frontend/src/pages/DocumentDetailPage.tsx new file mode 100644 index 0000000..d7069df --- /dev/null +++ b/frontend/src/pages/DocumentDetailPage.tsx @@ -0,0 +1,226 @@ +import { useParams, useNavigate, Link } from 'react-router-dom'; +import { useDocument, useDocumentAnalysis, useAnalyzeDocument } from '@/hooks/useDocuments'; +import { Button } from '@/components/Button'; +import { Card } from '@/components/Card'; +import { ArrowLeft, FileText, Sparkles, Tag, FolderOpen, Trash2, Edit2, CheckSquare } from 'lucide-react'; +import { useState, useEffect } from 'react'; + +export default function DocumentDetailPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { data: document, isLoading: docLoading, error: docError } = useDocument(id || ''); + const { data: analysis, isLoading: analysisLoading } = useDocumentAnalysis(id || ''); + const analyzeMutation = useAnalyzeDocument(); + const [isEditing, setIsEditing] = useState(false); + const [editedTitle, setEditedTitle] = useState(''); + const [editedContent, setEditedContent] = useState(''); + + useEffect(() => { + if (document) { + setEditedTitle(document.title || ''); + setEditedContent(document.content); + } + }, [document]); + + const handleAnalyze = async () => { + if (!id) return; + try { + await analyzeMutation.mutateAsync({ id }); + } catch (err: any) { + alert(err.message || 'AI 分析失败'); + } + }; + + const handleSave = () => { + // TODO: 实现保存功能 + setIsEditing(false); + }; + + if (docLoading) { + return ( +
+
加载中...
+
+ ); + } + + if (docError || !document) { + return ( +
+ +
+

文档不存在或无权访问

+ + + +
+
+
+ ); + } + + return ( +
+ {/* 头部导航 */} +
+ + + +
+

文档详情

+
+
+ + {/* 文档内容卡片 */} + +
+ {/* 标题和操作栏 */} +
+ {isEditing ? ( + setEditedTitle(e.target.value)} + className="flex-1 mr-4 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" + placeholder="文档标题" + /> + ) : ( +

+ {document.title || '无标题'} +

+ )} +
+ {isEditing ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+ + {/* 文档内容 */} + {isEditing ? ( +