feat: 添加图片OCR结果转换为待办事项的功能

- 后端新增 `/images/:id/convert-to-todo` 路由及控制器方法,支持将图片OCR结果创建为待办事项
- 前端添加 `useConvertImageToTodo` hook 和 `ImageService.convertToTodo` 方法
- 在图片列表页面为已识别图片添加“转为待办”操作按钮
- 新增文档详情页面路由及基础UI
- 修复PaddleOCR和RapidOCR提供商的配置及可用性检查逻辑
- 优化图片上传时对document_id的处理(空字符串转为null)
This commit is contained in:
congsh
2026-02-27 23:22:29 +08:00
parent ecf3999d2a
commit 762cc41c5a
9 changed files with 445 additions and 52 deletions

View File

@@ -26,6 +26,7 @@ export class ImageController {
path: file?.path, path: file?.path,
size: file?.size, size: file?.size,
mimetype: file?.mimetype, mimetype: file?.mimetype,
document_id,
}); });
if (!file) { if (!file) {
@@ -36,12 +37,15 @@ export class ImageController {
return; return;
} }
// 处理 document_id空字符串转换为 null
const processedDocumentId = document_id && document_id.trim() !== '' ? document_id : null;
const image = await ImageService.create({ const image = await ImageService.create({
user_id: userId, user_id: userId,
file_path: `/uploads/${file.filename}`, file_path: `/uploads/${file.filename}`,
file_size: file.size, file_size: file.size,
mime_type: file.mimetype, mime_type: file.mimetype,
document_id, document_id: processedDocumentId,
}); });
// 触发异步 OCR 处理(不等待完成) // 触发异步 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<void> {
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 image
* DELETE /api/images/:id * DELETE /api/images/:id

View File

@@ -137,6 +137,13 @@ router.put('/:id/ocr', authenticate, ImageController.updateOCR);
*/ */
router.put('/:id/link', authenticate, ImageController.linkToDocument); 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 * @route DELETE /api/images/:id
* @desc Delete image * @desc Delete image

View File

@@ -32,7 +32,7 @@ export class PaddleOCRProvider extends BaseOCRProvider {
constructor(config: OCRProviderConfig & { apiUrl?: string } = {}) { constructor(config: OCRProviderConfig & { apiUrl?: string } = {}) {
super(config); 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 { getName(): string {
@@ -45,15 +45,15 @@ export class PaddleOCRProvider extends BaseOCRProvider {
/** /**
* 检查 PaddleOCR 服务是否可用 * 检查 PaddleOCR 服务是否可用
* PaddleOCR 通过 Docker Compose 运行,默认假设可用
*/ */
async isAvailable(): Promise<boolean> { async isAvailable(): Promise<boolean> {
try { try {
const response = await fetch(`${this.apiUrl}/`, { signal: AbortSignal.timeout(3000) }); const response = await fetch(`${this.apiUrl}/`, { signal: AbortSignal.timeout(3000) });
return response.status === 200; // PaddleOCR 服务正常时返回 200
return response.ok || response.status === 200;
} catch { } catch {
// 即使健康检查失败,也返回 true因为服务在 Docker 网络中运行) // 服务不可用
return true; return false;
} }
} }

View File

@@ -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 { export class RapidOCRProvider extends BaseOCRProvider {
private apiUrl: string; private apiUrl: string;
constructor(config: OCRProviderConfig & { apiUrl?: string } = {}) { constructor(config: OCRProviderConfig & { apiUrl?: string } = {}) {
super(config); 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 { getName(): string {
@@ -73,21 +64,18 @@ export class RapidOCRProvider extends BaseOCRProvider {
): Promise<OCRRecognitionResult> { ): Promise<OCRRecognitionResult> {
const startTime = Date.now(); const startTime = Date.now();
// 获取图片 Base64 // 获取图片 Buffer
const imageBase64 = await this.getImageBase64(source); const imageBuffer = await this.getImageBuffer(source);
// 使用 FormData 发送图片
const formData = new FormData();
formData.append('file', new Blob([imageBuffer]), 'image.png');
// 调用 RapidOCR API // 调用 RapidOCR API
const response = await this.withTimeout( const response = await this.withTimeout(
fetch(`${this.apiUrl}/ocr`, { fetch(`${this.apiUrl}/ocr`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, body: formData,
body: JSON.stringify({
images: [imageBase64],
options: {
use_dilation: true, // 使用膨胀增强识别
use_cls: true, // 使用文字方向分类
},
} as RapidOCRRequest),
}), }),
options?.timeout || this.config.timeout || 15000 options?.timeout || this.config.timeout || 15000
); );
@@ -95,12 +83,12 @@ export class RapidOCRProvider extends BaseOCRProvider {
const data = (await response.json()) as RapidOCRResponse; const data = (await response.json()) as RapidOCRResponse;
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
// 检查错误(支持两种错误格式) // 检查错误
if (data.code !== 200 && 'error' in data) { if (data.code !== 200) {
throw new Error(`RapidOCR 错误: ${(data as any).error || data.msg} (${data.code})`); throw new Error(`RapidOCR 错误: ${data.msg} (${data.code})`);
} }
// 提取文本和置信度(确保 data.data 存在) // 提取文本和置信度
const ocrResults = Array.isArray(data.data) ? data.data : []; const ocrResults = Array.isArray(data.data) ? data.data : [];
const text = ocrResults.map((r) => r.text).join('\n'); 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<string> { private async getImageBuffer(source: IImageSource): Promise<Buffer> {
if (source.base64) {
// 移除 data URL 前缀
return source.base64.replace(/^data:image\/\w+;base64,/, '');
}
if (source.buffer) { if (source.buffer) {
return source.buffer.toString('base64'); return source.buffer;
} }
if (source.path) { if (source.path) {
// 使用基类的路径解析方法 // 使用基类的路径解析方法
const fullPath = this.resolveImagePath(source.path); const fullPath = this.resolveImagePath(source.path);
const buffer = fs.readFileSync(fullPath); return fs.readFileSync(fullPath);
return buffer.toString('base64'); }
if (source.base64) {
// 移除 data URL 前缀并转换为 Buffer
const base64 = source.base64.replace(/^data:image\/\w+;base64,/, '');
return Buffer.from(base64, 'base64');
} }
throw new Error('无效的图片来源'); throw new Error('无效的图片来源');

View File

@@ -5,6 +5,7 @@ import LoginPage from './pages/LoginPage';
import RegisterPage from './pages/RegisterPage'; import RegisterPage from './pages/RegisterPage';
import DashboardPage from './pages/DashboardPage'; import DashboardPage from './pages/DashboardPage';
import DocumentsPage from './pages/DocumentsPage'; import DocumentsPage from './pages/DocumentsPage';
import DocumentDetailPage from './pages/DocumentDetailPage';
import TodosPage from './pages/TodosPage'; import TodosPage from './pages/TodosPage';
import ImagesPage from './pages/ImagesPage'; import ImagesPage from './pages/ImagesPage';
import SettingsPage from './pages/SettingsPage'; import SettingsPage from './pages/SettingsPage';
@@ -47,6 +48,7 @@ function App() {
<Route index element={<Navigate to="/dashboard" replace />} /> <Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<DashboardPage />} /> <Route path="dashboard" element={<DashboardPage />} />
<Route path="documents" element={<DocumentsPage />} /> <Route path="documents" element={<DocumentsPage />} />
<Route path="documents/:id" element={<DocumentDetailPage />} />
<Route path="todos" element={<TodosPage />} /> <Route path="todos" element={<TodosPage />} />
<Route path="images" element={<ImagesPage />} /> <Route path="images" element={<ImagesPage />} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />

View File

@@ -101,3 +101,16 @@ export function useOCRProviders() {
staleTime: 5 * 60 * 1000, // 5 分钟内不重新获取 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'] });
},
});
}

View File

@@ -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 (
<div className="flex items-center justify-center min-h-screen">
<div className="text-gray-600">...</div>
</div>
);
}
if (docError || !document) {
return (
<div className="flex items-center justify-center min-h-screen">
<Card variant="bordered" className="max-w-md">
<div className="text-center py-8">
<p className="text-red-600 mb-4">访</p>
<Link to="/documents">
<Button></Button>
</Link>
</div>
</Card>
</div>
);
}
return (
<div className="max-w-4xl mx-auto space-y-6">
{/* 头部导航 */}
<div className="flex items-center gap-4">
<Link to="/documents">
<button className="p-2 hover:bg-gray-100 rounded-lg">
<ArrowLeft className="h-5 w-5 text-gray-600" />
</button>
</Link>
<div className="flex-1">
<h1 className="text-2xl font-bold text-gray-900"></h1>
</div>
</div>
{/* 文档内容卡片 */}
<Card variant="bordered">
<div className="space-y-4">
{/* 标题和操作栏 */}
<div className="flex items-start justify-between">
{isEditing ? (
<input
type="text"
value={editedTitle}
onChange={(e) => 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="文档标题"
/>
) : (
<h2 className="flex-1 text-xl font-semibold text-gray-900">
{document.title || '无标题'}
</h2>
)}
<div className="flex gap-2">
{isEditing ? (
<>
<Button size="sm" onClick={handleSave}>
</Button>
<Button size="sm" variant="secondary" onClick={() => setIsEditing(false)}>
</Button>
</>
) : (
<>
<button
onClick={() => setIsEditing(true)}
className="p-2 hover:bg-gray-100 rounded-lg"
title="编辑"
>
<Edit2 className="h-4 w-4 text-gray-600" />
</button>
<button
onClick={handleAnalyze}
disabled={analyzeMutation.isPending || analysisLoading}
className="p-2 hover:bg-purple-100 rounded-lg disabled:opacity-50"
title="AI 分析"
>
<Sparkles className={`h-4 w-4 ${analyzeMutation.isPending ? 'animate-pulse' : ''} text-purple-600`} />
</button>
</>
)}
</div>
</div>
{/* 文档内容 */}
{isEditing ? (
<textarea
value={editedContent}
onChange={(e) => setEditedContent(e.target.value)}
className="w-full min-h-[300px] px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
placeholder="文档内容"
/>
) : (
<div className="p-4 bg-gray-50 rounded-lg">
<p className="whitespace-pre-wrap text-sm text-gray-700">{document.content}</p>
</div>
)}
{/* 元数据 */}
<div className="flex items-center gap-4 text-xs text-gray-500">
<span>: {new Date(document.created_at).toLocaleString()}</span>
<span>: {new Date(document.updated_at).toLocaleString()}</span>
</div>
</div>
</Card>
{/* AI 分析结果 */}
{(analysis || analyzeMutation.isPending || analysisLoading) && (
<Card title="AI 分析结果" variant="bordered">
{analyzeMutation.isPending || analysisLoading ? (
<div className="flex items-center justify-center py-8">
<Sparkles className="h-6 w-6 text-purple-600 animate-pulse mr-2" />
<span className="text-gray-600">AI ...</span>
</div>
) : analysis ? (
<div className="space-y-4">
{/* 标签 */}
{analysis.suggested_tags && analysis.suggested_tags.length > 0 && (
<div className="flex items-start gap-2">
<Tag className="h-4 w-4 text-gray-500 mt-0.5" />
<div className="flex flex-wrap gap-2">
{analysis.suggested_tags.map((tag, idx) => (
<span
key={idx}
className="inline-flex items-center rounded-full bg-blue-100 px-3 py-1 text-sm font-medium text-blue-800"
>
{tag}
</span>
))}
</div>
</div>
)}
{/* 分类建议 */}
{analysis.suggested_category && (
<div className="flex items-start gap-2">
<FolderOpen className="h-4 w-4 text-gray-500 mt-0.5" />
<span className="text-gray-700">
:{' '}
<span className="font-medium text-gray-900">{analysis.suggested_category}</span>
</span>
</div>
)}
{/* 摘要 */}
{analysis.summary && (
<div className="bg-purple-50 rounded-lg p-4">
<div className="flex items-start gap-2 mb-2">
<Sparkles className="h-4 w-4 text-purple-600 mt-0.5" />
<span className="font-medium text-gray-900"></span>
</div>
<p className="text-sm text-gray-700 ml-6">{analysis.summary}</p>
</div>
)}
{/* 提供商信息 */}
<p className="text-xs text-gray-500">
{analysis.provider} ({analysis.model})
</p>
</div>
) : null}
</Card>
)}
{/* 转为待办 */}
<Card variant="bordered">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<CheckSquare className="h-5 w-5 text-green-600" />
<div>
<h3 className="font-medium text-gray-900"></h3>
<p className="text-xs text-gray-500"></p>
</div>
</div>
<Button
onClick={() => navigate(`/todos?createFromDocument=${id}`)}
variant="secondary"
>
</Button>
</div>
</Card>
</div>
);
}

View File

@@ -5,12 +5,15 @@ import {
useUploadImageFile, useUploadImageFile,
useReprocessImage, useReprocessImage,
useOCRProviders, useOCRProviders,
useConvertImageToTodo,
} from '@/hooks/useImages'; } from '@/hooks/useImages';
import { useCreateDocument } from '@/hooks/useDocuments';
import { Button } from '@/components/Button'; import { Button } from '@/components/Button';
import { Card } from '@/components/Card'; import { Card } from '@/components/Card';
import { Upload, Camera, FileText, CheckSquare, X, RefreshCw, ChevronDown, Settings } from 'lucide-react'; import { Upload, Camera, FileText, CheckSquare, X, RefreshCw, ChevronDown, Settings, Sparkles } from 'lucide-react';
import type { Image } from '@/types'; import type { Image } from '@/types';
import { useDeleteImage } from '@/hooks/useImages'; import { useDeleteImage } from '@/hooks/useImages';
import { useNavigate } from 'react-router';
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'; const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
@@ -39,12 +42,15 @@ const PROVIDER_DESCRIPTIONS: Record<string, string> = {
}; };
export default function ImagesPage() { export default function ImagesPage() {
const navigate = useNavigate();
const { data: images, refetch } = useImages(); const { data: images, refetch } = useImages();
const { data: pendingImages } = usePendingImages(); const { data: pendingImages } = usePendingImages();
const { data: providers } = useOCRProviders(); const { data: providers } = useOCRProviders();
const uploadMutation = useUploadImageFile(); const uploadMutation = useUploadImageFile();
const deleteMutation = useDeleteImage(); const deleteMutation = useDeleteImage();
const reprocessMutation = useReprocessImage(); const reprocessMutation = useReprocessImage();
const convertMutation = useConvertImageToTodo();
const createDocumentMutation = useCreateDocument();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// Provider 选择状态 // Provider 选择状态
@@ -167,6 +173,45 @@ export default function ImagesPage() {
} }
}; };
const handleCreateDocument = async (image: Image, e?: React.MouseEvent) => {
e?.stopPropagation();
if (!image.ocr_result) {
alert('OCR 识别尚未完成,无法创建文档');
return;
}
try {
const doc = await createDocumentMutation.mutateAsync({
title: `从图片提取: ${image.file_path.split('/').pop()}`,
content: image.ocr_result,
});
// 将图片关联到文档(需要调用后端 API
await fetch(`${API_BASE_URL}/images/${image.id}/link`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
},
body: JSON.stringify({ document_id: doc.id }),
});
refetch();
alert('已创建文档!');
} catch (err: any) {
alert(err.message || '创建文档失败');
}
};
const handleConvertToTodo = async (imageId: string, e?: React.MouseEvent) => {
e?.stopPropagation();
try {
const todo = await convertMutation.mutateAsync({ id: imageId });
alert('已成功转换为待办事项!');
// 可选:跳转到待办页面
// navigate('/todos');
} catch (err: any) {
alert(err.message || '转换为待办失败');
}
};
const getStatusLabel = (status: string) => { const getStatusLabel = (status: string) => {
switch (status) { switch (status) {
case 'completed': case 'completed':
@@ -447,17 +492,38 @@ export default function ImagesPage() {
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<ReprocessButton image={image} /> <ReprocessButton image={image} />
{image.processing_status === 'completed' && image.ocr_result ? (
<>
{image.document_id ? ( {image.document_id ? (
<button className="flex items-center rounded bg-blue-50 px-2 py-1 text-xs text-blue-600 hover:bg-blue-100"> <>
<button
onClick={() => navigate(`/documents/${image.document_id}`)}
className="flex items-center rounded bg-blue-50 px-2 py-1 text-xs text-blue-600 hover:bg-blue-100"
>
<FileText className="mr-1 h-3 w-3" /> <FileText className="mr-1 h-3 w-3" />
</button> </button>
) : ( <button
<button className="flex items-center rounded border border-gray-300 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50"> onClick={(e) => handleConvertToTodo(image.id, e)}
disabled={convertMutation.isPending}
className="flex items-center rounded border border-gray-300 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-50"
>
<CheckSquare className="mr-1 h-3 w-3" /> <CheckSquare className="mr-1 h-3 w-3" />
{convertMutation.isPending ? '转换中...' : '转为待办'}
</button>
</>
) : (
<button
onClick={(e) => handleCreateDocument(image, e)}
disabled={createDocumentMutation.isPending}
className="flex items-center rounded bg-green-50 px-2 py-1 text-xs text-green-600 hover:bg-green-100 disabled:opacity-50"
>
<FileText className="mr-1 h-3 w-3" />
{createDocumentMutation.isPending ? '创建中...' : '创建文档'}
</button> </button>
)} )}
</>
) : null}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -132,6 +132,23 @@ class ImageServiceClass {
return []; return [];
} }
} }
/**
* 将图片 OCR 结果转换为待办事项
* @param id 图片 ID
* @param options 转换选项
*/
async convertToTodo(id: string, options?: { title?: string; description?: string; priority?: string }): Promise<any> {
try {
const response = await apiClient.post<{ success: boolean; data: any }>(`/images/${id}/convert-to-todo`, options || {});
if (response.data.success && response.data.data) {
return response.data.data;
}
throw new Error('转换失败');
} catch (error: any) {
throw new Error(error.response?.data?.error || '转换为待办失败');
}
}
} }
export const ImageService = new ImageServiceClass(); export const ImageService = new ImageServiceClass();