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:
congsh
2026-02-26 18:20:46 +08:00
parent f8472987f0
commit 358deeb380
39 changed files with 3169 additions and 71 deletions

43
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,43 @@
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build output
dist
build
# Environment files
.env
.env.local
.env.*.local
# IDE
.vscode
.idea
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Testing
coverage
.nyc_output
playwright-report
test-results
# Vite
.vite
# Logs
logs
*.log
# Misc
*.md
.git
.gitignore

42
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,42 @@
# ========================================
# Stage 1: Dependencies
# ========================================
FROM node:20-alpine AS deps
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# ========================================
# Stage 2: Builder
# ========================================
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build the application (skip type check for production)
RUN npx vite build
# ========================================
# Stage 3: Runner with Nginx
# ========================================
FROM nginx:alpine AS runner
# Copy built assets from builder
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy custom nginx config
COPY nginx.docker.conf /etc/nginx/conf.d/default.conf
# Expose port
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -0,0 +1,41 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# API proxy to backend
location /api {
proxy_pass http://backend:13057;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# Static files with caching
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback - all non-file routes go to index.html
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -2013,7 +2013,6 @@
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
@@ -2103,7 +2102,6 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@types/babel__core": {
@@ -2735,7 +2733,6 @@
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
@@ -3381,7 +3378,6 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/dunder-proto": {
@@ -4800,7 +4796,6 @@
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
@@ -5428,7 +5423,6 @@
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
@@ -5444,7 +5438,6 @@
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
@@ -5516,7 +5509,6 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/react-refresh": {

View File

@@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { DocumentService } from '@/services/document.service';
import { DocumentService, type AIAnalysisResult } from '@/services/document.service';
import type { CreateDocumentRequest, UpdateDocumentRequest } from '@/types';
export function useDocuments(params?: { page?: number; limit?: number }) {
@@ -57,3 +57,35 @@ export function useSearchDocuments() {
mutationFn: (query: string) => DocumentService.search(query),
});
}
// AI 分析相关 hooks
export function useDocumentAnalysis(id: string) {
return useQuery({
queryKey: ['document-analysis', id],
queryFn: () => DocumentService.getAnalysis(id),
enabled: !!id,
});
}
export function useAnalyzeDocument() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, options }: { id: string; options?: { provider?: string; generate_summary?: boolean } }) =>
DocumentService.analyzeDocument(id, options),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['document-analysis', variables.id] });
},
});
}
export function useDeleteDocumentAnalysis() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => DocumentService.deleteAnalysis(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: ['document-analysis', id] });
},
});
}

View File

@@ -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>

View File

@@ -12,7 +12,7 @@ import { Upload, Camera, FileText, CheckSquare, X, RefreshCw, ChevronDown, Setti
import type { Image } from '@/types';
import { useDeleteImage } from '@/hooks/useImages';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000';
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
// 获取完整的图片 URL
const getImageUrl = (path: string) => {

View File

@@ -6,7 +6,7 @@ import { Settings, Save, CheckCircle, XCircle, Eye, EyeOff, Server, Globe, Datab
// 从环境变量或 localStorage 获取 API 地址
const getDefaultApiUrl = () => {
return import.meta.env.VITE_API_URL || localStorage.getItem('api_base_url') || 'http://localhost:4000';
return import.meta.env.VITE_API_URL || localStorage.getItem('api_base_url') || '/api';
};
type ApiConfig = {
@@ -428,7 +428,7 @@ export default function SettingsPage() {
<Input
value={apiConfig.baseUrl}
onChange={(e) => updateApiConfig('baseUrl', e.target.value)}
placeholder="http://localhost:4000"
placeholder="/api"
/>
<p className="mt-1 text-xs text-gray-500">
API
@@ -476,7 +476,7 @@ export default function SettingsPage() {
</div>
<div className="flex justify-between">
<span className="text-gray-600"> API :</span>
<span className="font-medium">{import.meta.env.VITE_API_URL || 'http://localhost:4000'}</span>
<span className="font-medium">{import.meta.env.VITE_API_URL || '/api'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">:</span>

View File

@@ -5,6 +5,16 @@ import type {
UpdateDocumentRequest,
} from '@/types';
// AI 分析结果类型
export interface AIAnalysisResult {
suggested_tags: string[];
suggested_category?: string;
summary?: string;
raw_response: string;
provider: string;
model: string;
}
class DocumentServiceClass {
async create(data: CreateDocumentRequest): Promise<Document> {
try {
@@ -64,6 +74,50 @@ class DocumentServiceClass {
const response = await apiClient.get<{ success: boolean; data: Document[] }>(`/documents/search?q=${encodeURIComponent(query)}`);
return response.data.data || [];
}
// AI 分析文档
async analyzeDocument(id: string, options?: { provider?: string; generate_summary?: boolean }): Promise<AIAnalysisResult> {
try {
const response = await apiClient.post<{ success: boolean; data: AIAnalysisResult }>(
`/documents/${id}/analyze`,
options || {}
);
if (response.data.success && response.data.data) {
return response.data.data;
}
throw new Error('AI 分析失败');
} catch (error: any) {
throw new Error(error.response?.data?.error || 'AI 分析失败');
}
}
// 获取文档的 AI 分析结果
async getAnalysis(id: string): Promise<AIAnalysisResult | null> {
try {
const response = await apiClient.get<{ success: boolean; data: AIAnalysisResult }>(
`/documents/${id}/analysis`
);
if (response.data.success && response.data.data) {
return response.data.data;
}
return null;
} catch (error: any) {
// 404 表示没有分析结果
if (error.response?.status === 404) {
return null;
}
throw new Error(error.response?.data?.error || '获取 AI 分析失败');
}
}
// 删除文档的 AI 分析结果
async deleteAnalysis(id: string): Promise<void> {
try {
await apiClient.delete(`/documents/${id}/analysis`);
} catch (error: any) {
throw new Error(error.response?.data?.error || '删除 AI 分析失败');
}
}
}
export const DocumentService = new DocumentServiceClass();

View File

@@ -6,7 +6,7 @@ import type {
UpdateOCRRequest,
} from '@/types';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000/api';
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
class ImageServiceClass {
/**

View File

@@ -11,6 +11,7 @@ export default defineConfig({
},
},
server: {
host: '0.0.0.0',
port: 13056,
strictPort: true,
proxy: {