feat: 完善代理商端业务逻辑与前后端框架
主要更新: - 更新代理商端文档,明确项目由品牌方分配流程 - 新增Brief配置详情页(已配置)设计稿 - 完善工作台紧急待办中品牌新任务功能 - 整理Pencil设计文件中代理商端页面顺序 - 新增后端FastAPI框架及核心API - 新增前端Next.js页面和组件库 - 添加.gitignore排除构建和缓存文件 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
d52509d630
commit
e4959d584f
@@ -0,0 +1,335 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { SuccessTag, ErrorTag, PendingTag } from '@/components/ui/Tag'
|
||||
import {
|
||||
Bot,
|
||||
Eye,
|
||||
Mic,
|
||||
Settings,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Info,
|
||||
Shield,
|
||||
AlertTriangle
|
||||
} from 'lucide-react'
|
||||
|
||||
// AI 提供商选项
|
||||
const providerOptions = [
|
||||
{ value: 'oneapi', label: 'OneAPI 中转服务' },
|
||||
{ value: 'anthropic', label: 'Anthropic Claude' },
|
||||
{ value: 'openai', label: 'OpenAI' },
|
||||
{ value: 'deepseek', label: 'DeepSeek' },
|
||||
{ value: 'custom', label: '自定义' },
|
||||
]
|
||||
|
||||
// 模拟可用模型列表
|
||||
const availableModels = {
|
||||
llm: [
|
||||
{ value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5', tags: ['推荐', '高性能'] },
|
||||
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4', tags: ['性价比'] },
|
||||
{ value: 'gpt-4o', label: 'GPT-4o', tags: ['文字', '视觉'] },
|
||||
{ value: 'deepseek-chat', label: 'DeepSeek Chat', tags: ['高性价比'] },
|
||||
],
|
||||
vision: [
|
||||
{ value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5', tags: ['推荐'] },
|
||||
{ value: 'gpt-4o', label: 'GPT-4o', tags: ['视觉'] },
|
||||
{ value: 'doubao-seed-1.6-thinking-vision', label: '豆包 Vision', tags: ['中文优化'] },
|
||||
],
|
||||
asr: [
|
||||
{ value: 'whisper-large-v3', label: 'Whisper Large V3', tags: ['推荐'] },
|
||||
{ value: 'whisper-medium', label: 'Whisper Medium', tags: ['快速'] },
|
||||
{ value: 'paraformer-zh', label: '达摩院 Paraformer', tags: ['中文优化'] },
|
||||
],
|
||||
}
|
||||
|
||||
type TestResult = {
|
||||
llm: 'idle' | 'testing' | 'success' | 'failed'
|
||||
vision: 'idle' | 'testing' | 'success' | 'failed'
|
||||
asr: 'idle' | 'testing' | 'success' | 'failed'
|
||||
}
|
||||
|
||||
export default function AIConfigPage() {
|
||||
const [provider, setProvider] = useState('oneapi')
|
||||
const [baseUrl, setBaseUrl] = useState('https://oneapi.intelligrow.cn')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [showApiKey, setShowApiKey] = 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 [testResults, setTestResults] = useState<TestResult>({
|
||||
llm: 'idle',
|
||||
vision: 'idle',
|
||||
asr: 'idle',
|
||||
})
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
// 模拟测试连接
|
||||
setTestResults({ llm: 'testing', vision: 'testing', asr: 'testing' })
|
||||
|
||||
// 模拟延迟
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
setTestResults(prev => ({ ...prev, llm: 'success' }))
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
setTestResults(prev => ({ ...prev, vision: 'success' }))
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 800))
|
||||
setTestResults(prev => ({ ...prev, asr: 'success' }))
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
alert('配置已保存')
|
||||
}
|
||||
|
||||
const getTestStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'testing':
|
||||
return <Loader2 size={16} className="text-blue-500 animate-spin" />
|
||||
case 'success':
|
||||
return <CheckCircle size={16} className="text-green-500" />
|
||||
case 'failed':
|
||||
return <XCircle size={16} className="text-red-500" />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
</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、Anthropic Claude、OpenAI、DeepSeek 等提供商
|
||||
</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(testResults.llm)}
|
||||
</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.llm.map(model => (
|
||||
<option key={model.value} value={model.value}>
|
||||
{model.label} [{model.tags.join(', ')}]
|
||||
</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(testResults.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.value} value={model.value}>
|
||||
{model.label} [{model.tags.join(', ')}]
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-text-tertiary mt-2">用于画面语义分析、场景/风险识别(Logo 检测由系统内置 CV 完成)</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(testResults.asr)}
|
||||
</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.asr.map(model => (
|
||||
<option key={model.value} value={model.value}>
|
||||
{model.label} [{model.tags.join(', ')}]
|
||||
</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="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}>
|
||||
保存配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Plus, FileText, Upload, Trash2, Edit } from 'lucide-react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { SuccessTag, PendingTag } from '@/components/ui/Tag'
|
||||
|
||||
// 模拟 Brief 列表
|
||||
const mockBriefs = [
|
||||
{
|
||||
id: 'brief-001',
|
||||
name: '2024 夏日护肤活动',
|
||||
description: '夏日护肤系列产品推广规范',
|
||||
status: 'active',
|
||||
rulesCount: 12,
|
||||
creatorsCount: 45,
|
||||
createdAt: '2024-01-15',
|
||||
updatedAt: '2024-02-01',
|
||||
},
|
||||
{
|
||||
id: 'brief-002',
|
||||
name: '新品口红上市',
|
||||
description: '春季新品口红营销 Brief',
|
||||
status: 'active',
|
||||
rulesCount: 8,
|
||||
creatorsCount: 32,
|
||||
createdAt: '2024-02-01',
|
||||
updatedAt: '2024-02-03',
|
||||
},
|
||||
{
|
||||
id: 'brief-003',
|
||||
name: '年货节活动',
|
||||
description: '春节年货促销活动规范',
|
||||
status: 'archived',
|
||||
rulesCount: 15,
|
||||
creatorsCount: 78,
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-20',
|
||||
},
|
||||
]
|
||||
|
||||
export default function BriefsPage() {
|
||||
const [briefs] = useState(mockBriefs)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
const filteredBriefs = briefs.filter((brief) =>
|
||||
brief.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Brief 管理</h1>
|
||||
<Button icon={Plus} onClick={() => setShowCreateModal(true)}>
|
||||
新建 Brief
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 搜索 */}
|
||||
<div className="max-w-md">
|
||||
<Input
|
||||
placeholder="搜索 Brief..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Brief 列表 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredBriefs.map((brief) => (
|
||||
<Card key={brief.id} className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="p-2 bg-blue-50 rounded-lg">
|
||||
<FileText size={24} className="text-blue-600" />
|
||||
</div>
|
||||
{brief.status === 'active' ? (
|
||||
<SuccessTag>使用中</SuccessTag>
|
||||
) : (
|
||||
<PendingTag>已归档</PendingTag>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="font-semibold text-gray-900 mb-1">{brief.name}</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">{brief.description}</p>
|
||||
|
||||
<div className="flex gap-4 text-sm text-gray-500 mb-4">
|
||||
<span>{brief.rulesCount} 条规则</span>
|
||||
<span>{brief.creatorsCount} 位达人</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-3 border-t">
|
||||
<span className="text-xs text-gray-400">
|
||||
更新于 {brief.updatedAt}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" className="p-1 hover:bg-gray-100 rounded">
|
||||
<Edit size={16} className="text-gray-500" />
|
||||
</button>
|
||||
<button type="button" className="p-1 hover:bg-gray-100 rounded">
|
||||
<Trash2 size={16} className="text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* 新建卡片 */}
|
||||
<Card
|
||||
className="border-dashed cursor-pointer hover:border-blue-400 hover:bg-blue-50/50 transition-colors"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
>
|
||||
<CardContent className="p-5 flex flex-col items-center justify-center h-full min-h-[200px]">
|
||||
<div className="p-3 bg-gray-100 rounded-full mb-3">
|
||||
<Plus size={24} className="text-gray-500" />
|
||||
</div>
|
||||
<span className="text-gray-500">新建 Brief</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 新建 Brief 弹窗 */}
|
||||
<Modal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
title="新建 Brief"
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Input label="Brief 名称" placeholder="输入 Brief 名称" />
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">描述</label>
|
||||
<textarea
|
||||
className="w-full h-20 p-3 border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="输入 Brief 描述..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 上传 PDF */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
上传 Brief 文档(可选)
|
||||
</label>
|
||||
<div className="border-2 border-dashed rounded-lg p-6 text-center hover:border-blue-400 transition-colors cursor-pointer">
|
||||
<Upload size={32} className="mx-auto text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-600">点击或拖拽上传 PDF 文件</p>
|
||||
<p className="text-xs text-gray-400 mt-1">AI 将自动提取规则</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end pt-4">
|
||||
<Button variant="ghost" onClick={() => setShowCreateModal(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={() => setShowCreateModal(false)}>
|
||||
创建
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { ArrowLeft, Check, X, CheckSquare, Video, Clock } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// 模拟待审核内容列表
|
||||
const mockReviewItems = [
|
||||
{
|
||||
id: 'review-001',
|
||||
title: '春季护肤新品体验分享',
|
||||
creator: '小美',
|
||||
agency: '代理商A',
|
||||
reviewer: '张三',
|
||||
reviewTime: '2小时前',
|
||||
agencyOpinion: '内容符合Brief要求,卖点覆盖完整,建议通过。',
|
||||
agencyStatus: 'passed',
|
||||
aiScore: 12,
|
||||
aiChecks: [
|
||||
{ label: '合规检测', status: 'passed', description: '未检测到违禁词、竞品Logo等违规内容' },
|
||||
{ label: '卖点覆盖', status: 'passed', description: '核心卖点覆盖率 95%' },
|
||||
{ label: '品牌调性', status: 'passed', description: '视觉风格符合品牌调性' },
|
||||
],
|
||||
currentStep: 4, // 1-已提交, 2-AI审核, 3-代理商审核, 4-品牌终审
|
||||
},
|
||||
{
|
||||
id: 'review-002',
|
||||
title: '夏日清爽护肤推荐',
|
||||
creator: '小红',
|
||||
agency: '代理商B',
|
||||
reviewer: '李四',
|
||||
reviewTime: '5小时前',
|
||||
agencyOpinion: '内容质量良好,但部分镜头略暗,建议后期调整后通过。',
|
||||
agencyStatus: 'passed',
|
||||
aiScore: 28,
|
||||
aiChecks: [
|
||||
{ label: '合规检测', status: 'passed', description: '未检测到违规内容' },
|
||||
{ label: '卖点覆盖', status: 'warning', description: '核心卖点覆盖率 78%,建议增加产品特写' },
|
||||
{ label: '品牌调性', status: 'passed', description: '视觉风格符合品牌调性' },
|
||||
],
|
||||
currentStep: 4,
|
||||
},
|
||||
]
|
||||
|
||||
// 审核流程进度组件
|
||||
function ReviewProgressBar({ currentStep }: { currentStep: number }) {
|
||||
const steps = [
|
||||
{ label: '已提交', step: 1 },
|
||||
{ label: 'AI审核', step: 2 },
|
||||
{ label: '代理商审核', step: 3 },
|
||||
{ label: '品牌终审', step: 4 },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex items-center w-full">
|
||||
{steps.map((s, index) => {
|
||||
const isCompleted = s.step < currentStep
|
||||
const isCurrent = s.step === currentStep
|
||||
|
||||
return (
|
||||
<div key={s.step} className="flex items-center flex-1">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className={cn(
|
||||
'flex items-center justify-center rounded-[10px]',
|
||||
isCurrent ? 'w-6 h-6 bg-accent-indigo' :
|
||||
isCompleted ? 'w-5 h-5 bg-accent-green' :
|
||||
'w-5 h-5 bg-bg-elevated border border-border-subtle'
|
||||
)}>
|
||||
{isCompleted && <Check className="w-3 h-3 text-white" />}
|
||||
{isCurrent && <Clock className="w-3 h-3 text-white" />}
|
||||
</div>
|
||||
<span className={cn(
|
||||
'text-[10px]',
|
||||
isCurrent ? 'text-accent-indigo font-semibold' :
|
||||
isCompleted ? 'text-text-secondary' :
|
||||
'text-text-tertiary'
|
||||
)}>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div className={cn(
|
||||
'h-0.5 flex-1 rounded',
|
||||
s.step < currentStep ? 'bg-accent-green' :
|
||||
s.step === currentStep ? 'bg-accent-indigo' :
|
||||
'bg-border-subtle'
|
||||
)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function FinalReviewPage() {
|
||||
const router = useRouter()
|
||||
const [selectedItem, setSelectedItem] = useState(mockReviewItems[0])
|
||||
const [feedback, setFeedback] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const handleApprove = async () => {
|
||||
setIsSubmitting(true)
|
||||
// 模拟提交
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
alert('已通过审核')
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
const handleReject = async () => {
|
||||
if (!feedback.trim()) {
|
||||
alert('请填写驳回原因')
|
||||
return
|
||||
}
|
||||
setIsSubmitting(true)
|
||||
// 模拟提交
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
alert('已驳回')
|
||||
setIsSubmitting(false)
|
||||
setFeedback('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 h-full">
|
||||
{/* 顶部栏 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-bold text-text-primary">终审台</h1>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{selectedItem.title} · 达人: {selectedItem.creator}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-bg-elevated text-text-secondary text-sm font-medium"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
返回列表
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 审核流程进度 */}
|
||||
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-semibold text-text-primary">审核流程</span>
|
||||
<span className="text-xs text-accent-indigo font-medium">当前:品牌终审</span>
|
||||
</div>
|
||||
<ReviewProgressBar currentStep={selectedItem.currentStep} />
|
||||
</div>
|
||||
|
||||
{/* 主内容区 - 两栏布局 */}
|
||||
<div className="flex gap-6 flex-1 min-h-0">
|
||||
{/* 左侧 - 视频播放器 */}
|
||||
<div className="flex-1 flex flex-col gap-4">
|
||||
<div className="flex-1 bg-bg-card rounded-2xl card-shadow flex items-center justify-center">
|
||||
<div className="w-[640px] h-[360px] rounded-xl bg-black flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-20 h-20 rounded-full bg-[#1A1A1E] flex items-center justify-center">
|
||||
<Video className="w-10 h-10 text-text-tertiary" />
|
||||
</div>
|
||||
<p className="text-sm text-text-tertiary">视频播放器</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧 - 分析面板 */}
|
||||
<div className="w-[380px] flex flex-col gap-4 overflow-auto">
|
||||
{/* 代理商初审意见 */}
|
||||
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-base font-semibold text-text-primary">代理商初审意见</span>
|
||||
<span className={cn(
|
||||
'px-3 py-1.5 rounded-lg text-[13px] font-semibold',
|
||||
selectedItem.agencyStatus === 'passed' ? 'bg-accent-green/15 text-accent-green' : 'bg-accent-coral/15 text-accent-coral'
|
||||
)}>
|
||||
{selectedItem.agencyStatus === 'passed' ? '已通过' : '需修改'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-bg-elevated rounded-[10px] p-3 flex flex-col gap-2">
|
||||
<span className="text-xs text-text-tertiary">
|
||||
审核人:{selectedItem.agency} - {selectedItem.reviewer} · {selectedItem.reviewTime}
|
||||
</span>
|
||||
<p className="text-[13px] text-text-secondary">{selectedItem.agencyOpinion}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI 分析结果 */}
|
||||
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-base font-semibold text-text-primary">AI 分析结果</span>
|
||||
<span className={cn(
|
||||
'px-3 py-1.5 rounded-lg text-[13px] font-semibold',
|
||||
selectedItem.aiScore < 30 ? 'bg-accent-green/15 text-accent-green' : 'bg-accent-amber/15 text-accent-amber'
|
||||
)}>
|
||||
风险评分: {selectedItem.aiScore}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{selectedItem.aiChecks.map((check, index) => (
|
||||
<div key={index} className="bg-bg-elevated rounded-[10px] p-3 flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckSquare className={cn(
|
||||
'w-4 h-4',
|
||||
check.status === 'passed' ? 'text-accent-green' : 'text-accent-amber'
|
||||
)} />
|
||||
<span className={cn(
|
||||
'text-sm font-semibold',
|
||||
check.status === 'passed' ? 'text-accent-green' : 'text-accent-amber'
|
||||
)}>
|
||||
{check.label} · {check.status === 'passed' ? '通过' : '警告'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[13px] text-text-secondary">{check.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 终审决策 */}
|
||||
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
|
||||
<h3 className="text-base font-semibold text-text-primary mb-4">终审决策</h3>
|
||||
|
||||
{/* 决策按钮 */}
|
||||
<div className="flex gap-3 mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleApprove}
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 flex items-center justify-center gap-2 py-3.5 rounded-xl bg-accent-green text-white font-semibold disabled:opacity-50"
|
||||
>
|
||||
<Check className="w-[18px] h-[18px]" />
|
||||
通过
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReject}
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 flex items-center justify-center gap-2 py-3.5 rounded-xl bg-accent-coral text-white font-semibold disabled:opacity-50"
|
||||
>
|
||||
<X className="w-[18px] h-[18px]" />
|
||||
驳回
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 终审意见 */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-[13px] font-medium text-text-secondary">
|
||||
终审意见(可选)
|
||||
</label>
|
||||
<textarea
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
placeholder="输入终审意见或修改建议..."
|
||||
className="w-full h-20 p-3.5 rounded-xl bg-bg-elevated border border-border-subtle text-sm text-text-primary placeholder-text-tertiary resize-none focus:outline-none focus:ring-2 focus:ring-accent-indigo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { DesktopLayout } from '@/components/layout/DesktopLayout'
|
||||
import { AuthGuard } from '@/components/auth/AuthGuard'
|
||||
|
||||
export default function BrandLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<AuthGuard allowedRoles={['brand']}>
|
||||
<DesktopLayout role="brand">
|
||||
{children}
|
||||
</DesktopLayout>
|
||||
</AuthGuard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||
import { SuccessTag, WarningTag, ErrorTag } from '@/components/ui/Tag'
|
||||
import { ProgressBar } from '@/components/ui/ProgressBar'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
BarChart3,
|
||||
Target,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
ChevronRight,
|
||||
Shield,
|
||||
Users,
|
||||
FileVideo
|
||||
} from 'lucide-react'
|
||||
|
||||
// 模拟核心指标
|
||||
const metrics = {
|
||||
totalReviews: 1234,
|
||||
totalTrend: '+12%',
|
||||
passRate: 78.5,
|
||||
passRateTrend: '+5.2%',
|
||||
hardRecall: 96.2,
|
||||
hardRecallTarget: 95,
|
||||
sentimentBlocks: 23,
|
||||
sentimentTrend: '-18%',
|
||||
avgCycle: 4.2,
|
||||
avgCycleTarget: 5,
|
||||
}
|
||||
|
||||
// 模拟趋势数据
|
||||
const weeklyData = [
|
||||
{ day: '周一', submitted: 45, passed: 40, failed: 5 },
|
||||
{ day: '周二', submitted: 52, passed: 48, failed: 4 },
|
||||
{ day: '周三', submitted: 38, passed: 35, failed: 3 },
|
||||
{ day: '周四', submitted: 61, passed: 54, failed: 7 },
|
||||
{ day: '周五', submitted: 55, passed: 50, failed: 5 },
|
||||
{ day: '周六', submitted: 28, passed: 26, failed: 2 },
|
||||
{ day: '周日', submitted: 22, passed: 20, failed: 2 },
|
||||
]
|
||||
|
||||
// 模拟违规类型分布
|
||||
const violationTypes = [
|
||||
{ type: '违禁词', count: 156, percentage: 45, color: 'bg-red-500' },
|
||||
{ type: '竞品露出', count: 89, percentage: 26, color: 'bg-orange-500' },
|
||||
{ type: '卖点遗漏', count: 67, percentage: 19, color: 'bg-yellow-500' },
|
||||
{ type: '舆情风险', count: 34, percentage: 10, color: 'bg-purple-500' },
|
||||
]
|
||||
|
||||
// 模拟代理商排名
|
||||
const agencyRanking = [
|
||||
{ name: '星耀传媒', passRate: 92, reviews: 156, trend: 'up' },
|
||||
{ name: '创意无限', passRate: 88, reviews: 134, trend: 'up' },
|
||||
{ name: '美妆达人MCN', passRate: 82, reviews: 98, trend: 'down' },
|
||||
{ name: '时尚风向标', passRate: 78, reviews: 87, trend: 'stable' },
|
||||
]
|
||||
|
||||
// 模拟风险预警
|
||||
const riskAlerts = [
|
||||
{
|
||||
id: 'alert-001',
|
||||
level: 'high',
|
||||
title: '代理商A竞品露出集中',
|
||||
description: '过去24小时内5条视频触发"竞品露出"',
|
||||
time: '10分钟前',
|
||||
},
|
||||
{
|
||||
id: 'alert-002',
|
||||
level: 'medium',
|
||||
title: '达人B连续未通过',
|
||||
description: '连续3次提交未通过,建议沟通',
|
||||
time: '2小时前',
|
||||
},
|
||||
{
|
||||
id: 'alert-003',
|
||||
level: 'low',
|
||||
title: '舆情风险上升',
|
||||
description: '本周舆情风险拦截数异常上升,建议检查阈值',
|
||||
time: '5小时前',
|
||||
},
|
||||
]
|
||||
|
||||
function MetricCard({
|
||||
title,
|
||||
value,
|
||||
unit = '',
|
||||
trend,
|
||||
target,
|
||||
icon: Icon,
|
||||
color,
|
||||
}: {
|
||||
title: string
|
||||
value: number | string
|
||||
unit?: string
|
||||
trend?: string
|
||||
target?: number
|
||||
icon: React.ElementType
|
||||
color: string
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="text-sm text-text-secondary mb-1">{title}</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className={`text-3xl font-bold ${color}`}>{value}</span>
|
||||
{unit && <span className="text-lg text-text-secondary">{unit}</span>}
|
||||
</div>
|
||||
{trend && (
|
||||
<div className={`text-xs mt-1 flex items-center gap-1 ${
|
||||
trend.includes('+') || trend.includes('↓') ? 'text-accent-green' : trend.includes('-') ? 'text-accent-coral' : 'text-text-secondary'
|
||||
}`}>
|
||||
{trend.includes('+') ? <TrendingUp size={12} /> : trend.includes('-') && !trend.includes('↓') ? <TrendingDown size={12} /> : null}
|
||||
{trend} vs 上周
|
||||
</div>
|
||||
)}
|
||||
{target && (
|
||||
<div className="text-xs text-text-tertiary mt-1">
|
||||
目标 ≥{target}{unit} {Number(value) >= target ? '✅' : '⚠️'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`w-12 h-12 rounded-lg ${color.replace('text-', 'bg-').replace('600', '').replace('900', '')}/20 flex items-center justify-center`}>
|
||||
<Icon size={24} className={color} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertLevelIcon({ level }: { level: string }) {
|
||||
if (level === 'high') return <AlertTriangle size={16} className="text-red-500" />
|
||||
if (level === 'medium') return <AlertTriangle size={16} className="text-orange-500" />
|
||||
return <AlertTriangle size={16} className="text-yellow-500" />
|
||||
}
|
||||
|
||||
export default function BrandDashboard() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-text-primary">数据看板</h1>
|
||||
<div className="text-sm text-text-secondary">更新时间:{new Date().toLocaleString('zh-CN')}</div>
|
||||
</div>
|
||||
|
||||
{/* 核心指标卡片 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<MetricCard
|
||||
title="本月审核总量"
|
||||
value={metrics.totalReviews}
|
||||
trend={metrics.totalTrend}
|
||||
icon={FileVideo}
|
||||
color="text-text-primary"
|
||||
/>
|
||||
<MetricCard
|
||||
title="初审通过率"
|
||||
value={metrics.passRate}
|
||||
unit="%"
|
||||
trend={metrics.passRateTrend}
|
||||
icon={Target}
|
||||
color="text-accent-green"
|
||||
/>
|
||||
<MetricCard
|
||||
title="硬性召回率"
|
||||
value={metrics.hardRecall}
|
||||
unit="%"
|
||||
target={metrics.hardRecallTarget}
|
||||
icon={Shield}
|
||||
color="text-accent-indigo"
|
||||
/>
|
||||
<MetricCard
|
||||
title="舆情拦截数"
|
||||
value={metrics.sentimentBlocks}
|
||||
trend={metrics.sentimentTrend + ' ↓'}
|
||||
icon={AlertTriangle}
|
||||
color="text-purple-400"
|
||||
/>
|
||||
<MetricCard
|
||||
title="平均审核周期"
|
||||
value={metrics.avgCycle}
|
||||
unit="小时"
|
||||
target={metrics.avgCycleTarget}
|
||||
icon={Clock}
|
||||
color="text-orange-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* 本周趋势 */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BarChart3 size={18} className="text-blue-500" />
|
||||
本周审核趋势
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{weeklyData.map((day) => (
|
||||
<div key={day.day} className="flex items-center gap-4">
|
||||
<div className="w-12 text-sm text-text-secondary font-medium">{day.day}</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex h-6 rounded-full overflow-hidden bg-bg-elevated">
|
||||
<div
|
||||
className="bg-accent-green transition-all"
|
||||
style={{ width: `${(day.passed / day.submitted) * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="bg-accent-coral transition-all"
|
||||
style={{ width: `${(day.failed / day.submitted) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-24 text-right text-sm">
|
||||
<span className="text-accent-green font-medium">{day.passed}</span>
|
||||
<span className="text-text-tertiary"> / </span>
|
||||
<span className="text-text-secondary">{day.submitted}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-6 mt-4 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-accent-green rounded" />
|
||||
<span className="text-text-secondary">通过</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-accent-coral rounded" />
|
||||
<span className="text-text-secondary">驳回</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 风险预警 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertTriangle size={18} className="text-red-500" />
|
||||
风险预警
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{riskAlerts.map((alert) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className={`p-3 rounded-lg border cursor-pointer hover:shadow-sm transition-shadow ${
|
||||
alert.level === 'high'
|
||||
? 'bg-accent-coral/10 border-accent-coral/30'
|
||||
: alert.level === 'medium'
|
||||
? 'bg-orange-500/10 border-orange-500/30'
|
||||
: 'bg-yellow-500/10 border-yellow-500/30'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertLevelIcon level={alert.level} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-text-primary text-sm">{alert.title}</div>
|
||||
<div className="text-xs text-text-secondary mt-0.5">{alert.description}</div>
|
||||
<div className="text-xs text-text-tertiary mt-1">{alert.time}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button variant="ghost" fullWidth size="sm">
|
||||
查看全部预警
|
||||
<ChevronRight size={14} />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* 违规类型分布 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>违规类型分布</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{violationTypes.map((item) => (
|
||||
<div key={item.type}>
|
||||
<div className="flex justify-between text-sm mb-2">
|
||||
<span className="text-text-primary font-medium">{item.type}</span>
|
||||
<span className="text-text-secondary">{item.count} 次 ({item.percentage}%)</span>
|
||||
</div>
|
||||
<div className="h-2 bg-bg-elevated rounded-full overflow-hidden">
|
||||
<div className={`h-full ${item.color} transition-all`} style={{ width: `${item.percentage}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 代理商排名 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users size={18} className="text-blue-500" />
|
||||
代理商通过率排名
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{agencyRanking.map((agency, index) => (
|
||||
<div key={agency.name} className="flex items-center gap-4 p-3 rounded-lg bg-bg-elevated">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold ${
|
||||
index === 0 ? 'bg-yellow-500/20 text-yellow-400' :
|
||||
index === 1 ? 'bg-gray-500/20 text-gray-400' :
|
||||
index === 2 ? 'bg-orange-500/20 text-orange-400' : 'bg-bg-page text-text-tertiary'
|
||||
}`}>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-text-primary">{agency.name}</div>
|
||||
<div className="text-xs text-text-secondary">{agency.reviews} 条审核</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className={`font-bold ${agency.passRate >= 90 ? 'text-accent-green' : agency.passRate >= 80 ? 'text-accent-indigo' : 'text-orange-400'}`}>
|
||||
{agency.passRate}%
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-1 text-xs">
|
||||
{agency.trend === 'up' && <TrendingUp size={12} className="text-accent-green" />}
|
||||
{agency.trend === 'down' && <TrendingDown size={12} className="text-accent-coral" />}
|
||||
<span className="text-text-tertiary">
|
||||
{agency.trend === 'up' ? '上升' : agency.trend === 'down' ? '下降' : '持平'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Download, Calendar, Filter } from 'lucide-react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { SuccessTag, WarningTag, ErrorTag } from '@/components/ui/Tag'
|
||||
|
||||
// 模拟报表数据
|
||||
const mockReportData = [
|
||||
{ id: '1', date: '2024-02-04', submitted: 45, passed: 40, failed: 5, avgScore: 82 },
|
||||
{ id: '2', date: '2024-02-03', submitted: 52, passed: 48, failed: 4, avgScore: 85 },
|
||||
{ id: '3', date: '2024-02-02', submitted: 38, passed: 32, failed: 6, avgScore: 78 },
|
||||
{ id: '4', date: '2024-02-01', submitted: 61, passed: 55, failed: 6, avgScore: 84 },
|
||||
{ id: '5', date: '2024-01-31', submitted: 55, passed: 50, failed: 5, avgScore: 83 },
|
||||
{ id: '6', date: '2024-01-30', submitted: 48, passed: 44, failed: 4, avgScore: 86 },
|
||||
{ id: '7', date: '2024-01-29', submitted: 42, passed: 38, failed: 4, avgScore: 81 },
|
||||
]
|
||||
|
||||
// 模拟详细审核记录
|
||||
const mockReviewRecords = [
|
||||
{ id: '1', videoTitle: '夏日护肤推荐', creator: '小美护肤', platform: '抖音', score: 95, status: 'passed', reviewedAt: '2024-02-04 15:30' },
|
||||
{ id: '2', videoTitle: '新品口红试色', creator: '美妆达人Lisa', platform: '小红书', score: 72, status: 'warning', reviewedAt: '2024-02-04 14:20' },
|
||||
{ id: '3', videoTitle: '健身器材开箱', creator: '健身教练王', platform: '抖音', score: 45, status: 'failed', reviewedAt: '2024-02-04 13:15' },
|
||||
{ id: '4', videoTitle: '美食探店vlog', creator: '吃货小胖', platform: '小红书', score: 88, status: 'passed', reviewedAt: '2024-02-04 12:00' },
|
||||
{ id: '5', videoTitle: '数码产品评测', creator: '科技宅', platform: 'B站', score: 91, status: 'passed', reviewedAt: '2024-02-04 11:30' },
|
||||
]
|
||||
|
||||
const periodOptions = [
|
||||
{ value: '7d', label: '最近 7 天' },
|
||||
{ value: '30d', label: '最近 30 天' },
|
||||
{ value: '90d', label: '最近 90 天' },
|
||||
]
|
||||
|
||||
const platformOptions = [
|
||||
{ value: 'all', label: '全部平台' },
|
||||
{ value: 'douyin', label: '抖音' },
|
||||
{ value: 'xiaohongshu', label: '小红书' },
|
||||
{ value: 'bilibili', label: 'B站' },
|
||||
]
|
||||
|
||||
export default function ReportsPage() {
|
||||
const [period, setPeriod] = useState('7d')
|
||||
const [platform, setPlatform] = useState('all')
|
||||
|
||||
// 计算汇总数据
|
||||
const summary = mockReportData.reduce(
|
||||
(acc, day) => ({
|
||||
totalSubmitted: acc.totalSubmitted + day.submitted,
|
||||
totalPassed: acc.totalPassed + day.passed,
|
||||
totalFailed: acc.totalFailed + day.failed,
|
||||
}),
|
||||
{ totalSubmitted: 0, totalPassed: 0, totalFailed: 0 }
|
||||
)
|
||||
const passRate = Math.round((summary.totalPassed / summary.totalSubmitted) * 100)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">审核报表</h1>
|
||||
<Button icon={Download} variant="secondary">导出报表</Button>
|
||||
</div>
|
||||
|
||||
{/* 筛选器 */}
|
||||
<div className="flex gap-4">
|
||||
<div className="w-40">
|
||||
<Select
|
||||
options={periodOptions}
|
||||
value={period}
|
||||
onChange={(e) => setPeriod(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<Select
|
||||
options={platformOptions}
|
||||
value={platform}
|
||||
onChange={(e) => setPlatform(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 汇总卡片 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="text-sm text-gray-500">提交总数</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{summary.totalSubmitted}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="text-sm text-gray-500">通过数</div>
|
||||
<div className="text-3xl font-bold text-green-600">{summary.totalPassed}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="text-sm text-gray-500">驳回数</div>
|
||||
<div className="text-3xl font-bold text-red-600">{summary.totalFailed}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="text-sm text-gray-500">通过率</div>
|
||||
<div className="text-3xl font-bold text-blue-600">{passRate}%</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 每日数据表格 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>每日统计</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-sm text-gray-500">
|
||||
<th className="pb-3 font-medium">日期</th>
|
||||
<th className="pb-3 font-medium">提交数</th>
|
||||
<th className="pb-3 font-medium">通过数</th>
|
||||
<th className="pb-3 font-medium">驳回数</th>
|
||||
<th className="pb-3 font-medium">通过率</th>
|
||||
<th className="pb-3 font-medium">平均分</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mockReportData.map((row) => (
|
||||
<tr key={row.id} className="border-b last:border-0">
|
||||
<td className="py-3 font-medium text-gray-900">{row.date}</td>
|
||||
<td className="py-3 text-gray-600">{row.submitted}</td>
|
||||
<td className="py-3 text-green-600">{row.passed}</td>
|
||||
<td className="py-3 text-red-600">{row.failed}</td>
|
||||
<td className="py-3 text-gray-600">
|
||||
{Math.round((row.passed / row.submitted) * 100)}%
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<span className={`font-medium ${row.avgScore >= 80 ? 'text-green-600' : 'text-yellow-600'}`}>
|
||||
{row.avgScore}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 详细审核记录 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>审核记录</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-sm text-gray-500">
|
||||
<th className="pb-3 font-medium">视频标题</th>
|
||||
<th className="pb-3 font-medium">达人</th>
|
||||
<th className="pb-3 font-medium">平台</th>
|
||||
<th className="pb-3 font-medium">合规分</th>
|
||||
<th className="pb-3 font-medium">状态</th>
|
||||
<th className="pb-3 font-medium">审核时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mockReviewRecords.map((record) => (
|
||||
<tr key={record.id} className="border-b last:border-0 hover:bg-gray-50">
|
||||
<td className="py-3 font-medium text-gray-900">{record.videoTitle}</td>
|
||||
<td className="py-3 text-gray-600">{record.creator}</td>
|
||||
<td className="py-3 text-gray-600">{record.platform}</td>
|
||||
<td className="py-3">
|
||||
<span className={`font-medium ${
|
||||
record.score >= 80 ? 'text-green-600' : record.score >= 60 ? 'text-yellow-600' : 'text-red-600'
|
||||
}`}>
|
||||
{record.score}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
{record.status === 'passed' && <SuccessTag>通过</SuccessTag>}
|
||||
{record.status === 'warning' && <WarningTag>待改进</WarningTag>}
|
||||
{record.status === 'failed' && <ErrorTag>驳回</ErrorTag>}
|
||||
</td>
|
||||
<td className="py-3 text-sm text-gray-500">{record.reviewedAt}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Plus, Shield, AlertTriangle, Ban, Building2 } from 'lucide-react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { ErrorTag, WarningTag, SuccessTag } from '@/components/ui/Tag'
|
||||
|
||||
// 模拟规则数据
|
||||
const mockRules = {
|
||||
forbiddenWords: [
|
||||
{ id: '1', word: '最好', category: '极限词', severity: 'high' },
|
||||
{ id: '2', word: '第一', category: '极限词', severity: 'high' },
|
||||
{ id: '3', word: '最佳', category: '极限词', severity: 'high' },
|
||||
{ id: '4', word: '100%有效', category: '虚假宣称', severity: 'high' },
|
||||
{ id: '5', word: '立即见效', category: '虚假宣称', severity: 'medium' },
|
||||
{ id: '6', word: '永久', category: '极限词', severity: 'medium' },
|
||||
],
|
||||
competitors: [
|
||||
{ id: '1', name: '竞品A', logoUrl: '' },
|
||||
{ id: '2', name: '竞品B', logoUrl: '' },
|
||||
{ id: '3', name: '竞品C', logoUrl: '' },
|
||||
],
|
||||
whitelist: [
|
||||
{ id: '1', term: '品牌专属术语1', reason: '品牌授权使用' },
|
||||
{ id: '2', term: '特定产品名', reason: '官方产品名称' },
|
||||
],
|
||||
}
|
||||
|
||||
const categoryOptions = [
|
||||
{ value: 'absolute_term', label: '极限词' },
|
||||
{ value: 'false_claim', label: '虚假宣称' },
|
||||
{ value: 'platform_rule', label: '平台规则' },
|
||||
{ value: 'custom', label: '自定义' },
|
||||
]
|
||||
|
||||
const severityOptions = [
|
||||
{ value: 'high', label: '高风险' },
|
||||
{ value: 'medium', label: '中风险' },
|
||||
{ value: 'low', label: '低风险' },
|
||||
]
|
||||
|
||||
function SeverityTag({ severity }: { severity: string }) {
|
||||
if (severity === 'high') return <ErrorTag>高风险</ErrorTag>
|
||||
if (severity === 'medium') return <WarningTag>中风险</WarningTag>
|
||||
return <SuccessTag>低风险</SuccessTag>
|
||||
}
|
||||
|
||||
export default function RulesPage() {
|
||||
const [activeTab, setActiveTab] = useState<'forbidden' | 'competitors' | 'whitelist'>('forbidden')
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [newWord, setNewWord] = useState('')
|
||||
const [newCategory, setNewCategory] = useState('absolute_term')
|
||||
const [newSeverity, setNewSeverity] = useState('high')
|
||||
|
||||
const handleAddWord = () => {
|
||||
if (!newWord.trim()) return
|
||||
// TODO: 调用 API 添加
|
||||
setShowAddModal(false)
|
||||
setNewWord('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">规则配置</h1>
|
||||
</div>
|
||||
|
||||
{/* 标签页 */}
|
||||
<div className="flex gap-2 border-b">
|
||||
<button
|
||||
type="button"
|
||||
className={`px-4 py-2 border-b-2 transition-colors ${
|
||||
activeTab === 'forbidden'
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
onClick={() => setActiveTab('forbidden')}
|
||||
>
|
||||
<Ban size={16} className="inline mr-2" />
|
||||
违禁词 ({mockRules.forbiddenWords.length})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`px-4 py-2 border-b-2 transition-colors ${
|
||||
activeTab === 'competitors'
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
onClick={() => setActiveTab('competitors')}
|
||||
>
|
||||
<Building2 size={16} className="inline mr-2" />
|
||||
竞品列表 ({mockRules.competitors.length})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`px-4 py-2 border-b-2 transition-colors ${
|
||||
activeTab === 'whitelist'
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
onClick={() => setActiveTab('whitelist')}
|
||||
>
|
||||
<Shield size={16} className="inline mr-2" />
|
||||
白名单 ({mockRules.whitelist.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 违禁词列表 */}
|
||||
{activeTab === 'forbidden' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>违禁词列表</span>
|
||||
<Button size="sm" icon={Plus} onClick={() => setShowAddModal(true)}>
|
||||
添加
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-sm text-gray-500">
|
||||
<th className="pb-3 font-medium">词汇</th>
|
||||
<th className="pb-3 font-medium">分类</th>
|
||||
<th className="pb-3 font-medium">风险等级</th>
|
||||
<th className="pb-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mockRules.forbiddenWords.map((word) => (
|
||||
<tr key={word.id} className="border-b last:border-0">
|
||||
<td className="py-3 font-medium text-gray-900">{word.word}</td>
|
||||
<td className="py-3 text-gray-600">{word.category}</td>
|
||||
<td className="py-3"><SeverityTag severity={word.severity} /></td>
|
||||
<td className="py-3">
|
||||
<Button size="sm" variant="ghost">删除</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 竞品列表 */}
|
||||
{activeTab === 'competitors' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>竞品列表</span>
|
||||
<Button size="sm" icon={Plus}>添加竞品</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
系统将在视频中检测以下竞品的 Logo 或品牌名称
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{mockRules.competitors.map((competitor) => (
|
||||
<div key={competitor.id} className="p-4 border rounded-lg flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<Building2 size={20} className="text-gray-400" />
|
||||
</div>
|
||||
<span className="font-medium">{competitor.name}</span>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost">删除</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 白名单 */}
|
||||
{activeTab === 'whitelist' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>白名单</span>
|
||||
<Button size="sm" icon={Plus}>添加</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
白名单中的词汇即使命中违禁词也不会触发告警
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-sm text-gray-500">
|
||||
<th className="pb-3 font-medium">词汇</th>
|
||||
<th className="pb-3 font-medium">原因</th>
|
||||
<th className="pb-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mockRules.whitelist.map((item) => (
|
||||
<tr key={item.id} className="border-b last:border-0">
|
||||
<td className="py-3 font-medium text-gray-900">{item.term}</td>
|
||||
<td className="py-3 text-gray-600">{item.reason}</td>
|
||||
<td className="py-3">
|
||||
<Button size="sm" variant="ghost">删除</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 添加违禁词弹窗 */}
|
||||
<Modal
|
||||
isOpen={showAddModal}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
title="添加违禁词"
|
||||
size="sm"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="违禁词"
|
||||
placeholder="输入违禁词"
|
||||
value={newWord}
|
||||
onChange={(e) => setNewWord(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
label="分类"
|
||||
options={categoryOptions}
|
||||
value={newCategory}
|
||||
onChange={(e) => setNewCategory(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
label="风险等级"
|
||||
options={severityOptions}
|
||||
value={newSeverity}
|
||||
onChange={(e) => setNewSeverity(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-3 justify-end pt-4">
|
||||
<Button variant="ghost" onClick={() => setShowAddModal(false)}>取消</Button>
|
||||
<Button onClick={handleAddWord}>添加</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user