feat: 添加 Docker 部署支持和多 OCR 提供商架构
- 添加完整的 Docker 配置 (Dockerfile, docker-compose.yml) - 修复前端硬编码端口 4000,改用相对路径 /api - 实现多 OCR 提供商架构 (Tesseract.js/Baidu/RapidOCR) - 修复 Docker 环境中图片上传路径问题 - 添加用户设置页面和 AI 分析服务 - 更新 Prisma schema 支持 AI 分析结果 - 添加部署文档和 OCR 配置指南 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { useDocuments, useCreateDocument, useDeleteDocument } from '@/hooks/useDocuments';
|
||||
import { useDocuments, useCreateDocument, useDeleteDocument, useAnalyzeDocument, useDocumentAnalysis } from '@/hooks/useDocuments';
|
||||
import { Button } from '@/components/Button';
|
||||
import { Input } from '@/components/Input';
|
||||
import { Card } from '@/components/Card';
|
||||
import { Plus, Trash2, Search } from 'lucide-react';
|
||||
import { Plus, Trash2, Search, Sparkles, Tag, FolderOpen } from 'lucide-react';
|
||||
import { useSearchDocuments } from '@/hooks/useDocuments';
|
||||
import type { Document } from '@/types';
|
||||
|
||||
@@ -11,10 +11,13 @@ export default function DocumentsPage() {
|
||||
const { data: documents } = useDocuments();
|
||||
const createDocument = useCreateDocument();
|
||||
const deleteDocument = useDeleteDocument();
|
||||
const analyzeDocument = useAnalyzeDocument();
|
||||
const searchDocuments = useSearchDocuments();
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<Document[]>([]);
|
||||
const [analyzingDocs, setAnalyzingDocs] = useState<Set<string>>(new Set());
|
||||
const [expandedAnalyses, setExpandedAnalyses] = useState<Set<string>>(new Set());
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
@@ -56,6 +59,128 @@ export default function DocumentsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnalyze = async (docId: string) => {
|
||||
setAnalyzingDocs((prev) => new Set(prev).add(docId));
|
||||
try {
|
||||
await analyzeDocument.mutateAsync({ id: docId, options: { generate_summary: true } });
|
||||
setExpandedAnalyses((prev) => new Set(prev).add(docId));
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'AI 分析失败,请检查配置');
|
||||
} finally {
|
||||
setAnalyzingDocs((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(docId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAnalysis = (docId: string) => {
|
||||
setExpandedAnalyses((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(docId)) {
|
||||
next.delete(docId);
|
||||
} else {
|
||||
next.add(docId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// DocumentCard 组件
|
||||
const DocumentCard = ({ doc }: { doc: Document }) => {
|
||||
const { data: analysis } = useDocumentAnalysis(doc.id);
|
||||
const isAnalyzing = analyzingDocs.has(doc.id);
|
||||
const isExpanded = expandedAnalyses.has(doc.id);
|
||||
|
||||
return (
|
||||
<Card variant="bordered" className="flex flex-col">
|
||||
<div className="mb-3 flex items-start justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex-1">
|
||||
{doc.title || '无标题'}
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleAnalyze(doc.id)}
|
||||
disabled={isAnalyzing}
|
||||
className="text-gray-400 hover:text-blue-600 disabled:opacity-50"
|
||||
title="AI 分析"
|
||||
>
|
||||
<Sparkles className={`h-4 w-4 ${isAnalyzing ? 'animate-pulse' : ''}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(doc.id)}
|
||||
className="text-gray-400 hover:text-red-600"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mb-3 line-clamp-3 text-sm text-gray-600 flex-1">
|
||||
{doc.content}
|
||||
</p>
|
||||
|
||||
{/* AI 分析结果 */}
|
||||
{analysis && (
|
||||
<div className="mb-3 border-t border-gray-200 pt-3">
|
||||
<button
|
||||
onClick={() => toggleAnalysis(doc.id)}
|
||||
className="flex items-center gap-2 text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{isExpanded ? '收起' : '查看'} AI 分析
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-3 space-y-2 text-sm">
|
||||
{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-1">
|
||||
{analysis.suggested_tags.map((tag, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs 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-600">
|
||||
建议分类: <span className="font-medium text-gray-900">{analysis.suggested_category}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysis.summary && (
|
||||
<div className="bg-gray-50 rounded p-2 text-gray-700">
|
||||
<span className="font-medium">摘要:</span> {analysis.summary}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-500">
|
||||
由 {analysis.provider} ({analysis.model}) 分析
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-500">
|
||||
{new Date(doc.created_at).toLocaleString()}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const displayDocuments = searchResults.length > 0 ? searchResults : documents;
|
||||
|
||||
return (
|
||||
@@ -129,25 +254,7 @@ export default function DocumentsPage() {
|
||||
{/* Documents List */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{displayDocuments?.map((doc) => (
|
||||
<Card key={doc.id} variant="bordered">
|
||||
<div className="mb-3 flex items-start justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{doc.title || '无标题'}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => handleDelete(doc.id)}
|
||||
className="text-gray-400 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="mb-3 line-clamp-3 text-sm text-gray-600">
|
||||
{doc.content}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{new Date(doc.created_at).toLocaleString()}
|
||||
</p>
|
||||
</Card>
|
||||
<DocumentCard key={doc.id} doc={doc} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user