feat: 添加图片OCR结果转换为待办事项的功能
- 后端新增 `/images/:id/convert-to-todo` 路由及控制器方法,支持将图片OCR结果创建为待办事项 - 前端添加 `useConvertImageToTodo` hook 和 `ImageService.convertToTodo` 方法 - 在图片列表页面为已识别图片添加“转为待办”操作按钮 - 新增文档详情页面路由及基础UI - 修复PaddleOCR和RapidOCR提供商的配置及可用性检查逻辑 - 优化图片上传时对document_id的处理(空字符串转为null)
This commit is contained in:
@@ -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() {
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
<Route path="documents" element={<DocumentsPage />} />
|
||||
<Route path="documents/:id" element={<DocumentDetailPage />} />
|
||||
<Route path="todos" element={<TodosPage />} />
|
||||
<Route path="images" element={<ImagesPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -5,12 +5,15 @@ import {
|
||||
useUploadImageFile,
|
||||
useReprocessImage,
|
||||
useOCRProviders,
|
||||
useConvertImageToTodo,
|
||||
} from '@/hooks/useImages';
|
||||
import { useCreateDocument } from '@/hooks/useDocuments';
|
||||
import { Button } from '@/components/Button';
|
||||
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 { useDeleteImage } from '@/hooks/useImages';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
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() {
|
||||
const navigate = useNavigate();
|
||||
const { data: images, refetch } = useImages();
|
||||
const { data: pendingImages } = usePendingImages();
|
||||
const { data: providers } = useOCRProviders();
|
||||
const uploadMutation = useUploadImageFile();
|
||||
const deleteMutation = useDeleteImage();
|
||||
const reprocessMutation = useReprocessImage();
|
||||
const convertMutation = useConvertImageToTodo();
|
||||
const createDocumentMutation = useCreateDocument();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 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) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
@@ -447,17 +492,38 @@ export default function ImagesPage() {
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<ReprocessButton image={image} />
|
||||
|
||||
{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">
|
||||
<FileText className="mr-1 h-3 w-3" />
|
||||
查看文档
|
||||
</button>
|
||||
) : (
|
||||
<button className="flex items-center rounded border border-gray-300 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50">
|
||||
<CheckSquare className="mr-1 h-3 w-3" />
|
||||
转为待办
|
||||
</button>
|
||||
)}
|
||||
{image.processing_status === 'completed' && image.ocr_result ? (
|
||||
<>
|
||||
{image.document_id ? (
|
||||
<>
|
||||
<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" />
|
||||
查看文档
|
||||
</button>
|
||||
<button
|
||||
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" />
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -132,6 +132,23 @@ class ImageServiceClass {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user