Your Name a8be7bbca9 feat: 前端剩余页面全面对接后端 API(Phase 2 完成)
为品牌方端(8页)、代理商端(10页)、达人端(6页)共24个页面添加真实API调用:
- 每页新增 USE_MOCK 条件分支,开发环境使用 mock 数据,生产环境调用真实 API
- 添加 loading 骨架屏、error toast 提示、submitting 状态管理
- 数据映射:TaskResponse → 页面视图模型,处理类型差异
- 审核操作(通过/驳回/强制通过)对接 api.reviewScript/reviewVideo
- Brief/规则/AI配置对接 api.getBrief/updateBrief/listForbiddenWords 等
- 申诉/历史/额度管理对接 api.listTasks + 状态过滤映射

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 16:29:43 +08:00

454 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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.

'use client'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { useToast } from '@/components/ui/Toast'
import {
Bot,
Eye,
Mic,
Settings,
CheckCircle,
XCircle,
Loader2,
Info,
Shield,
AlertTriangle,
RefreshCw,
Clock
} from 'lucide-react'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import type { AIProvider, AIConfigResponse, ConnectionTestResponse, ModelInfo } from '@/types/ai-config'
// AI 提供商选项
const providerOptions: { value: AIProvider | string; label: string }[] = [
{ value: 'oneapi', label: 'OneAPI 中转服务' },
{ value: 'anthropic', label: 'Anthropic Claude' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'deepseek', label: 'DeepSeek' },
{ value: 'qwen', label: '通义千问' },
{ value: 'doubao', label: '豆包' },
{ value: 'zhipu', label: '智谱' },
{ value: 'moonshot', label: 'Moonshot' },
]
// Mock 可用模型列表
const mockModels: Record<string, ModelInfo[]> = {
text: [
{ id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5' },
{ id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4' },
{ id: 'gpt-4o', name: 'GPT-4o' },
{ id: 'deepseek-chat', name: 'DeepSeek Chat' },
],
vision: [
{ id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5' },
{ id: 'gpt-4o', name: 'GPT-4o' },
],
audio: [
{ id: 'whisper-large-v3', name: 'Whisper Large V3' },
{ id: 'whisper-medium', name: 'Whisper Medium' },
],
}
type TestStatus = 'idle' | 'testing' | 'success' | 'failed'
function ConfigSkeleton() {
return (
<div className="space-y-6 animate-pulse">
<div className="h-32 bg-bg-elevated rounded-lg" />
<div className="h-48 bg-bg-elevated rounded-lg" />
<div className="h-32 bg-bg-elevated rounded-lg" />
</div>
)
}
export default function AIConfigPage() {
const toast = useToast()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [provider, setProvider] = useState<string>('oneapi')
const [baseUrl, setBaseUrl] = useState('https://oneapi.intelligrow.cn')
const [apiKey, setApiKey] = useState('')
const [showApiKey, setShowApiKey] = useState(false)
const [isConfigured, setIsConfigured] = useState(false)
const [llmModel, setLlmModel] = useState('claude-opus-4-5-20251101')
const [visionModel, setVisionModel] = useState('claude-opus-4-5-20251101')
const [asrModel, setAsrModel] = useState('whisper-large-v3')
const [temperature, setTemperature] = useState(0.7)
const [maxTokens, setMaxTokens] = useState(2000)
const [availableModels, setAvailableModels] = useState<Record<string, ModelInfo[]>>(mockModels)
const [testResults, setTestResults] = useState<Record<string, { status: TestStatus; latency?: number; error?: string }>>({
text: { status: 'idle' },
vision: { status: 'idle' },
audio: { status: 'idle' },
})
const loadConfig = useCallback(async () => {
if (USE_MOCK) {
setLoading(false)
return
}
try {
const config = await api.getAIConfig()
setProvider(config.provider)
setBaseUrl(config.base_url)
setApiKey('') // API key is masked, don't fill it
setIsConfigured(config.is_configured)
setLlmModel(config.models.text)
setVisionModel(config.models.vision)
setAsrModel(config.models.audio)
setTemperature(config.parameters.temperature)
setMaxTokens(config.parameters.max_tokens)
if (config.available_models && Object.keys(config.available_models).length > 0) {
setAvailableModels(config.available_models)
}
} catch (err) {
console.error('Failed to load AI config:', err)
toast.error('加载 AI 配置失败')
} finally {
setLoading(false)
}
}, [toast])
useEffect(() => { loadConfig() }, [loadConfig])
const handleTestConnection = async () => {
setTestResults({
text: { status: 'testing' },
vision: { status: 'testing' },
audio: { status: 'testing' },
})
if (USE_MOCK) {
await new Promise(resolve => setTimeout(resolve, 1500))
setTestResults(prev => ({ ...prev, text: { status: 'success', latency: 320 } }))
await new Promise(resolve => setTimeout(resolve, 1000))
setTestResults(prev => ({ ...prev, vision: { status: 'success', latency: 450 } }))
await new Promise(resolve => setTimeout(resolve, 800))
setTestResults(prev => ({ ...prev, audio: { status: 'success', latency: 280 } }))
return
}
try {
const result: ConnectionTestResponse = await api.testAIConnection({
provider: provider as AIProvider,
base_url: baseUrl,
api_key: apiKey || '***', // use existing key if not changed
models: { text: llmModel, vision: visionModel, audio: asrModel },
})
const newResults: Record<string, { status: TestStatus; latency?: number; error?: string }> = {}
for (const [key, r] of Object.entries(result.results)) {
newResults[key] = {
status: r.success ? 'success' : 'failed',
latency: r.latency_ms ?? undefined,
error: r.error ?? undefined,
}
}
setTestResults(prev => ({ ...prev, ...newResults }))
if (result.success) {
toast.success(result.message)
} else {
toast.error(result.message)
}
} catch (err) {
toast.error('连接测试失败')
setTestResults({
text: { status: 'failed', error: '请求失败' },
vision: { status: 'failed', error: '请求失败' },
audio: { status: 'failed', error: '请求失败' },
})
}
}
const handleSave = async () => {
setSaving(true)
try {
if (USE_MOCK) {
await new Promise(resolve => setTimeout(resolve, 500))
} else {
await api.updateAIConfig({
provider: provider as AIProvider,
base_url: baseUrl,
api_key: apiKey || '***',
models: { text: llmModel, vision: visionModel, audio: asrModel },
parameters: { temperature, max_tokens: maxTokens },
})
}
toast.success('配置已保存')
} catch (err) {
toast.error('保存失败')
} finally {
setSaving(false)
}
}
const getTestStatusIcon = (key: string) => {
const result = testResults[key]
if (!result) return null
switch (result.status) {
case 'testing':
return <Loader2 size={16} className="text-blue-500 animate-spin" />
case 'success':
return (
<span className="flex items-center gap-1">
<CheckCircle size={16} className="text-green-500" />
{result.latency && <span className="text-xs text-text-tertiary">{result.latency}ms</span>}
</span>
)
case 'failed':
return (
<span className="flex items-center gap-1">
<XCircle size={16} className="text-red-500" />
{result.error && <span className="text-xs text-accent-coral">{result.error}</span>}
</span>
)
default:
return null
}
}
if (loading) {
return (
<div className="space-y-6 max-w-4xl">
<h1 className="text-2xl font-bold text-text-primary">AI </h1>
<ConfigSkeleton />
</div>
)
}
return (
<div className="space-y-6 max-w-4xl">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">AI </h1>
<p className="text-sm text-text-secondary mt-1"> AI </p>
</div>
{isConfigured && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-accent-green/15 border border-accent-green/30">
<CheckCircle size={16} className="text-accent-green" />
<span className="text-sm font-medium text-accent-green"></span>
</div>
)}
</div>
{/* 配置继承提示 */}
<div className="p-4 bg-accent-indigo/10 rounded-lg border border-accent-indigo/30">
<div className="flex items-start gap-3">
<Info size={20} className="text-accent-indigo flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-accent-indigo font-medium"></p>
<p className="text-sm text-accent-indigo/80 mt-1">
使
</p>
</div>
</div>
</div>
{/* AI 提供商 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bot size={18} className="text-blue-500" />
AI
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1"></label>
<select
className="w-full px-3 py-2 border border-border-subtle rounded-lg bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
value={provider}
onChange={(e) => setProvider(e.target.value)}
>
{providerOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<p className="text-xs text-text-tertiary mt-1">
使 OneAPI 便 AI
</p>
</div>
</CardContent>
</Card>
{/* 模型配置 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Settings size={18} className="text-purple-500" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* 文字处理模型 */}
<div className="p-4 bg-bg-elevated rounded-lg">
<div className="flex items-center gap-2 mb-3">
<Bot size={16} className="text-accent-indigo" />
<span className="font-medium text-text-primary"> (LLM)</span>
{getTestStatusIcon('text')}
</div>
<select
className="w-full px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
value={llmModel}
onChange={(e) => setLlmModel(e.target.value)}
>
{(availableModels.text || []).map(model => (
<option key={model.id} value={model.id}>{model.name}</option>
))}
</select>
<p className="text-xs text-text-tertiary mt-2"> Brief </p>
</div>
{/* 视频分析模型 */}
<div className="p-4 bg-bg-elevated rounded-lg">
<div className="flex items-center gap-2 mb-3">
<Eye size={16} className="text-accent-green" />
<span className="font-medium text-text-primary"> (Vision)</span>
{getTestStatusIcon('vision')}
</div>
<select
className="w-full px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
value={visionModel}
onChange={(e) => setVisionModel(e.target.value)}
>
{(availableModels.vision || []).map(model => (
<option key={model.id} value={model.id}>{model.name}</option>
))}
</select>
<p className="text-xs text-text-tertiary mt-2">/</p>
</div>
{/* 音频解析模型 */}
<div className="p-4 bg-bg-elevated rounded-lg">
<div className="flex items-center gap-2 mb-3">
<Mic size={16} className="text-orange-400" />
<span className="font-medium text-text-primary"> (ASR)</span>
{getTestStatusIcon('audio')}
</div>
<select
className="w-full px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
value={asrModel}
onChange={(e) => setAsrModel(e.target.value)}
>
{(availableModels.audio || []).map(model => (
<option key={model.id} value={model.id}>{model.name}</option>
))}
</select>
<p className="text-xs text-text-tertiary mt-2"></p>
</div>
</CardContent>
</Card>
{/* 连接配置 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Base URL</label>
<input
type="text"
className="w-full px-3 py-2 border border-border-subtle rounded-lg bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="https://api.openai.com/v1"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">API Key</label>
<div className="flex gap-2">
<input
type={showApiKey ? 'text' : 'password'}
className="flex-1 px-3 py-2 border border-border-subtle rounded-lg bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={isConfigured ? '留空使用已保存的密钥' : 'sk-...'}
/>
<Button
variant="secondary"
size="sm"
onClick={() => setShowApiKey(!showApiKey)}
>
{showApiKey ? '隐藏' : '显示'}
</Button>
</div>
</div>
</CardContent>
</Card>
{/* 生成参数 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-text-primary">Temperature</label>
<span className="text-sm text-text-secondary">{temperature}</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.1"
value={temperature}
onChange={(e) => setTemperature(parseFloat(e.target.value))}
className="w-full h-2 bg-bg-elevated rounded-lg appearance-none cursor-pointer accent-accent-indigo"
/>
<div className="flex justify-between text-xs text-text-tertiary mt-1">
<span> (0)</span>
<span> (1)</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-2">Max Tokens</label>
<input
type="number"
className="w-32 px-3 py-2 border border-border-subtle rounded-lg bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
value={maxTokens}
onChange={(e) => setMaxTokens(parseInt(e.target.value))}
min="100"
max="8000"
/>
</div>
</CardContent>
</Card>
{/* 安全说明 */}
<div className="p-4 bg-bg-elevated rounded-lg border border-border-subtle">
<div className="flex items-start gap-3">
<Shield size={20} className="text-text-tertiary flex-shrink-0 mt-0.5" />
<div className="text-sm text-text-secondary">
<p className="font-medium text-text-primary mb-1"></p>
<ul className="space-y-1 text-xs">
<li> API Key 使 AES-256-GCM </li>
<li> API 使 HTTPS</li>
<li> /</li>
<li> </li>
</ul>
</div>
</div>
</div>
{/* 操作按钮 */}
<div className="flex items-center justify-between pt-4 border-t border-border-subtle">
<Button variant="secondary" onClick={handleTestConnection}>
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <><Loader2 size={16} className="animate-spin" /> ...</> : '保存配置'}
</Button>
</div>
</div>
)
}