feat: 完善图片上传和 OCR 处理功能
- 新增注册页面 (RegisterPage) 和设置页面 (SettingsPage) - 实现多图片上传功能,支持 FormData 文件上传 - 添加 multer 中间件处理图片文件 - 实现 OCR 异步处理服务,自动触发文字识别 - 添加 OCR 处理状态轮询,显示处理进度 - 修复图片显示问题,拼接完整的后端 URL - 添加图片重新处理 API (POST /api/images/:id/reprocess) - 更新 Card 组件支持 extra 属性 - 创建 CLAUDE.md 项目文档 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,10 +2,12 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { useAuthStore } from './stores/authStore';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import RegisterPage from './pages/RegisterPage';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import DocumentsPage from './pages/DocumentsPage';
|
||||
import TodosPage from './pages/TodosPage';
|
||||
import ImagesPage from './pages/ImagesPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import Layout from './components/Layout';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -33,6 +35,7 @@ function App() {
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
@@ -46,6 +49,7 @@ function App() {
|
||||
<Route path="documents" element={<DocumentsPage />} />
|
||||
<Route path="todos" element={<TodosPage />} />
|
||||
<Route path="images" element={<ImagesPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -4,11 +4,12 @@ import { cn } from '@/utils/cn';
|
||||
export interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
title?: string;
|
||||
action?: React.ReactNode;
|
||||
extra?: React.ReactNode;
|
||||
variant?: 'default' | 'bordered' | 'elevated';
|
||||
}
|
||||
|
||||
export const Card = forwardRef<HTMLDivElement, CardProps>(
|
||||
({ className, title, action, variant = 'default', children, ...props }, ref) => {
|
||||
({ className, title, action, extra, variant = 'default', children, ...props }, ref) => {
|
||||
const baseStyles = 'rounded bg-white p-4';
|
||||
|
||||
const variantStyles = {
|
||||
@@ -19,10 +20,13 @@ export const Card = forwardRef<HTMLDivElement, CardProps>(
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn(baseStyles, variantStyles[variant], className)} {...props}>
|
||||
{(title || action) && (
|
||||
{(title || action || extra) && (
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
{title && <h3 className="text-lg font-semibold">{title}</h3>}
|
||||
{action && <div>{action}</div>}
|
||||
<div className="flex items-center gap-2">
|
||||
{extra}
|
||||
{action}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>{children || '卡片内容'}</div>
|
||||
|
||||
@@ -35,6 +35,18 @@ export function useUploadImage() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadImageFile() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ file, documentId }: { file: File; documentId?: string }) =>
|
||||
ImageService.uploadFile(file, documentId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['images'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateOCR() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
||||
@@ -1,57 +1,165 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useImages, usePendingImages } from '@/hooks/useImages';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useImages, usePendingImages, useUploadImageFile } from '@/hooks/useImages';
|
||||
import { Button } from '@/components/Button';
|
||||
import { Card } from '@/components/Card';
|
||||
import { Upload, Camera, FileText, CheckSquare } from 'lucide-react';
|
||||
import { Upload, Camera, FileText, CheckSquare, X, RefreshCw } from 'lucide-react';
|
||||
import type { Image } from '@/types';
|
||||
import { useDeleteImage } from '@/hooks/useImages';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000';
|
||||
|
||||
// 获取完整的图片 URL
|
||||
const getImageUrl = (path: string) => {
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
return path;
|
||||
}
|
||||
return `${API_BASE_URL}${path}`;
|
||||
};
|
||||
|
||||
export default function ImagesPage() {
|
||||
const { data: images } = useImages();
|
||||
const { data: images, refetch } = useImages();
|
||||
const { data: pendingImages } = usePendingImages();
|
||||
const uploadMutation = useUploadImageFile();
|
||||
const deleteMutation = useDeleteImage();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
// OCR 处理状态轮询
|
||||
useEffect(() => {
|
||||
// 检查是否有处理中的图片
|
||||
const hasPendingImages = images?.some(img =>
|
||||
img.processing_status === 'pending' || img.processing_status === 'processing'
|
||||
);
|
||||
|
||||
if (hasPendingImages) {
|
||||
const interval = setInterval(() => {
|
||||
refetch();
|
||||
}, 3000); // 每3秒轮询一次
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [images, refetch]);
|
||||
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
// 这里实现文件上传逻辑
|
||||
const file = files[0];
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = async () => {
|
||||
// 将文件转换为 base64 或上传到服务器
|
||||
const base64 = reader.result as string;
|
||||
// TODO: 实现上传到 API
|
||||
console.log('File uploaded:', base64.substring(0, 50) + '...');
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} catch (err) {
|
||||
alert('上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
// 验证并上传所有文件
|
||||
const validFiles = Array.from(files).filter(file => {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
alert(`文件 "${file.name}" 不是图片文件`);
|
||||
return false;
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
alert(`文件 "${file.name}" 超过 10MB 限制`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (validFiles.length === 0) return;
|
||||
|
||||
// 依次上传所有文件
|
||||
for (const file of validFiles) {
|
||||
try {
|
||||
await uploadMutation.mutateAsync({ file });
|
||||
} catch (err: any) {
|
||||
console.error(`上传 ${file.name} 失败:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// 清空 input 允许再次选择同一文件
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleCapture = async () => {
|
||||
try {
|
||||
// 调用系统截图功能(需要与 Electron 集成或使用浏览器 API)
|
||||
// 调用系统截图功能
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: { mediaSource: 'screen' },
|
||||
video: { mediaSource: 'screen' as any },
|
||||
});
|
||||
// TODO: 处理截图流
|
||||
|
||||
// 创建视频元素捕获帧
|
||||
const video = document.createElement('video');
|
||||
video.srcObject = stream;
|
||||
await video.play();
|
||||
|
||||
// 创建 canvas 截取当前帧
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx?.drawImage(video, 0, 0);
|
||||
|
||||
// 停止视频流
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
} catch (err) {
|
||||
alert('截图失败或被取消');
|
||||
|
||||
// 转换为 blob 并上传
|
||||
canvas.toBlob(async (blob) => {
|
||||
if (blob) {
|
||||
const file = new File([blob], `screenshot-${Date.now()}.png`, { type: 'image/png' });
|
||||
try {
|
||||
await uploadMutation.mutateAsync({ file });
|
||||
} catch (err: any) {
|
||||
alert(err.message || '上传截图失败');
|
||||
}
|
||||
}
|
||||
}, 'image/png');
|
||||
} catch (err: any) {
|
||||
if (err.name !== 'NotAllowedError') {
|
||||
alert('截图失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('确定要删除这张图片吗?')) return;
|
||||
|
||||
try {
|
||||
await deleteMutation.mutateAsync(id);
|
||||
} catch (err: any) {
|
||||
alert(err.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed': return '已完成';
|
||||
case 'pending': return '等待处理';
|
||||
case 'processing': return 'OCR 处理中';
|
||||
case 'failed': return '处理失败';
|
||||
default: return status;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed': return 'bg-green-100 text-green-800';
|
||||
case 'pending': return 'bg-yellow-100 text-yellow-800';
|
||||
case 'processing': return 'bg-blue-100 text-blue-800';
|
||||
case 'failed': return 'bg-red-100 text-red-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const hasProcessingImages = images?.some(img =>
|
||||
img.processing_status === 'pending' || img.processing_status === 'processing'
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">图片管理</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">上传和管理您的图片</p>
|
||||
<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>
|
||||
{hasProcessingImages && (
|
||||
<div className="flex items-center gap-2 text-sm text-blue-600">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
<span>OCR 处理中...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upload Actions */}
|
||||
@@ -72,10 +180,10 @@ export default function ImagesPage() {
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold">上传图片</h3>
|
||||
<p className="mb-4 text-sm text-gray-600">从本地上传图片文件</p>
|
||||
<p className="mb-4 text-sm text-gray-600">支持选择多张图片</p>
|
||||
<Button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
loading={uploading}
|
||||
loading={uploadMutation.isPending}
|
||||
>
|
||||
选择文件
|
||||
</Button>
|
||||
@@ -91,7 +199,7 @@ export default function ImagesPage() {
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold">屏幕截图</h3>
|
||||
<p className="mb-4 text-sm text-gray-600">使用系统截图功能</p>
|
||||
<Button onClick={handleCapture} variant="secondary">
|
||||
<Button onClick={handleCapture} variant="secondary" loading={uploadMutation.isPending}>
|
||||
开始截图
|
||||
</Button>
|
||||
</div>
|
||||
@@ -109,13 +217,13 @@ export default function ImagesPage() {
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-yellow-800">
|
||||
处理中
|
||||
等待处理
|
||||
</span>
|
||||
<span className="text-xs text-yellow-600">
|
||||
{new Date(image.created_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-yellow-700">{image.file_path}</p>
|
||||
<p className="text-sm text-yellow-700 truncate">{image.file_path}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -123,59 +231,88 @@ export default function ImagesPage() {
|
||||
)}
|
||||
|
||||
{/* Images Grid */}
|
||||
<Card title="所有图片" variant="bordered">
|
||||
<Card
|
||||
title={`所有图片 (${images?.length || 0})`}
|
||||
variant="bordered"
|
||||
extra={
|
||||
hasProcessingImages && (
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="flex items-center gap-1 text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
刷新
|
||||
</button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{images && images.length > 0 ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{images.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className="overflow-hidden rounded-lg border border-gray-200"
|
||||
className="group relative overflow-hidden rounded-lg border border-gray-200 bg-white"
|
||||
>
|
||||
<button
|
||||
onClick={() => handleDelete(image.id)}
|
||||
className="absolute right-2 top-2 z-10 rounded-full bg-red-500 p-1 text-white opacity-0 transition-opacity group-hover:opacity-100 hover:bg-red-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* 图片预览 */}
|
||||
<img
|
||||
src={image.file_path}
|
||||
src={getImageUrl(image.file_path)}
|
||||
alt="Upload"
|
||||
className="h-48 w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
<div className="p-3">
|
||||
{/* 状态标签 */}
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span
|
||||
className={`rounded px-2 py-1 text-xs font-medium ${
|
||||
image.processing_status === 'completed'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: image.processing_status === 'pending'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
className={`rounded px-2 py-1 text-xs font-medium ${getStatusColor(image.processing_status)}`}
|
||||
>
|
||||
{image.processing_status === 'completed'
|
||||
? '已完成'
|
||||
: image.processing_status === 'pending'
|
||||
? '处理中'
|
||||
: '失败'}
|
||||
{image.processing_status === 'processing' && (
|
||||
<RefreshCw className="mr-1 h-3 w-3 animate-spin inline" />
|
||||
)}
|
||||
{getStatusLabel(image.processing_status)}
|
||||
</span>
|
||||
{image.ocr_confidence && (
|
||||
{image.ocr_confidence !== null && image.ocr_confidence !== undefined && (
|
||||
<span className="text-xs text-gray-600">
|
||||
{Math.round(image.ocr_confidence * 100)}%
|
||||
置信度: {Math.round(image.ocr_confidence * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* OCR 结果 */}
|
||||
{image.ocr_result && (
|
||||
<p className="mb-2 line-clamp-2 text-sm text-gray-600">
|
||||
{image.ocr_result}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 错误信息 */}
|
||||
{image.error_message && (
|
||||
<p className="mb-2 text-xs text-red-600">
|
||||
{image.error_message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex gap-2">
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
@@ -184,7 +321,7 @@ export default function ImagesPage() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center text-gray-500">暂无图片</p>
|
||||
<p className="py-8 text-center text-gray-500">暂无图片,请上传图片开始使用</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
103
frontend/src/pages/RegisterPage.tsx
Normal file
103
frontend/src/pages/RegisterPage.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useRegister } from '@/hooks/useAuth';
|
||||
import { Button } from '@/components/Button';
|
||||
import { Input } from '@/components/Input';
|
||||
import { Card } from '@/components/Card';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const navigate = useNavigate();
|
||||
const register = useRegister();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!username || !password) {
|
||||
setError('请输入用户名和密码');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError('密码长度至少为 6 位');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('两次输入的密码不一致');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await register.mutateAsync({ username, password });
|
||||
navigate('/dashboard');
|
||||
} catch (err: any) {
|
||||
setError(err.message || '注册失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-100 px-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900">图片分析系统</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">创建新账号</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-50 p-3 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="用户名"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="请输入用户名"
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="密码"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="请输入密码(至少 6 位)"
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="确认密码"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="请再次输入密码"
|
||||
required
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
loading={register.isPending}
|
||||
>
|
||||
注册
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-sm text-gray-600">
|
||||
已有账号?{' '}
|
||||
<Link to="/login" className="text-blue-600 hover:underline">
|
||||
立即登录
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
154
frontend/src/pages/SettingsPage.tsx
Normal file
154
frontend/src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/Button';
|
||||
import { Input } from '@/components/Input';
|
||||
import { Card } from '@/components/Card';
|
||||
import { Settings, Save, Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [showApiKeys, setShowApiKeys] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 表单状态
|
||||
const [ocrProvider, setOcrProvider] = useState('tesseract');
|
||||
const [aiProvider, setAiProvider] = useState('glm');
|
||||
const [glmApiKey, setGlmApiKey] = useState('');
|
||||
const [minimaxApiKey, setMinimaxApiKey] = useState('');
|
||||
const [deepseekApiKey, setDeepseekApiKey] = useState('');
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
// TODO: 保存配置到后端
|
||||
console.log('Saving settings:', {
|
||||
ocrProvider,
|
||||
aiProvider,
|
||||
glmApiKey,
|
||||
minimaxApiKey,
|
||||
deepseekApiKey,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
alert('设置已保存');
|
||||
} catch (err) {
|
||||
alert('保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">系统设置</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">配置 OCR 和 AI 服务提供商</p>
|
||||
</div>
|
||||
|
||||
{/* OCR 设置 */}
|
||||
<Card title="OCR 设置" variant="bordered">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
OCR 提供商
|
||||
</label>
|
||||
<select
|
||||
value={ocrProvider}
|
||||
onChange={(e) => setOcrProvider(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="tesseract">Tesseract.js (浏览器端)</option>
|
||||
<option value="paddle">PaddleOCR (本地服务)</option>
|
||||
<option value="baidu">百度 OCR</option>
|
||||
<option value="tencent">腾讯云 OCR</option>
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
选择 OCR 文字识别服务提供商
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* AI 设置 */}
|
||||
<Card title="AI 分析设置" variant="bordered">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
AI 提供商
|
||||
</label>
|
||||
<select
|
||||
value={aiProvider}
|
||||
onChange={(e) => setAiProvider(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="glm">智谱 AI (GLM)</option>
|
||||
<option value="minimax">MiniMax</option>
|
||||
<option value="deepseek">DeepSeek</option>
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
选择用于智能标签和分类的 AI 服务
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* GLM API Key */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
智谱 AI API Key
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKeys(!showApiKeys)}
|
||||
className="text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{showApiKeys ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
type={showApiKeys ? 'text' : 'password'}
|
||||
value={glmApiKey}
|
||||
onChange={(e) => setGlmApiKey(e.target.value)}
|
||||
placeholder="请输入智谱 AI API Key"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* MiniMax API Key */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
MiniMax API Key
|
||||
</label>
|
||||
<Input
|
||||
type={showApiKeys ? 'text' : 'password'}
|
||||
value={minimaxApiKey}
|
||||
onChange={(e) => setMinimaxApiKey(e.target.value)}
|
||||
placeholder="请输入 MiniMax API Key"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* DeepSeek API Key */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
DeepSeek API Key
|
||||
</label>
|
||||
<Input
|
||||
type={showApiKeys ? 'text' : 'password'}
|
||||
value={deepseekApiKey}
|
||||
onChange={(e) => setDeepseekApiKey(e.target.value)}
|
||||
placeholder="请输入 DeepSeek API Key"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 保存按钮 */}
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSave} loading={saving} className="min-w-[120px]">
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
保存设置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import axios from 'axios';
|
||||
import { apiClient } from './api';
|
||||
import type {
|
||||
Image,
|
||||
@@ -5,7 +6,41 @@ import type {
|
||||
UpdateOCRRequest,
|
||||
} from '@/types';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000/api';
|
||||
|
||||
class ImageServiceClass {
|
||||
/**
|
||||
* Upload image file using FormData
|
||||
*/
|
||||
async uploadFile(file: File, documentId?: string): Promise<Image> {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (documentId) {
|
||||
formData.append('document_id', documentId);
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const response = await axios.post<{ success: boolean; data: Image }>(
|
||||
`${API_BASE_URL}/images`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
return response.data.data;
|
||||
}
|
||||
throw new Error('上传失败');
|
||||
} catch (error: any) {
|
||||
throw new Error(error.response?.data?.error || '上传图片失败');
|
||||
}
|
||||
}
|
||||
|
||||
async create(data: CreateImageRequest): Promise<Image> {
|
||||
try {
|
||||
const response = await apiClient.post<{ success: boolean; data: Image }>('/images', data);
|
||||
|
||||
Reference in New Issue
Block a user