feat: 实现多 OCR 提供商架构和完整设置页面
## 主要变更 ### OCR 架构 - 新增多提供商 OCR 系统 (Tesseract.js, Baidu OCR, RapidOCR) - 添加 Provider 基类接口和工厂模式 - 支持 provider 自动选择和降级处理 - 新增 RapidOCR Python HTTP 服务 (端口 8080) ### 路径修复 - 修复 Windows 平台路径解析问题 - 统一路径处理工具 (lib/path.ts) - 修复 uploads 目录定位问题 ### 设置页面重构 - 三个标签页:API 配置、OCR 配置、AI 配置 - API 服务器地址配置 - OCR 服务商配置(Tesseract.js, RapidOCR, 百度 OCR) - AI 服务商配置(智谱 GLM, MiniMax, DeepSeek, Kimi, OpenAI, Anthropic) ### 端口配置 - 前端端口: 13056 - 后端端口: 13057 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -81,3 +81,23 @@ export function useDeleteImage() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useReprocessImage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, provider }: { id: string; provider?: string }) =>
|
||||
ImageService.reprocess(id, provider),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['images'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useOCRProviders() {
|
||||
return useQuery({
|
||||
queryKey: ['ocr-providers'],
|
||||
queryFn: () => ImageService.getOCRProviders(),
|
||||
staleTime: 5 * 60 * 1000, // 5 分钟内不重新获取
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useImages, usePendingImages, useUploadImageFile } from '@/hooks/useImages';
|
||||
import {
|
||||
useImages,
|
||||
usePendingImages,
|
||||
useUploadImageFile,
|
||||
useReprocessImage,
|
||||
useOCRProviders,
|
||||
} from '@/hooks/useImages';
|
||||
import { Button } from '@/components/Button';
|
||||
import { Card } from '@/components/Card';
|
||||
import { Upload, Camera, FileText, CheckSquare, X, RefreshCw } from 'lucide-react';
|
||||
import { Upload, Camera, FileText, CheckSquare, X, RefreshCw, ChevronDown, Settings } from 'lucide-react';
|
||||
import type { Image } from '@/types';
|
||||
import { useDeleteImage } from '@/hooks/useImages';
|
||||
|
||||
@@ -16,18 +22,39 @@ const getImageUrl = (path: string) => {
|
||||
return `${API_BASE_URL}${path}`;
|
||||
};
|
||||
|
||||
// OCR Provider 显示名称映射
|
||||
const PROVIDER_NAMES: Record<string, string> = {
|
||||
tesseract: 'Tesseract.js',
|
||||
baidu: '百度 OCR',
|
||||
rapidocr: 'RapidOCR',
|
||||
auto: '自动选择',
|
||||
};
|
||||
|
||||
// OCR Provider 描述
|
||||
const PROVIDER_DESCRIPTIONS: Record<string, string> = {
|
||||
tesseract: '本地轻量,免费',
|
||||
baidu: '云端准确,有免费额度',
|
||||
rapidocr: '本地快速准确,推荐',
|
||||
auto: '自动选择可用的服务',
|
||||
};
|
||||
|
||||
export default function ImagesPage() {
|
||||
const { data: images, refetch } = useImages();
|
||||
const { data: pendingImages } = usePendingImages();
|
||||
const { data: providers } = useOCRProviders();
|
||||
const uploadMutation = useUploadImageFile();
|
||||
const deleteMutation = useDeleteImage();
|
||||
const reprocessMutation = useReprocessImage();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Provider 选择状态
|
||||
const [showProviderMenu, setShowProviderMenu] = useState<string | null>(null);
|
||||
|
||||
// OCR 处理状态轮询
|
||||
useEffect(() => {
|
||||
// 检查是否有处理中的图片
|
||||
const hasPendingImages = images?.some(img =>
|
||||
img.processing_status === 'pending' || img.processing_status === 'processing'
|
||||
const hasPendingImages = images?.some(
|
||||
(img) => img.processing_status === 'pending' || img.processing_status === 'processing'
|
||||
);
|
||||
|
||||
if (hasPendingImages) {
|
||||
@@ -39,12 +66,19 @@ export default function ImagesPage() {
|
||||
}
|
||||
}, [images, refetch]);
|
||||
|
||||
// 点击外部关闭菜单
|
||||
useEffect(() => {
|
||||
const handleClickOutside = () => setShowProviderMenu(null);
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
// 验证并上传所有文件
|
||||
const validFiles = Array.from(files).filter(file => {
|
||||
const validFiles = Array.from(files).filter((file) => {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
alert(`文件 "${file.name}" 不是图片文件`);
|
||||
return false;
|
||||
@@ -123,30 +157,126 @@ export default function ImagesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleReprocess = async (id: string, provider?: string, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
try {
|
||||
await reprocessMutation.mutateAsync({ id, provider });
|
||||
setShowProviderMenu(null);
|
||||
} 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;
|
||||
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';
|
||||
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'
|
||||
// 获取可用的 provider 列表
|
||||
const availableProviders = providers?.filter((p) => p.available) || [];
|
||||
const hasProviders = availableProviders.length > 0;
|
||||
|
||||
const hasProcessingImages = images?.some(
|
||||
(img) => img.processing_status === 'pending' || img.processing_status === 'processing'
|
||||
);
|
||||
|
||||
// OCR 重新处理按钮组件
|
||||
const ReprocessButton = ({ image }: { image: Image }) => {
|
||||
const isProcessing = image.processing_status === 'processing';
|
||||
const canReprocess = image.processing_status === 'completed' || image.processing_status === 'failed';
|
||||
|
||||
if (!canReprocess) return null;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (showProviderMenu === image.id) {
|
||||
setShowProviderMenu(null);
|
||||
} else {
|
||||
setShowProviderMenu(image.id);
|
||||
}
|
||||
}}
|
||||
disabled={reprocessMutation.isPending}
|
||||
className="flex items-center gap-1 rounded border border-orange-300 px-2 py-1 text-xs text-orange-600 hover:bg-orange-50 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
重新识别
|
||||
{hasProviders && <ChevronDown className="h-3 w-3" />}
|
||||
</button>
|
||||
|
||||
{/* Provider 选择菜单 */}
|
||||
{hasProviders && showProviderMenu === image.id && (
|
||||
<div
|
||||
className="absolute bottom-full left-0 mb-1 z-20 min-w-[200px] rounded-lg border border-gray-200 bg-white shadow-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-2 border-b border-gray-100">
|
||||
<p className="text-xs font-medium text-gray-700">选择 OCR 引擎</p>
|
||||
</div>
|
||||
<div className="p-1">
|
||||
{/* 自动选择 */}
|
||||
<button
|
||||
onClick={(e) => handleReprocess(image.id, 'auto', e)}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50 rounded flex items-center justify-between group"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium text-gray-800">自动选择</div>
|
||||
<div className="text-xs text-gray-500">系统自动选择可用的服务</div>
|
||||
</div>
|
||||
{reprocessMutation.isPending && (
|
||||
<RefreshCw className="h-4 w-4 animate-spin text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
{/* 可用的 providers */}
|
||||
{availableProviders.map((provider) => (
|
||||
<button
|
||||
key={provider.type}
|
||||
onClick={(e) => handleReprocess(image.id, provider.type, e)}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50 rounded flex items-center justify-between group"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium text-gray-800">{provider.name}</div>
|
||||
<div className="text-xs text-gray-500">{provider.typeDesc}</div>
|
||||
</div>
|
||||
{reprocessMutation.isPending && (
|
||||
<RefreshCw className="h-4 w-4 animate-spin text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -154,12 +284,22 @@ export default function ImagesPage() {
|
||||
<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 className="flex items-center gap-4">
|
||||
{hasProviders && (
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<Settings className="h-4 w-4" />
|
||||
<span>
|
||||
可用 OCR: {availableProviders.map((p) => p.name).join(', ')}
|
||||
</span>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{/* Upload Actions */}
|
||||
@@ -199,7 +339,11 @@ 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" loading={uploadMutation.isPending}>
|
||||
<Button
|
||||
onClick={handleCapture}
|
||||
variant="secondary"
|
||||
loading={uploadMutation.isPending}
|
||||
>
|
||||
开始截图
|
||||
</Button>
|
||||
</div>
|
||||
@@ -216,9 +360,7 @@ export default function ImagesPage() {
|
||||
className="rounded-lg border border-yellow-200 bg-yellow-50 p-4"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-yellow-800">
|
||||
等待处理
|
||||
</span>
|
||||
<span className="text-xs font-medium text-yellow-800">等待处理</span>
|
||||
<span className="text-xs text-yellow-600">
|
||||
{new Date(image.created_at).toLocaleString()}
|
||||
</span>
|
||||
@@ -272,18 +414,21 @@ export default function ImagesPage() {
|
||||
{/* 状态标签 */}
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span
|
||||
className={`rounded px-2 py-1 text-xs font-medium ${getStatusColor(image.processing_status)}`}
|
||||
className={`rounded px-2 py-1 text-xs font-medium ${getStatusColor(
|
||||
image.processing_status
|
||||
)}`}
|
||||
>
|
||||
{image.processing_status === 'processing' && (
|
||||
<RefreshCw className="mr-1 h-3 w-3 animate-spin inline" />
|
||||
)}
|
||||
{getStatusLabel(image.processing_status)}
|
||||
</span>
|
||||
{image.ocr_confidence !== null && image.ocr_confidence !== undefined && (
|
||||
<span className="text-xs text-gray-600">
|
||||
置信度: {Math.round(image.ocr_confidence * 100)}%
|
||||
</span>
|
||||
)}
|
||||
{image.ocr_confidence !== null &&
|
||||
image.ocr_confidence !== undefined && (
|
||||
<span className="text-xs text-gray-600">
|
||||
置信度: {Math.round(image.ocr_confidence * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* OCR 结果 */}
|
||||
@@ -295,17 +440,15 @@ export default function ImagesPage() {
|
||||
|
||||
{/* 错误信息 */}
|
||||
{image.error_message && (
|
||||
<p className="mb-2 text-xs text-red-600">
|
||||
{image.error_message}
|
||||
</p>
|
||||
<p className="mb-2 text-xs text-red-600">{image.error_message}</p>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex gap-2">
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
|
||||
+719
-122
@@ -1,153 +1,750 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } 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';
|
||||
import { Settings, Save, CheckCircle, XCircle, Eye, EyeOff, Server, Globe, Database, Sparkles } from 'lucide-react';
|
||||
|
||||
// 从环境变量或 localStorage 获取 API 地址
|
||||
const getDefaultApiUrl = () => {
|
||||
return import.meta.env.VITE_API_URL || localStorage.getItem('api_base_url') || 'http://localhost:4000';
|
||||
};
|
||||
|
||||
type ApiConfig = {
|
||||
baseUrl: string;
|
||||
};
|
||||
|
||||
type OCRConfig = {
|
||||
provider: 'auto' | 'tesseract' | 'baidu' | 'tencent' | 'rapidocr';
|
||||
confidenceThreshold: number;
|
||||
baiduApiKey: string;
|
||||
baiduSecretKey: string;
|
||||
tencentSecretId: string;
|
||||
tencentSecretKey: string;
|
||||
rapidocrUrl: string;
|
||||
};
|
||||
|
||||
type AIConfig = {
|
||||
defaultProvider: 'glm' | 'minimax' | 'deepseek' | 'kimi' | 'openai' | 'anthropic';
|
||||
// GLM (智谱AI)
|
||||
glmApiKey: string;
|
||||
glmApiUrl: string;
|
||||
glmModel: string;
|
||||
// MiniMax
|
||||
minimaxApiKey: string;
|
||||
minimaxApiUrl: string;
|
||||
minimaxModel: string;
|
||||
// DeepSeek
|
||||
deepseekApiKey: string;
|
||||
deepseekApiUrl: string;
|
||||
deepseekModel: string;
|
||||
// Kimi (月之暗面)
|
||||
kimiApiKey: string;
|
||||
kimiApiUrl: string;
|
||||
kimiModel: string;
|
||||
// OpenAI
|
||||
openaiApiKey: string;
|
||||
openaiApiUrl: string;
|
||||
openaiModel: string;
|
||||
// Anthropic
|
||||
anthropicApiKey: string;
|
||||
anthropicApiUrl: string;
|
||||
anthropicModel: string;
|
||||
};
|
||||
|
||||
const defaultApiConfig: ApiConfig = {
|
||||
baseUrl: getDefaultApiUrl(),
|
||||
};
|
||||
|
||||
const defaultOCRConfig: OCRConfig = {
|
||||
provider: 'auto',
|
||||
confidenceThreshold: 0.3,
|
||||
baiduApiKey: '',
|
||||
baiduSecretKey: '',
|
||||
tencentSecretId: '',
|
||||
tencentSecretKey: '',
|
||||
rapidocrUrl: 'http://localhost:8080',
|
||||
};
|
||||
|
||||
const defaultAIConfig: AIConfig = {
|
||||
defaultProvider: 'glm',
|
||||
glmApiKey: '',
|
||||
glmApiUrl: 'https://open.bigmodel.cn/api/paas/v4/chat/completions',
|
||||
glmModel: 'glm-4-flash',
|
||||
minimaxApiKey: '',
|
||||
minimaxApiUrl: 'https://api.minimax.chat/v1/chat/completions',
|
||||
minimaxModel: 'abab6.5s-chat',
|
||||
deepseekApiKey: '',
|
||||
deepseekApiUrl: 'https://api.deepseek.com/v1/chat/completions',
|
||||
deepseekModel: 'deepseek-chat',
|
||||
kimiApiKey: '',
|
||||
kimiApiUrl: 'https://api.moonshot.cn/v1/chat/completions',
|
||||
kimiModel: 'moonshot-v1-8k',
|
||||
openaiApiKey: '',
|
||||
openaiApiUrl: 'https://api.openai.com/v1/chat/completions',
|
||||
openaiModel: 'gpt-4o-mini',
|
||||
anthropicApiKey: '',
|
||||
anthropicApiUrl: 'https://api.anthropic.com/v1/messages',
|
||||
anthropicModel: 'claude-3-5-sonnet-20241022',
|
||||
};
|
||||
|
||||
type TabType = 'api' | 'ocr' | 'ai';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [showApiKeys, setShowApiKeys] = useState(false);
|
||||
const [apiConfig, setApiConfig] = useState<ApiConfig>(defaultApiConfig);
|
||||
const [ocrConfig, setOcrConfig] = useState<OCRConfig>(defaultOCRConfig);
|
||||
const [aiConfig, setAiConfig] = useState<AIConfig>(defaultAIConfig);
|
||||
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [testing, setTesting] = useState<string | null>(null);
|
||||
const [testResults, setTestResults] = useState<Record<string, { success: boolean; message: string; duration?: number }>>({});
|
||||
const [availableProviders, setAvailableProviders] = useState<Array<{ type: string; name: string; available: boolean }>>([]);
|
||||
const [activeTab, setActiveTab] = useState<TabType>('api');
|
||||
|
||||
// 表单状态
|
||||
const [ocrProvider, setOcrProvider] = useState('tesseract');
|
||||
const [aiProvider, setAiProvider] = useState('glm');
|
||||
const [glmApiKey, setGlmApiKey] = useState('');
|
||||
const [minimaxApiKey, setMinimaxApiKey] = useState('');
|
||||
const [deepseekApiKey, setDeepseekApiKey] = useState('');
|
||||
// 加载配置和可用服务
|
||||
useEffect(() => {
|
||||
// 加载 API 配置
|
||||
const savedApiConfig = localStorage.getItem('api_config');
|
||||
if (savedApiConfig) {
|
||||
try {
|
||||
setApiConfig(JSON.parse(savedApiConfig));
|
||||
} catch (e) {
|
||||
console.error('Failed to load API config', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载 OCR 配置
|
||||
const savedOcrConfig = localStorage.getItem('ocr_config');
|
||||
if (savedOcrConfig) {
|
||||
try {
|
||||
setOcrConfig(JSON.parse(savedOcrConfig));
|
||||
} catch (e) {
|
||||
console.error('Failed to load OCR config', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载 AI 配置
|
||||
const savedAiConfig = localStorage.getItem('ai_config');
|
||||
if (savedAiConfig) {
|
||||
try {
|
||||
setAiConfig(JSON.parse(savedAiConfig));
|
||||
} catch (e) {
|
||||
console.error('Failed to load AI config', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取可用的 OCR 提供商
|
||||
fetchProviders();
|
||||
}, []);
|
||||
|
||||
const fetchProviders = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const response = await fetch(`${apiConfig.baseUrl}/api/images/ocr/providers`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setAvailableProviders(data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch providers', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
// TODO: 保存配置到后端
|
||||
console.log('Saving settings:', {
|
||||
ocrProvider,
|
||||
aiProvider,
|
||||
glmApiKey,
|
||||
minimaxApiKey,
|
||||
deepseekApiKey,
|
||||
// 保存所有配置到 localStorage
|
||||
localStorage.setItem('api_config', JSON.stringify(apiConfig));
|
||||
localStorage.setItem('api_base_url', apiConfig.baseUrl);
|
||||
localStorage.setItem('ocr_config', JSON.stringify(ocrConfig));
|
||||
localStorage.setItem('ai_config', JSON.stringify(aiConfig));
|
||||
|
||||
// TODO: 保存到后端用户配置
|
||||
const token = localStorage.getItem('auth_token');
|
||||
await fetch(`${apiConfig.baseUrl}/api/user/settings`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ocr: ocrConfig,
|
||||
ai: aiConfig,
|
||||
}),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
alert('设置已保存');
|
||||
} catch (err) {
|
||||
alert('保存失败');
|
||||
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
} catch (error) {
|
||||
console.error('Save failed', error);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const testApiConnection = async () => {
|
||||
setTesting('api');
|
||||
setTestResults((prev) => ({ ...prev, api: { success: false, message: '测试中...' } }));
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const response = await fetch(`${apiConfig.baseUrl}/api/health`);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (response.ok) {
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
api: { success: true, message: '连接成功', duration },
|
||||
}));
|
||||
} else {
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
api: { success: false, message: `服务器返回错误: ${response.status}` },
|
||||
}));
|
||||
}
|
||||
} catch (error: any) {
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
api: { success: false, message: error.message || '连接失败' },
|
||||
}));
|
||||
} finally {
|
||||
setTesting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async (provider: string) => {
|
||||
setTesting(provider);
|
||||
setTestResults((prev) => ({ ...prev, [provider]: { success: false, message: '测试中...' } }));
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const token = localStorage.getItem('auth_token');
|
||||
|
||||
const response = await fetch(`${apiConfig.baseUrl}/api/images/ocr/providers`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
const data = await response.json();
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (data.success) {
|
||||
const providers = data.data || [];
|
||||
const found = providers.find((p: any) => p.type === provider);
|
||||
|
||||
if (found?.available) {
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
[provider]: { success: true, message: '连接成功', duration },
|
||||
}));
|
||||
} else {
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
[provider]: { success: false, message: '服务不可用' },
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
[provider]: { success: false, message: data.error || '测试失败' },
|
||||
}));
|
||||
}
|
||||
} catch (error: any) {
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
[provider]: { success: false, message: error.message || '连接失败' },
|
||||
}));
|
||||
} finally {
|
||||
setTesting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSecret = (key: string) => {
|
||||
setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
};
|
||||
|
||||
const updateApiConfig = (key: keyof ApiConfig, value: any) => {
|
||||
setApiConfig((prev) => ({ ...prev, [key]: value }));
|
||||
setSaved(false);
|
||||
};
|
||||
|
||||
const updateOcrConfig = (key: keyof OCRConfig, value: any) => {
|
||||
setOcrConfig((prev) => ({ ...prev, [key]: value }));
|
||||
setSaved(false);
|
||||
};
|
||||
|
||||
const updateAiConfig = (key: keyof AIConfig, value: any) => {
|
||||
setAiConfig((prev) => ({ ...prev, [key]: value }));
|
||||
setSaved(false);
|
||||
};
|
||||
|
||||
const isProviderAvailable = (type: string) => {
|
||||
return availableProviders.find((p) => p.type === type)?.available ?? false;
|
||||
};
|
||||
|
||||
const renderSecretInput = (
|
||||
value: string,
|
||||
onChange: (val: string) => void,
|
||||
placeholder: string,
|
||||
secretKey: string
|
||||
) => (
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showSecrets[secretKey] ? 'text' : 'password'}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSecret(secretKey)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showSecrets[secretKey] ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderAIProviderCard = (
|
||||
title: string,
|
||||
providerKey: 'glm' | 'minimax' | 'deepseek' | 'kimi' | 'openai' | 'anthropic',
|
||||
color: string,
|
||||
description: string,
|
||||
link: string,
|
||||
linkText: string
|
||||
) => (
|
||||
<Card title={title} variant="bordered" key={providerKey}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
API Key
|
||||
</label>
|
||||
{renderSecretInput(
|
||||
aiConfig[`${providerKey}ApiKey` as keyof AIConfig] as string,
|
||||
(val) => updateAiConfig(`${providerKey}ApiKey` as keyof AIConfig, val),
|
||||
`输入 ${title} API Key`,
|
||||
`${providerKey}ApiKey`
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
API 地址
|
||||
</label>
|
||||
<Input
|
||||
value={aiConfig[`${providerKey}ApiUrl` as keyof AIConfig] as string}
|
||||
onChange={(e) => updateAiConfig(`${providerKey}ApiUrl` as keyof AIConfig, e.target.value)}
|
||||
placeholder="API 地址"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
模型
|
||||
</label>
|
||||
<Input
|
||||
value={aiConfig[`${providerKey}Model` as keyof AIConfig] as string}
|
||||
onChange={(e) => updateAiConfig(`${providerKey}Model` as keyof AIConfig, e.target.value)}
|
||||
placeholder="模型名称"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`flex items-center justify-between ${color} p-3 rounded-lg`}>
|
||||
<div className="text-sm">
|
||||
<p className="text-gray-700">{description}</p>
|
||||
<a
|
||||
href={link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline text-xs"
|
||||
>
|
||||
{linkText}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
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 className="max-w-3xl mx-auto space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Settings className="h-8 w-8 text-gray-600" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">系统设置</h1>
|
||||
<p className="text-sm text-gray-600">配置 API、OCR 和 AI 服务</p>
|
||||
</div>
|
||||
</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>
|
||||
{/* 标签页切换 */}
|
||||
<div className="flex gap-2 border-b border-gray-200">
|
||||
<button
|
||||
onClick={() => setActiveTab('api')}
|
||||
className={`px-4 py-2 font-medium text-sm border-b-2 transition-colors ${
|
||||
activeTab === 'api'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<Globe className="h-4 w-4 inline mr-1" />
|
||||
API 配置
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('ocr')}
|
||||
className={`px-4 py-2 font-medium text-sm border-b-2 transition-colors ${
|
||||
activeTab === 'ocr'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<Database className="h-4 w-4 inline mr-1" />
|
||||
OCR 配置
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('ai')}
|
||||
className={`px-4 py-2 font-medium text-sm border-b-2 transition-colors ${
|
||||
activeTab === 'ai'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="h-4 w-4 inline mr-1" />
|
||||
AI 配置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
{/* API 配置标签页 */}
|
||||
{activeTab === 'api' && (
|
||||
<>
|
||||
<Card title="API 服务器配置" variant="bordered">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
后端 API 地址
|
||||
</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>
|
||||
<Input
|
||||
value={apiConfig.baseUrl}
|
||||
onChange={(e) => updateApiConfig('baseUrl', e.target.value)}
|
||||
placeholder="http://localhost:4000"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
后端服务器的 API 地址,修改后需要刷新页面生效
|
||||
</p>
|
||||
</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>
|
||||
<div className="flex items-center justify-between bg-blue-50 p-3 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">
|
||||
当前 API 地址: <code className="bg-gray-100 px-2 py-1 rounded text-xs">{apiConfig.baseUrl}</code>
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
确保后端服务已启动且可访问
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={testApiConnection}
|
||||
loading={testing === 'api'}
|
||||
>
|
||||
测试连接
|
||||
</Button>
|
||||
</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"
|
||||
/>
|
||||
{testResults.api && (
|
||||
<div className={`flex items-center gap-2 text-sm ${testResults.api.success ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{testResults.api.success ? (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4" />
|
||||
)}
|
||||
{testResults.api.message}
|
||||
{testResults.api.duration && ` (${testResults.api.duration}ms)`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Card>
|
||||
|
||||
<Card title="环境信息" variant="bordered">
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">前端运行环境:</span>
|
||||
<span className="font-medium">{import.meta.env.MODE}</span>
|
||||
</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>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">当前用户:</span>
|
||||
<span className="font-medium">{localStorage.getItem('user_name') || '未登录'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* OCR 配置标签页 */}
|
||||
{activeTab === 'ocr' && (
|
||||
<>
|
||||
<Card title="基本设置" variant="bordered">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
默认 OCR 引擎
|
||||
</label>
|
||||
<select
|
||||
value={ocrConfig.provider}
|
||||
onChange={(e) => updateOcrConfig('provider', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="auto">自动选择</option>
|
||||
<option value="tesseract">Tesseract.js (本地)</option>
|
||||
<option value="rapidocr">RapidOCR (本地服务)</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>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
置信度阈值: {(ocrConfig.confidenceThreshold * 100).toFixed(0)}%
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={ocrConfig.confidenceThreshold}
|
||||
onChange={(e) => updateOcrConfig('confidenceThreshold', parseFloat(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
低于此阈值的识别结果将被标记为失败
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Tesseract.js" variant="bordered">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">
|
||||
Tesseract.js 已安装,无需额外配置。首次使用时会下载语言包。
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
特点: 免费、离线、速度较慢、准确率中等
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleTest('tesseract')}
|
||||
loading={testing === 'tesseract'}
|
||||
>
|
||||
测试
|
||||
</Button>
|
||||
</div>
|
||||
{testResults.tesseract && (
|
||||
<div className={`mt-3 flex items-center gap-2 text-sm ${testResults.tesseract.success ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{testResults.tesseract.success ? <CheckCircle className="h-4 w-4" /> : <XCircle className="h-4 w-4" />}
|
||||
{testResults.tesseract.message}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="RapidOCR" variant="bordered">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
服务地址
|
||||
</label>
|
||||
<Input
|
||||
value={ocrConfig.rapidocrUrl}
|
||||
onChange={(e) => updateOcrConfig('rapidocrUrl', e.target.value)}
|
||||
placeholder="http://localhost:8080"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-gray-600">
|
||||
特点: 免费、离线、速度快、准确率高 (推荐)
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
启动: <code className="bg-gray-100 px-1 rounded">python backend/scripts/rapidocr_server.py</code>
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleTest('rapidocr')}
|
||||
loading={testing === 'rapidocr'}
|
||||
>
|
||||
测试
|
||||
</Button>
|
||||
</div>
|
||||
{testResults.rapidocr && (
|
||||
<div className={`flex items-center gap-2 text-sm ${testResults.rapidocr.success ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{testResults.rapidocr.success ? <CheckCircle className="h-4 w-4" /> : <XCircle className="h-4 w-4" />}
|
||||
{testResults.rapidocr.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="百度 OCR" variant="bordered">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">API Key</label>
|
||||
{renderSecretInput(
|
||||
ocrConfig.baiduApiKey,
|
||||
(val) => updateOcrConfig('baiduApiKey', val),
|
||||
'输入百度 OCR API Key',
|
||||
'baiduApiKey'
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Secret Key</label>
|
||||
{renderSecretInput(
|
||||
ocrConfig.baiduSecretKey,
|
||||
(val) => updateOcrConfig('baiduSecretKey', val),
|
||||
'输入百度 OCR Secret Key',
|
||||
'baiduSecretKey'
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-blue-50 p-3 rounded-lg flex items-center justify-between">
|
||||
<div className="text-sm">
|
||||
<p className="text-gray-700">
|
||||
需要申请密钥?
|
||||
<a href="https://cloud.baidu.com/product/ocr" target="_blank" rel="noopener noreferrer" className="ml-1 text-blue-600 hover:underline">
|
||||
前往百度智能云
|
||||
</a>
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">标准版 QPS=2,每日免费 1000 次</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={() => handleTest('baidu')} loading={testing === 'baidu'}>
|
||||
测试
|
||||
</Button>
|
||||
</div>
|
||||
{testResults.baidu && (
|
||||
<div className={`flex items-center gap-2 text-sm ${testResults.baidu.success ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{testResults.baidu.success ? <CheckCircle className="h-4 w-4" /> : <XCircle className="h-4 w-4" />}
|
||||
{testResults.baidu.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* AI 配置标签页 */}
|
||||
{activeTab === 'ai' && (
|
||||
<>
|
||||
<Card title="基本设置" variant="bordered">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
默认 AI 服务商
|
||||
</label>
|
||||
<select
|
||||
value={aiConfig.defaultProvider}
|
||||
onChange={(e) => updateAiConfig('defaultProvider', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="glm">智谱 AI (GLM)</option>
|
||||
<option value="minimax">MiniMax</option>
|
||||
<option value="deepseek">DeepSeek</option>
|
||||
<option value="kimi">Kimi (月之暗面)</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="anthropic">Anthropic (Claude)</option>
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
选择默认使用的 AI 服务商,用于文档分析和智能标签生成
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{renderAIProviderCard(
|
||||
'智谱 AI (GLM)',
|
||||
'glm',
|
||||
'bg-green-50',
|
||||
'国内领先的大语言模型,支持文档分析、智能标签等功能。',
|
||||
'https://open.bigmodel.cn/',
|
||||
'前往智谱AI开放平台'
|
||||
)}
|
||||
|
||||
{renderAIProviderCard(
|
||||
'MiniMax',
|
||||
'minimax',
|
||||
'bg-purple-50',
|
||||
'专注于对话和文本生成的大模型,中文理解能力强。',
|
||||
'https://api.minimax.chat/',
|
||||
'前往 MiniMax 开放平台'
|
||||
)}
|
||||
|
||||
{renderAIProviderCard(
|
||||
'DeepSeek',
|
||||
'deepseek',
|
||||
'bg-blue-50',
|
||||
'开源大模型,代码理解和逻辑分析能力强,价格优惠。',
|
||||
'https://platform.deepseek.com/',
|
||||
'前往 DeepSeek 平台'
|
||||
)}
|
||||
|
||||
{renderAIProviderCard(
|
||||
'Kimi (月之暗面)',
|
||||
'kimi',
|
||||
'bg-orange-50',
|
||||
'支持超长文本处理,适合长文档分析和总结。',
|
||||
'https://platform.moonshot.cn/',
|
||||
'前往 Moonshot AI 平台'
|
||||
)}
|
||||
|
||||
{renderAIProviderCard(
|
||||
'OpenAI',
|
||||
'openai',
|
||||
'bg-emerald-50',
|
||||
'全球领先的 AI 模型,包括 GPT-4、GPT-4o 等。',
|
||||
'https://platform.openai.com/',
|
||||
'前往 OpenAI 平台'
|
||||
)}
|
||||
|
||||
{renderAIProviderCard(
|
||||
'Anthropic (Claude)',
|
||||
'anthropic',
|
||||
'bg-amber-50',
|
||||
'Claude 系列 AI 模型,擅长分析和长文本处理。',
|
||||
'https://console.anthropic.com/',
|
||||
'前往 Anthropic 控制台'
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 保存按钮 */}
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSave} loading={saving} className="min-w-[120px]">
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
保存设置
|
||||
</Button>
|
||||
<div className="flex items-center justify-between bg-white p-4 rounded-lg border border-gray-200">
|
||||
<div className="text-sm text-gray-600">
|
||||
<Server className="h-4 w-4 inline mr-1" />
|
||||
配置保存在浏览器本地,清除数据会丢失
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{saved && (
|
||||
<span className="flex items-center gap-1 text-green-600 text-sm">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
已保存
|
||||
</span>
|
||||
)}
|
||||
<Button onClick={handleSave} loading={saving}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
保存配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -107,6 +107,31 @@ class ImageServiceClass {
|
||||
throw new Error(error.response?.data?.error || '删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新处理图片 OCR
|
||||
* @param id 图片 ID
|
||||
* @param provider OCR 提供商 ('tesseract' | 'baidu' | 'rapidocr' | 'auto')
|
||||
*/
|
||||
async reprocess(id: string, provider?: string): Promise<void> {
|
||||
try {
|
||||
await apiClient.post(`/images/${id}/reprocess`, { provider });
|
||||
} catch (error: any) {
|
||||
throw new Error(error.response?.data?.error || '重新处理失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可用的 OCR 提供商列表
|
||||
*/
|
||||
async getOCRProviders(): Promise<Array<{ type: string; name: string; available: boolean; typeDesc: string }>> {
|
||||
try {
|
||||
const response = await apiClient.get<{ success: boolean; data: Array<{ type: string; name: string; available: boolean; typeDesc: string }> }>('/images/ocr/providers');
|
||||
return response.data.data || [];
|
||||
} catch (error: any) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const ImageService = new ImageServiceClass();
|
||||
|
||||
@@ -11,10 +11,11 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
port: 13056,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:4000',
|
||||
target: 'http://localhost:13057',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user