Files
QQuiz/frontend/src/pages/AdminSettings.jsx
handsomezhuzhu d24a1a1f92 feat: 添加 Gemini 支持和 AI 参考答案生成功能
主要功能:
- 🎯 新增 Google Gemini AI 提供商支持
  - 原生 PDF 理解能力(最多1000页)
  - 完整保留图片、表格、公式等内容
  - 支持自定义 Base URL(用于代理/中转服务)

- 🤖 实现 AI 参考答案自动生成
  - 当题目缺少答案时自动调用 AI 生成参考答案
  - 支持单选、多选、判断、简答等所有题型
  - 答案标记为"AI参考答案:"便于识别

- 🔧 优化文档解析功能
  - 改进中文 Prompt 提高识别准确度
  - 自动修复 JSON 中的控制字符(换行符等)
  - 智能题目类型验证和自动转换(proof→short等)
  - 增加超时时间和重试机制

- 🎨 完善管理后台配置界面
  - 新增 Gemini 配置区域
  - 突出显示 PDF 原生支持特性
  - 为其他提供商添加"仅文本"警告
  - 支持 Gemini Base URL 自定义

技术改进:
- 添加 google-genai 依赖
- 实现异步 API 调用适配
- 完善错误处理和日志输出
- 统一配置管理和数据库存储

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 22:43:08 +08:00

