Files
PicAnalysis/frontend/src/pages/DocumentsPage.tsx
congsh 358deeb380 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>
2026-02-26 18:20:46 +08:00

269 lines
9.0 KiB
TypeScript

import { useState } from 'react';
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, Sparkles, Tag, FolderOpen } from 'lucide-react';
import { useSearchDocuments } from '@/hooks/useDocuments';
import type { Document } from '@/types';
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: '',
content: '',
});
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
try {
await createDocument.mutateAsync(formData);
setFormData({ title: '', content: '' });
setShowCreateForm(false);
} catch (err: any) {
alert(err.message || '创建失败');
}
};
const handleDelete = async (id: string) => {
if (confirm('确定要删除这个文档吗?')) {
try {
await deleteDocument.mutateAsync(id);
} catch (err: any) {
alert(err.message || '删除失败');
}
}
};
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
if (!searchQuery.trim()) {
setSearchResults([]);
return;
}
try {
const results = await searchDocuments.mutateAsync(searchQuery);
setSearchResults(results);
} catch (err: any) {
alert(err.message || '搜索失败');
}
};
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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="mt-1 text-sm text-gray-600"></p>
</div>
<Button onClick={() => setShowCreateForm(!showCreateForm)}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
{/* Search */}
<Card>
<form onSubmit={handleSearch} className="flex gap-3">
<Input
className="flex-1"
placeholder="搜索文档..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Button type="submit" variant="secondary">
<Search className="mr-2 h-4 w-4" />
</Button>
</form>
</Card>
{/* Create Form */}
{showCreateForm && (
<Card title="新建文档">
<form onSubmit={handleCreate} className="space-y-4">
<Input
label="标题"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="文档标题"
/>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
</label>
<textarea
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={6}
value={formData.content}
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
placeholder="文档内容"
required
/>
</div>
<div className="flex justify-end gap-3">
<Button
type="button"
variant="secondary"
onClick={() => setShowCreateForm(false)}
>
</Button>
<Button type="submit" loading={createDocument.isPending}>
</Button>
</div>
</form>
</Card>
)}
{/* Documents List */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{displayDocuments?.map((doc) => (
<DocumentCard key={doc.id} doc={doc} />
))}
</div>
{displayDocuments?.length === 0 && (
<Card variant="bordered">
<p className="text-center text-gray-500"></p>
</Card>
)}
</div>
);
}