Your Name 0ef7650c09 feat: 审核体系全面改造 — 多维度评分 + 卖点优先级 + AI 语义匹配 + 品牌方 AI 状态通知
后端:
- 审核结果拆分为 4 个独立维度 (法规合规/平台规则/品牌安全/Brief匹配度)
- 卖点优先级从 required:bool 改为三级 (core/recommended/reference)
- AI 语义匹配卖点覆盖 + AI 整体 Brief 匹配度分析
- BriefMatchDetail 评分详情 (覆盖率+亮点+问题点)
- min_selling_points 代理商可配置最少卖点数 + Alembic 迁移
- AI 语境复核过滤误报
- Brief AI 解析 + 规则 AI 解析
- AI 未配置/异常时通知品牌方
- 种子数据更新 (新格式审核结果+brief_match_detail)

前端:
- 三端审核页面展示四维度评分卡片
- 卖点编辑改为三级优先级选择器
- BriefMatchDetail 展示 (覆盖率进度条+亮点+问题)
- min_selling_points 配置 UI
- AI 配置页未配置时静默处理
- 文件预览/下载/签名 URL 优化

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 19:11:54 +08:00

461 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: any) {
// 后端 404 返回 "AI 服务未配置" → Axios 拦截器转为 Error(message)
// 这是正常的"尚未配置"状态,不弹错误
const msg = err?.message || ''
if (msg.includes('未配置')) {
setIsConfigured(false)
} else {
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>
)
}