580 lines
24 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Admin Settings Page - Enhanced with API Configuration
*/
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { adminAPI } from '../api/client'
import { useAuth } from '../context/AuthContext'
import { Settings, Save, Loader, Key, Link as LinkIcon, Eye, EyeOff, ArrowLeft } from 'lucide-react'
import toast from 'react-hot-toast'
export const AdminSettings = () => {
const { user } = useAuth()
const navigate = useNavigate()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [showApiKeys, setShowApiKeys] = useState({
openai: false,
anthropic: false,
qwen: false,
gemini: false
})
const [config, setConfig] = useState({
allow_registration: true,
max_upload_size_mb: 10,
max_daily_uploads: 20,
ai_provider: 'gemini',
// OpenAI
openai_api_key: '',
openai_base_url: 'https://api.openai.com/v1',
openai_model: 'gpt-4o-mini',
// Anthropic
anthropic_api_key: '',
anthropic_model: 'claude-3-haiku-20240307',
// Qwen
qwen_api_key: '',
qwen_base_url: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
qwen_model: 'qwen-plus',
// Gemini
gemini_api_key: '',
gemini_base_url: '',
gemini_model: 'gemini-2.0-flash-exp'
})
useEffect(() => {
loadConfig()
}, [])
const loadConfig = async () => {
try {
const response = await adminAPI.getConfig()
setConfig(response.data)
} catch (error) {
console.error('Failed to load config:', error)
toast.error('加载配置失败')
} finally {
setLoading(false)
}
}
const handleSave = async () => {
setSaving(true)
try {
await adminAPI.updateConfig(config)
toast.success('配置保存成功!')
} catch (error) {
console.error('Failed to save config:', error)
toast.error('保存配置失败')
} finally {
setSaving(false)
}
}
const handleChange = (key, value) => {
setConfig({
...config,
[key]: value
})
}
const toggleApiKeyVisibility = (provider) => {
setShowApiKeys({
...showApiKeys,
[provider]: !showApiKeys[provider]
})
}
// Get complete API endpoint URL
const getCompleteEndpoint = (provider) => {
const endpoints = {
openai: '/chat/completions',
anthropic: '/messages',
qwen: '/chat/completions'
}
let baseUrl = ''
if (provider === 'openai') {
baseUrl = config.openai_base_url || 'https://api.openai.com/v1'
} else if (provider === 'anthropic') {
baseUrl = 'https://api.anthropic.com/v1'
} else if (provider === 'qwen') {
baseUrl = config.qwen_base_url || 'https://dashscope.aliyuncs.com/compatible-mode/v1'
}
// Remove trailing slash
baseUrl = baseUrl.replace(/\/$/, '')
return `${baseUrl}${endpoints[provider]}`
}
if (loading) {
return (
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
<Loader className="h-8 w-8 animate-spin text-primary-600" />
</div>
)
}
return (
<div className="min-h-screen bg-gray-100">
{/* Header */}
<div className="bg-white shadow">
<div className="max-w-5xl mx-auto px-4 py-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => navigate(-1)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
title="返回"
>
<ArrowLeft className="h-6 w-6 text-gray-600" />
</button>
<Settings className="h-8 w-8 text-primary-600" />
<div>
<h1 className="text-2xl font-bold text-gray-900">系统设置</h1>
<p className="text-gray-600">管理员{user?.username}</p>
</div>
</div>
</div>
</div>
</div>
{/* Content */}
<div className="max-w-5xl mx-auto px-4 py-8 space-y-6">
{/* Basic Settings */}
<div className="bg-white rounded-xl shadow-md p-6 space-y-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">基础设置</h2>
{/* Allow Registration */}
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-gray-900">允许用户注册</h3>
<p className="text-sm text-gray-500">关闭后新用户无法注册</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={config.allow_registration}
onChange={(e) => handleChange('allow_registration', e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-600"></div>
</label>
</div>
{/* Max Upload Size */}
<div>
<label className="block font-medium text-gray-900 mb-2">
最大上传文件大小 (MB)
</label>
<input
type="number"
min="1"
max="100"
value={config.max_upload_size_mb}
onChange={(e) => handleChange('max_upload_size_mb', parseInt(e.target.value))}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
<p className="text-sm text-gray-500 mt-1">建议5-20 MB</p>
</div>
{/* Max Daily Uploads */}
<div>
<label className="block font-medium text-gray-900 mb-2">
每日上传次数限制
</label>
<input
type="number"
min="1"
max="100"
value={config.max_daily_uploads}
onChange={(e) => handleChange('max_daily_uploads', parseInt(e.target.value))}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
<p className="text-sm text-gray-500 mt-1">建议10-50 </p>
</div>
{/* AI Provider */}
<div>
<label className="block font-medium text-gray-900 mb-2">
AI 提供商
</label>
<select
value={config.ai_provider}
onChange={(e) => handleChange('ai_provider', e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
>
<option value="gemini">Google Gemini (推荐)</option>
<option value="openai">OpenAI (GPT)</option>
<option value="anthropic">Anthropic (Claude)</option>
<option value="qwen">Qwen (通义千问)</option>
</select>
<p className="text-sm text-gray-500 mt-1">
选择后在下方配置对应的 API 密钥Gemini 支持原生 PDF 解析
</p>
</div>
</div>
{/* OpenAI Configuration */}
<div className={`bg-white rounded-xl shadow-md p-6 space-y-4 ${config.ai_provider === 'openai' ? 'ring-2 ring-primary-500' : ''}`}>
<div className="flex items-center gap-2">
<Key className="h-5 w-5 text-green-600" />
<h2 className="text-xl font-bold text-gray-900">OpenAI 配置</h2>
{config.ai_provider === 'openai' && (
<span className="px-2 py-1 text-xs font-medium bg-primary-100 text-primary-700 rounded-full">当前使用</span>
)}
</div>
{/* Text-only warning */}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-sm text-amber-800">
OpenAI 仅支持文本解析不支持 PDF 原生理解PDF 文件将通过文本提取处理可能丢失格式和图片信息
</p>
</div>
{/* API Key */}
<div>
<label className="block font-medium text-gray-900 mb-2">
API Key
</label>
<div className="relative">
<input
type={showApiKeys.openai ? 'text' : 'password'}
value={config.openai_api_key || ''}
onChange={(e) => handleChange('openai_api_key', e.target.value)}
placeholder="sk-proj-..."
className="w-full px-4 py-2 pr-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
/>
<button
type="button"
onClick={() => toggleApiKeyVisibility('openai')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showApiKeys.openai ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>
</div>
<p className="text-sm text-gray-500 mt-1"> https://platform.openai.com/api-keys 获取</p>
</div>
{/* Base URL */}
<div>
<label className="block font-medium text-gray-900 mb-2">
Base URL
</label>
<div className="relative">
<LinkIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
value={config.openai_base_url}
onChange={(e) => handleChange('openai_base_url', e.target.value)}
placeholder="https://api.openai.com/v1"
className="w-full pl-10 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
/>
</div>
<p className="text-xs text-gray-500 mt-1">
完整 endpoint: <code className="bg-gray-100 px-2 py-0.5 rounded">{getCompleteEndpoint('openai')}</code>
</p>
</div>
{/* Model */}
<div>
<label className="block font-medium text-gray-900 mb-2">
模型
</label>
<input
type="text"
list="openai-models"
value={config.openai_model}
onChange={(e) => handleChange('openai_model', e.target.value)}
placeholder="gpt-4o-mini"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
/>
<datalist id="openai-models">
<option value="gpt-4o">gpt-4o (最强)</option>
<option value="gpt-4o-mini">gpt-4o-mini (推荐)</option>
<option value="gpt-4-turbo">gpt-4-turbo</option>
<option value="gpt-3.5-turbo">gpt-3.5-turbo</option>
</datalist>
<p className="text-xs text-gray-500 mt-1">可输入自定义模型名称或从建议中选择</p>
</div>
</div>
{/* Anthropic Configuration */}
<div className={`bg-white rounded-xl shadow-md p-6 space-y-4 ${config.ai_provider === 'anthropic' ? 'ring-2 ring-primary-500' : ''}`}>
<div className="flex items-center gap-2">
<Key className="h-5 w-5 text-orange-600" />
<h2 className="text-xl font-bold text-gray-900">Anthropic 配置</h2>
{config.ai_provider === 'anthropic' && (
<span className="px-2 py-1 text-xs font-medium bg-primary-100 text-primary-700 rounded-full">当前使用</span>
)}
</div>
{/* Text-only warning */}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-sm text-amber-800">
Anthropic 仅支持文本解析不支持 PDF 原生理解PDF 文件将通过文本提取处理可能丢失格式和图片信息
</p>
</div>
{/* API Key */}
<div>
<label className="block font-medium text-gray-900 mb-2">
API Key
</label>
<div className="relative">
<input
type={showApiKeys.anthropic ? 'text' : 'password'}
value={config.anthropic_api_key || ''}
onChange={(e) => handleChange('anthropic_api_key', e.target.value)}
placeholder="sk-ant-..."
className="w-full px-4 py-2 pr-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
/>
<button
type="button"
onClick={() => toggleApiKeyVisibility('anthropic')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showApiKeys.anthropic ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>
</div>
<p className="text-sm text-gray-500 mt-1"> https://console.anthropic.com/settings/keys 获取</p>
</div>
{/* Base URL (fixed for Anthropic) */}
<div>
<label className="block font-medium text-gray-900 mb-2">
Base URL (固定)
</label>
<div className="relative">
<LinkIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
value="https://api.anthropic.com/v1"
disabled
className="w-full pl-10 px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 font-mono text-sm"
/>
</div>
<p className="text-xs text-gray-500 mt-1">
完整 endpoint: <code className="bg-gray-100 px-2 py-0.5 rounded">{getCompleteEndpoint('anthropic')}</code>
</p>
</div>
{/* Model */}
<div>
<label className="block font-medium text-gray-900 mb-2">
模型
</label>
<input
type="text"
list="anthropic-models"
value={config.anthropic_model}
onChange={(e) => handleChange('anthropic_model', e.target.value)}
placeholder="claude-3-haiku-20240307"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
/>
<datalist id="anthropic-models">
<option value="claude-3-5-sonnet-20241022">claude-3-5-sonnet (最强)</option>
<option value="claude-3-haiku-20240307">claude-3-haiku (推荐)</option>
<option value="claude-3-opus-20240229">claude-3-opus</option>
<option value="claude-3-sonnet-20240229">claude-3-sonnet</option>
</datalist>
<p className="text-xs text-gray-500 mt-1">可输入自定义模型名称或从建议中选择</p>
</div>
</div>
{/* Qwen Configuration */}
<div className={`bg-white rounded-xl shadow-md p-6 space-y-4 ${config.ai_provider === 'qwen' ? 'ring-2 ring-primary-500' : ''}`}>
<div className="flex items-center gap-2">
<Key className="h-5 w-5 text-blue-600" />
<h2 className="text-xl font-bold text-gray-900">通义千问 配置</h2>
{config.ai_provider === 'qwen' && (
<span className="px-2 py-1 text-xs font-medium bg-primary-100 text-primary-700 rounded-full">当前使用</span>
)}
</div>
{/* Text-only warning */}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-sm text-amber-800">
通义千问 仅支持文本解析不支持 PDF 原生理解PDF 文件将通过文本提取处理可能丢失格式和图片信息
</p>
</div>
{/* API Key */}
<div>
<label className="block font-medium text-gray-900 mb-2">
API Key
</label>
<div className="relative">
<input
type={showApiKeys.qwen ? 'text' : 'password'}
value={config.qwen_api_key || ''}
onChange={(e) => handleChange('qwen_api_key', e.target.value)}
placeholder="sk-..."
className="w-full px-4 py-2 pr-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
/>
<button
type="button"
onClick={() => toggleApiKeyVisibility('qwen')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showApiKeys.qwen ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>
</div>
<p className="text-sm text-gray-500 mt-1"> https://dashscope.console.aliyun.com/apiKey 获取</p>
</div>
{/* Base URL */}
<div>
<label className="block font-medium text-gray-900 mb-2">
Base URL
</label>
<div className="relative">
<LinkIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
value={config.qwen_base_url}
onChange={(e) => handleChange('qwen_base_url', e.target.value)}
placeholder="https://dashscope.aliyuncs.com/compatible-mode/v1"
className="w-full pl-10 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
/>
</div>
<p className="text-xs text-gray-500 mt-1">
完整 endpoint: <code className="bg-gray-100 px-2 py-0.5 rounded">{getCompleteEndpoint('qwen')}</code>
</p>
</div>
{/* Model */}
<div>
<label className="block font-medium text-gray-900 mb-2">
模型
</label>
<input
type="text"
list="qwen-models"
value={config.qwen_model}
onChange={(e) => handleChange('qwen_model', e.target.value)}
placeholder="qwen-plus"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
/>
<datalist id="qwen-models">
<option value="qwen-max">qwen-max (最强)</option>
<option value="qwen-plus">qwen-plus (推荐)</option>
<option value="qwen-turbo">qwen-turbo (快速)</option>
<option value="qwen-long">qwen-long (长文本)</option>
</datalist>
<p className="text-xs text-gray-500 mt-1">可输入自定义模型名称或从建议中选择</p>
</div>
</div>
{/* Gemini Configuration */}
<div className={`bg-white rounded-xl shadow-md p-6 space-y-4 ${config.ai_provider === 'gemini' ? 'ring-2 ring-primary-500' : ''}`}>
<div className="flex items-center gap-2">
<Key className="h-5 w-5 text-purple-600" />
<h2 className="text-xl font-bold text-gray-900">Google Gemini 配置</h2>
{config.ai_provider === 'gemini' && (
<span className="px-2 py-1 text-xs font-medium bg-primary-100 text-primary-700 rounded-full">当前使用</span>
)}
</div>
{/* PDF support highlight */}
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
<p className="text-sm text-green-800">
Gemini 支持原生 PDF 理解可直接处理 PDF 文件最多 1000 完整保留图片表格公式等内容
</p>
</div>
{/* API Key */}
<div>
<label className="block font-medium text-gray-900 mb-2">
API Key
</label>
<div className="relative">
<input
type={showApiKeys.gemini ? 'text' : 'password'}
value={config.gemini_api_key || ''}
onChange={(e) => handleChange('gemini_api_key', e.target.value)}
placeholder="AIza..."
className="w-full px-4 py-2 pr-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
/>
<button
type="button"
onClick={() => toggleApiKeyVisibility('gemini')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showApiKeys.gemini ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>
</div>
<p className="text-sm text-gray-500 mt-1"> https://aistudio.google.com/apikey 获取</p>
</div>
{/* Base URL (optional) */}
<div>
<label className="block font-medium text-gray-900 mb-2">
Base URL (可选)
</label>
<div className="relative">
<LinkIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
value={config.gemini_base_url}
onChange={(e) => handleChange('gemini_base_url', e.target.value)}
placeholder="https://generativelanguage.googleapis.com留空使用默认"
className="w-full pl-10 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
/>
</div>
<p className="text-xs text-gray-500 mt-1">
可配置自定义代理或中转服务支持 Key 轮训等留空则使用 Google 官方 API
</p>
</div>
{/* Model */}
<div>
<label className="block font-medium text-gray-900 mb-2">
模型
</label>
<input
type="text"
list="gemini-models"
value={config.gemini_model}
onChange={(e) => handleChange('gemini_model', e.target.value)}
placeholder="gemini-2.0-flash-exp"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm"
/>
<datalist id="gemini-models">
<option value="gemini-2.0-flash-exp">gemini-2.0-flash-exp (最新推荐)</option>
<option value="gemini-1.5-pro">gemini-1.5-pro (最强)</option>
<option value="gemini-1.5-flash">gemini-1.5-flash (快速)</option>
<option value="gemini-1.0-pro">gemini-1.0-pro</option>
</datalist>
<p className="text-xs text-gray-500 mt-1">可输入自定义模型名称或从建议中选择</p>
</div>
</div>
{/* Save Button */}
<div className="bg-white rounded-xl shadow-md p-6">
<button
onClick={handleSave}
disabled={saving}
className="w-full bg-primary-600 text-white py-3 rounded-lg font-medium hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{saving ? (
<>
<Loader className="h-5 w-5 animate-spin" />
保存中...
</>
) : (
<>
<Save className="h-5 w-5" />
保存所有设置
</>
)}
</button>
</div>
</div>
</div>
)
}
export default AdminSettings