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,18 @@
|
||||
'use client'
|
||||
|
||||
import { DesktopLayout } from '@/components/layout/DesktopLayout'
|
||||
import { AuthGuard } from '@/components/auth/AuthGuard'
|
||||
|
||||
export default function AgencyLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<AuthGuard allowedRoles={['agency']}>
|
||||
<DesktopLayout role="agency">
|
||||
{children}
|
||||
</DesktopLayout>
|
||||
</AuthGuard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { SuccessTag, PendingTag, WarningTag, ErrorTag } from '@/components/ui/Tag'
|
||||
import {
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
ChevronRight,
|
||||
FileVideo,
|
||||
MessageSquare,
|
||||
TrendingUp
|
||||
} from 'lucide-react'
|
||||
|
||||
// 模拟统计数据
|
||||
const stats = {
|
||||
pendingReview: 12,
|
||||
pendingAppeal: 3,
|
||||
todayPassed: 28,
|
||||
inProgress: 45,
|
||||
}
|
||||
|
||||
// 模拟紧急待办
|
||||
const urgentTodos = [
|
||||
{
|
||||
id: 'urgent-001',
|
||||
type: 'violation',
|
||||
title: '达人A视频 - 竞品露出',
|
||||
description: 'XX品牌618推广',
|
||||
time: '2小时前',
|
||||
level: 'high',
|
||||
},
|
||||
{
|
||||
id: 'urgent-002',
|
||||
type: 'appeal',
|
||||
title: '达人B申诉 - 待仲裁',
|
||||
description: '对违禁词检测结果有异议',
|
||||
time: '30分钟前',
|
||||
level: 'medium',
|
||||
},
|
||||
{
|
||||
id: 'urgent-003',
|
||||
type: 'ai_done',
|
||||
title: '达人C视频 - AI审核完成',
|
||||
description: '新品口红试色',
|
||||
time: '10分钟前',
|
||||
level: 'low',
|
||||
},
|
||||
]
|
||||
|
||||
// 模拟项目概览
|
||||
const projectOverview = [
|
||||
{
|
||||
id: 'proj-001',
|
||||
name: 'XX品牌618推广',
|
||||
total: 20,
|
||||
submitted: 15,
|
||||
passed: 10,
|
||||
reviewing: 3,
|
||||
needRevision: 2,
|
||||
},
|
||||
{
|
||||
id: 'proj-002',
|
||||
name: '新品口红系列',
|
||||
total: 12,
|
||||
submitted: 8,
|
||||
passed: 6,
|
||||
reviewing: 1,
|
||||
needRevision: 1,
|
||||
},
|
||||
{
|
||||
id: 'proj-003',
|
||||
name: '护肤品秋季活动',
|
||||
total: 15,
|
||||
submitted: 12,
|
||||
passed: 9,
|
||||
reviewing: 2,
|
||||
needRevision: 1,
|
||||
},
|
||||
]
|
||||
|
||||
// 模拟待审核任务列表
|
||||
const pendingTasks = [
|
||||
{
|
||||
id: 'task-001',
|
||||
videoTitle: '夏日护肤推广',
|
||||
creatorName: '小美护肤',
|
||||
brandName: 'XX品牌',
|
||||
aiScore: 85,
|
||||
submittedAt: '2026-02-04 14:30',
|
||||
hasHighRisk: false,
|
||||
},
|
||||
{
|
||||
id: 'task-002',
|
||||
videoTitle: '新品口红试色',
|
||||
creatorName: '美妆达人Lisa',
|
||||
brandName: 'XX品牌',
|
||||
aiScore: 72,
|
||||
submittedAt: '2026-02-04 13:45',
|
||||
hasHighRisk: true,
|
||||
},
|
||||
{
|
||||
id: 'task-003',
|
||||
videoTitle: '健身器材开箱',
|
||||
creatorName: '健身教练王',
|
||||
brandName: 'XX运动',
|
||||
aiScore: 68,
|
||||
submittedAt: '2026-02-04 14:50',
|
||||
hasHighRisk: true,
|
||||
},
|
||||
]
|
||||
|
||||
function UrgentLevelIcon({ level }: { level: string }) {
|
||||
if (level === 'high') return <AlertTriangle size={16} className="text-red-500" />
|
||||
if (level === 'medium') return <MessageSquare size={16} className="text-orange-500" />
|
||||
return <CheckCircle size={16} className="text-yellow-500" />
|
||||
}
|
||||
|
||||
export default function AgencyDashboard() {
|
||||
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-4 gap-4">
|
||||
<Card className="bg-gradient-to-br from-accent-coral/20 to-bg-card border-accent-coral/30">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm text-text-secondary">待审核</div>
|
||||
<div className="text-3xl font-bold text-accent-coral">{stats.pendingReview}</div>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-full bg-accent-coral/20 flex items-center justify-center">
|
||||
<Clock size={24} className="text-accent-coral" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gradient-to-br from-orange-500/20 to-bg-card border-orange-500/30">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm text-text-secondary">待仲裁</div>
|
||||
<div className="text-3xl font-bold text-orange-400">{stats.pendingAppeal}</div>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-full bg-orange-500/20 flex items-center justify-center">
|
||||
<MessageSquare size={24} className="text-orange-400" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gradient-to-br from-accent-green/20 to-bg-card border-accent-green/30">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm text-text-secondary">今日通过</div>
|
||||
<div className="text-3xl font-bold text-accent-green">{stats.todayPassed}</div>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-full bg-accent-green/20 flex items-center justify-center">
|
||||
<CheckCircle size={24} className="text-accent-green" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gradient-to-br from-accent-indigo/20 to-bg-card border-accent-indigo/30">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm text-text-secondary">进行中</div>
|
||||
<div className="text-3xl font-bold text-accent-indigo">{stats.inProgress}</div>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-full bg-accent-indigo/20 flex items-center justify-center">
|
||||
<FileVideo size={24} className="text-accent-indigo" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* 紧急待办 */}
|
||||
<Card className="lg:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertTriangle size={18} className="text-red-500" />
|
||||
紧急待办
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{urgentTodos.map((todo) => (
|
||||
<Link
|
||||
key={todo.id}
|
||||
href={todo.type === 'violation' || todo.type === 'ai_done' ? `/agency/review/${todo.id}` : `/agency/appeals/${todo.id}`}
|
||||
className="block p-3 rounded-lg border border-border-subtle hover:border-accent-indigo hover:bg-accent-indigo/5 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<UrgentLevelIcon level={todo.level} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-text-primary truncate">{todo.title}</div>
|
||||
<div className="text-sm text-text-secondary">{todo.description}</div>
|
||||
<div className="text-xs text-text-tertiary mt-1">{todo.time}</div>
|
||||
</div>
|
||||
<ChevronRight size={16} className="text-text-tertiary flex-shrink-0" />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 项目概览 */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp size={18} className="text-blue-500" />
|
||||
项目概览
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{projectOverview.map((project) => (
|
||||
<div key={project.id} className="p-4 rounded-lg bg-bg-elevated">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="font-medium text-text-primary">{project.name}</span>
|
||||
<span className="text-sm text-text-secondary">
|
||||
{project.submitted}/{project.total} 已提交
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex h-3 rounded-full overflow-hidden bg-bg-page">
|
||||
<div
|
||||
className="bg-accent-green transition-all"
|
||||
style={{ width: `${(project.passed / project.total) * 100}%` }}
|
||||
title={`已通过: ${project.passed}`}
|
||||
/>
|
||||
<div
|
||||
className="bg-accent-indigo transition-all"
|
||||
style={{ width: `${(project.reviewing / project.total) * 100}%` }}
|
||||
title={`审核中: ${project.reviewing}`}
|
||||
/>
|
||||
<div
|
||||
className="bg-orange-500 transition-all"
|
||||
style={{ width: `${(project.needRevision / project.total) * 100}%` }}
|
||||
title={`需修改: ${project.needRevision}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-4 mt-2 text-xs text-text-secondary">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-accent-green rounded-full" />
|
||||
通过 {project.passed}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-accent-indigo rounded-full" />
|
||||
审核中 {project.reviewing}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-orange-500 rounded-full" />
|
||||
需修改 {project.needRevision}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 待审核列表 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>待审核任务</span>
|
||||
<Link href="/agency/tasks">
|
||||
<Button variant="ghost" size="sm">
|
||||
查看全部
|
||||
<ChevronRight size={16} />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border-subtle text-left text-sm text-text-secondary">
|
||||
<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">AI评分</th>
|
||||
<th className="pb-3 font-medium">提交时间</th>
|
||||
<th className="pb-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pendingTasks.map((task) => (
|
||||
<tr key={task.id} className="border-b border-border-subtle last:border-0 hover:bg-bg-elevated">
|
||||
<td className="py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-medium text-text-primary">{task.videoTitle}</div>
|
||||
{task.hasHighRisk && (
|
||||
<span className="px-1.5 py-0.5 text-xs bg-accent-coral/20 text-accent-coral rounded">
|
||||
高风险
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 text-text-secondary">{task.creatorName}</td>
|
||||
<td className="py-4 text-text-secondary">{task.brandName}</td>
|
||||
<td className="py-4">
|
||||
<span className={`font-medium ${
|
||||
task.aiScore >= 80 ? 'text-accent-green' : task.aiScore >= 60 ? 'text-yellow-400' : 'text-accent-coral'
|
||||
}`}>
|
||||
{task.aiScore}分
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4 text-sm text-text-tertiary">{task.submittedAt}</td>
|
||||
<td className="py-4">
|
||||
<Link href={`/agency/review/${task.id}`}>
|
||||
<Button size="sm">审核</Button>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import { ArrowLeft, Play, Pause, AlertTriangle, Shield, Radio } from 'lucide-react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { SuccessTag, WarningTag, ErrorTag } from '@/components/ui/Tag'
|
||||
import { Modal, ConfirmModal } from '@/components/ui/Modal'
|
||||
import { ReviewSteps, getAgencyReviewSteps } from '@/components/ui/ReviewSteps'
|
||||
|
||||
// 模拟审核任务数据
|
||||
const mockTask = {
|
||||
id: 'task-001',
|
||||
videoTitle: '夏日护肤推广',
|
||||
creatorName: '小美护肤',
|
||||
brandName: 'XX护肤品牌',
|
||||
platform: '抖音',
|
||||
aiScore: 85,
|
||||
aiSummary: '视频整体合规,发现2处硬性问题和1处舆情提示需人工确认',
|
||||
reviewSteps: [
|
||||
{ key: 'submitted', label: '已提交', status: 'done' as const, time: '2/3 10:30' },
|
||||
{ key: 'ai_review', label: 'AI审核', status: 'done' as const, time: '2/3 10:35' },
|
||||
{ key: 'agent_review', label: '代理商审核', status: 'current' as const },
|
||||
{ key: 'final', label: '最终结果', status: 'pending' as const },
|
||||
],
|
||||
hardViolations: [
|
||||
{
|
||||
id: 'v1',
|
||||
type: '违禁词',
|
||||
content: '效果最好',
|
||||
timestamp: 15.5,
|
||||
source: 'speech',
|
||||
riskLevel: 'high',
|
||||
aiConfidence: 0.95,
|
||||
suggestion: '建议替换为"效果显著"',
|
||||
},
|
||||
{
|
||||
id: 'v2',
|
||||
type: '竞品露出',
|
||||
content: '疑似竞品Logo',
|
||||
timestamp: 42.0,
|
||||
source: 'visual',
|
||||
riskLevel: 'high',
|
||||
aiConfidence: 0.72,
|
||||
suggestion: '需人工确认是否为竞品露出',
|
||||
},
|
||||
],
|
||||
sentimentWarnings: [
|
||||
{ id: 's1', type: '油腻预警', timestamp: 42.0, content: '达人表情过于夸张,建议检查', riskLevel: 'medium' },
|
||||
],
|
||||
}
|
||||
|
||||
function ReviewProgressBar({ taskStatus }: { taskStatus: string }) {
|
||||
const steps = getAgencyReviewSteps(taskStatus)
|
||||
const currentStep = steps.find(s => s.status === 'current')
|
||||
|
||||
return (
|
||||
<Card className="mb-6">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium text-text-primary">审核流程</span>
|
||||
<span className="text-sm text-accent-indigo font-medium">
|
||||
当前:{currentStep?.label || '代理商审核'}
|
||||
</span>
|
||||
</div>
|
||||
<ReviewSteps steps={steps} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function RiskLevelTag({ level }: { level: string }) {
|
||||
if (level === 'high') return <ErrorTag>高风险</ErrorTag>
|
||||
if (level === 'medium') return <WarningTag>中风险</WarningTag>
|
||||
return <SuccessTag>低风险</SuccessTag>
|
||||
}
|
||||
|
||||
function formatTimestamp(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export default function ReviewPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [showApproveModal, setShowApproveModal] = useState(false)
|
||||
const [showRejectModal, setShowRejectModal] = useState(false)
|
||||
const [showForcePassModal, setShowForcePassModal] = useState(false)
|
||||
const [rejectReason, setRejectReason] = useState('')
|
||||
const [forcePassReason, setForcePassReason] = useState('')
|
||||
const [saveAsException, setSaveAsException] = useState(false)
|
||||
const [checkedViolations, setCheckedViolations] = useState<Record<string, boolean>>({})
|
||||
|
||||
const task = mockTask
|
||||
|
||||
const handleApprove = () => {
|
||||
setShowApproveModal(false)
|
||||
router.push('/agency')
|
||||
}
|
||||
|
||||
const handleReject = () => {
|
||||
if (!rejectReason.trim()) {
|
||||
alert('请填写驳回原因')
|
||||
return
|
||||
}
|
||||
setShowRejectModal(false)
|
||||
router.push('/agency')
|
||||
}
|
||||
|
||||
const handleForcePass = () => {
|
||||
if (!forcePassReason.trim()) {
|
||||
alert('请填写强制通过原因')
|
||||
return
|
||||
}
|
||||
setShowForcePassModal(false)
|
||||
router.push('/agency')
|
||||
}
|
||||
|
||||
// 计算问题时间点用于进度条展示
|
||||
const timelineMarkers = [
|
||||
...task.hardViolations.map(v => ({ time: v.timestamp, type: 'hard' as const })),
|
||||
...task.sentimentWarnings.map(w => ({ time: w.timestamp, type: 'soft' as const })),
|
||||
].sort((a, b) => a.time - b.time)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 顶部导航 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button type="button" onClick={() => router.back()} className="p-2 hover:bg-bg-elevated rounded-full">
|
||||
<ArrowLeft size={20} className="text-text-primary" />
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-xl font-bold text-text-primary">{task.videoTitle}</h1>
|
||||
<p className="text-sm text-text-secondary">{task.creatorName} · {task.brandName} · {task.platform}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 审核流程进度条 */}
|
||||
<ReviewProgressBar taskStatus="agent_reviewing" />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||
{/* 左侧:视频播放器 (3/5) */}
|
||||
<div className="lg:col-span-3 space-y-4">
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="aspect-video bg-gray-900 rounded-t-lg flex items-center justify-center relative">
|
||||
<button
|
||||
type="button"
|
||||
className="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center hover:bg-white/30 transition-colors"
|
||||
onClick={() => setIsPlaying(!isPlaying)}
|
||||
>
|
||||
{isPlaying ? <Pause size={32} className="text-white" /> : <Play size={32} className="text-white ml-1" />}
|
||||
</button>
|
||||
</div>
|
||||
{/* 智能进度条 */}
|
||||
<div className="p-4 border-t border-border-subtle">
|
||||
<div className="text-sm font-medium text-text-primary mb-3">智能进度条(点击跳转)</div>
|
||||
<div className="relative h-3 bg-bg-elevated rounded-full">
|
||||
{/* 时间标记点 */}
|
||||
{timelineMarkers.map((marker, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
className={`absolute top-1/2 -translate-y-1/2 w-4 h-4 rounded-full border-2 border-bg-card shadow-md cursor-pointer transition-transform hover:scale-125 ${
|
||||
marker.type === 'hard' ? 'bg-accent-coral' : 'bg-orange-500'
|
||||
}`}
|
||||
style={{ left: `${(marker.time / 120) * 100}%` }}
|
||||
title={`${formatTimestamp(marker.time)} - ${marker.type === 'hard' ? '硬性问题' : '舆情提示'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-text-tertiary mt-1">
|
||||
<span>0:00</span>
|
||||
<span>2:00</span>
|
||||
</div>
|
||||
<div className="flex gap-4 mt-3 text-xs text-text-secondary">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 bg-accent-coral rounded-full" />
|
||||
硬性问题
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 bg-orange-500 rounded-full" />
|
||||
舆情提示
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 bg-accent-green rounded-full" />
|
||||
卖点覆盖
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* AI 分析总结 */}
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium text-text-primary">AI 分析总结</span>
|
||||
<span className={`text-xl font-bold ${task.aiScore >= 80 ? 'text-accent-green' : 'text-yellow-400'}`}>
|
||||
{task.aiScore}分
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-text-secondary text-sm">{task.aiSummary}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 右侧:AI 检查单 (2/5) */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
{/* 硬性合规 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Shield size={16} className="text-red-500" />
|
||||
硬性合规 ({task.hardViolations.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{task.hardViolations.map((v) => (
|
||||
<div key={v.id} className={`p-3 rounded-lg border ${checkedViolations[v.id] ? 'bg-bg-elevated border-border-subtle' : 'bg-accent-coral/10 border-accent-coral/30'}`}>
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checkedViolations[v.id] || false}
|
||||
onChange={() => setCheckedViolations((prev) => ({ ...prev, [v.id]: !prev[v.id] }))}
|
||||
className="mt-1 accent-accent-indigo"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<ErrorTag>{v.type}</ErrorTag>
|
||||
<span className="text-xs text-text-tertiary">{formatTimestamp(v.timestamp)}</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-text-primary">「{v.content}」</p>
|
||||
<p className="text-xs text-accent-indigo mt-1">{v.suggestion}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 舆情雷达 */}
|
||||
{task.sentimentWarnings.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Radio size={16} className="text-orange-500" />
|
||||
舆情雷达(仅提示)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{task.sentimentWarnings.map((w) => (
|
||||
<div key={w.id} className="p-3 bg-orange-500/10 rounded-lg border border-orange-500/30">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<WarningTag>{w.type}</WarningTag>
|
||||
<span className="text-xs text-text-tertiary">{formatTimestamp(w.timestamp)}</span>
|
||||
</div>
|
||||
<p className="text-sm text-orange-400">{w.content}</p>
|
||||
<p className="text-xs text-text-tertiary mt-1">⚠️ 软性风险仅作提示,不强制拦截</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部决策栏 */}
|
||||
<Card className="sticky bottom-4 shadow-lg">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-text-secondary">
|
||||
已检查 {Object.values(checkedViolations).filter(Boolean).length}/{task.hardViolations.length} 个问题
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="danger" onClick={() => setShowRejectModal(true)}>
|
||||
驳回
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setShowForcePassModal(true)}>
|
||||
强制通过
|
||||
</Button>
|
||||
<Button variant="success" onClick={() => setShowApproveModal(true)}>
|
||||
通过
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 通过确认弹窗 */}
|
||||
<ConfirmModal
|
||||
isOpen={showApproveModal}
|
||||
onClose={() => setShowApproveModal(false)}
|
||||
onConfirm={handleApprove}
|
||||
title="确认通过"
|
||||
message="确定要通过此视频的审核吗?通过后达人将收到通知。"
|
||||
confirmText="确认通过"
|
||||
/>
|
||||
|
||||
{/* 驳回弹窗 */}
|
||||
<Modal isOpen={showRejectModal} onClose={() => setShowRejectModal(false)} title="驳回审核">
|
||||
<div className="space-y-4">
|
||||
<p className="text-text-secondary text-sm">请填写驳回原因,已勾选的问题将自动打包发送给达人。</p>
|
||||
<div className="p-3 bg-bg-elevated rounded-lg">
|
||||
<p className="text-sm font-medium text-text-primary mb-2">已选问题 ({Object.values(checkedViolations).filter(Boolean).length})</p>
|
||||
{task.hardViolations.filter(v => checkedViolations[v.id]).map(v => (
|
||||
<div key={v.id} className="text-sm text-text-secondary">• {v.type}: {v.content}</div>
|
||||
))}
|
||||
{Object.values(checkedViolations).filter(Boolean).length === 0 && (
|
||||
<div className="text-sm text-text-tertiary">未选择任何问题</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">补充说明</label>
|
||||
<textarea
|
||||
className="w-full h-24 p-3 border border-border-subtle rounded-lg resize-none bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
|
||||
placeholder="请详细说明驳回原因..."
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button variant="ghost" onClick={() => setShowRejectModal(false)}>取消</Button>
|
||||
<Button variant="danger" onClick={handleReject}>确认驳回</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* 强制通过弹窗 */}
|
||||
<Modal isOpen={showForcePassModal} onClose={() => setShowForcePassModal(false)} title="强制通过">
|
||||
<div className="space-y-4">
|
||||
<div className="p-3 bg-yellow-500/10 rounded-lg border border-yellow-500/30">
|
||||
<p className="text-sm text-yellow-400">
|
||||
<AlertTriangle size={14} className="inline mr-1" />
|
||||
强制通过将跳过所有问题检测,操作将记录到审计日志
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">放行原因(必填)</label>
|
||||
<textarea
|
||||
className="w-full h-24 p-3 border border-border-subtle rounded-lg resize-none bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
|
||||
placeholder="例如:达人玩的新梗,品牌方认可"
|
||||
value={forcePassReason}
|
||||
onChange={(e) => setForcePassReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={saveAsException}
|
||||
onChange={(e) => setSaveAsException(e.target.checked)}
|
||||
className="rounded accent-accent-indigo"
|
||||
/>
|
||||
<span className="text-sm text-text-secondary">保存为特例(需品牌方确认后生效)</span>
|
||||
</label>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button variant="ghost" onClick={() => setShowForcePassModal(false)}>取消</Button>
|
||||
<Button onClick={handleForcePass}>确认强制通过</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import { ArrowLeft, Download, Play } from 'lucide-react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { SuccessTag, WarningTag, ErrorTag, PendingTag } from '@/components/ui/Tag'
|
||||
|
||||
// 模拟任务详情
|
||||
const mockTaskDetail = {
|
||||
id: 'task-004',
|
||||
videoTitle: '美食探店vlog',
|
||||
creatorName: '吃货小胖',
|
||||
brandName: '某餐饮品牌',
|
||||
platform: '小红书',
|
||||
status: 'approved',
|
||||
aiScore: 95,
|
||||
finalScore: 95,
|
||||
aiSummary: '视频内容合规,无明显违规项',
|
||||
submittedAt: '2024-02-04 10:00',
|
||||
reviewedAt: '2024-02-04 12:00',
|
||||
reviewerName: '审核员A',
|
||||
reviewNotes: '内容积极正面,品牌露出合适,通过审核。',
|
||||
softWarnings: [
|
||||
{ id: 'w1', content: '品牌提及次数适中', suggestion: '可考虑适当增加品牌提及' },
|
||||
],
|
||||
timeline: [
|
||||
{ time: '2024-02-04 10:00', event: '达人提交视频', actor: '吃货小胖' },
|
||||
{ time: '2024-02-04 10:02', event: 'AI审核开始', actor: '系统' },
|
||||
{ time: '2024-02-04 10:05', event: 'AI审核完成,得分95分', actor: '系统' },
|
||||
{ time: '2024-02-04 12:00', event: '人工审核通过', actor: '审核员A' },
|
||||
],
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
if (status === 'approved') return <SuccessTag>已通过</SuccessTag>
|
||||
if (status === 'rejected') return <ErrorTag>已驳回</ErrorTag>
|
||||
if (status === 'pending_review') return <WarningTag>待审核</WarningTag>
|
||||
return <PendingTag>处理中</PendingTag>
|
||||
}
|
||||
|
||||
export default function TaskDetailPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const task = mockTaskDetail
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 顶部导航 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button type="button" onClick={() => router.back()} className="p-2 hover:bg-gray-100 rounded-full">
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-bold text-gray-900">{task.videoTitle}</h1>
|
||||
<StatusBadge status={task.status} />
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">{task.creatorName} · {task.brandName} · {task.platform}</p>
|
||||
</div>
|
||||
<Button variant="secondary" icon={Download}>导出报告</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* 左侧:视频和基本信息 */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="aspect-video bg-gray-900 rounded-t-lg flex items-center justify-center">
|
||||
<button type="button" className="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center hover:bg-white/30">
|
||||
<Play size={32} className="text-white ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>审核结果</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">AI 评分</div>
|
||||
<div className={`text-3xl font-bold ${task.aiScore >= 80 ? 'text-green-600' : 'text-yellow-600'}`}>
|
||||
{task.aiScore}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">最终评分</div>
|
||||
<div className={`text-3xl font-bold ${task.finalScore >= 80 ? 'text-green-600' : 'text-yellow-600'}`}>
|
||||
{task.finalScore}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<div className="text-sm text-gray-500 mb-1">AI 分析摘要</div>
|
||||
<p className="text-gray-700">{task.aiSummary}</p>
|
||||
</div>
|
||||
{task.reviewNotes && (
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<div className="text-sm text-gray-500 mb-1">审核备注</div>
|
||||
<p className="text-gray-700">{task.reviewNotes}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{task.softWarnings.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>优化建议</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{task.softWarnings.map((w) => (
|
||||
<div key={w.id} className="p-3 bg-yellow-50 rounded-lg">
|
||||
<p className="font-medium text-yellow-800">{w.content}</p>
|
||||
<p className="text-sm text-yellow-600 mt-1">{w.suggestion}</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧:详细信息和时间线 */}
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>任务信息</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">任务ID</span>
|
||||
<span className="text-gray-900 font-mono text-sm">{task.id}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">达人</span>
|
||||
<span className="text-gray-900">{task.creatorName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">品牌</span>
|
||||
<span className="text-gray-900">{task.brandName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">平台</span>
|
||||
<span className="text-gray-900">{task.platform}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">提交时间</span>
|
||||
<span className="text-gray-900 text-sm">{task.submittedAt}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">审核时间</span>
|
||||
<span className="text-gray-900 text-sm">{task.reviewedAt}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">审核员</span>
|
||||
<span className="text-gray-900">{task.reviewerName}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>处理时间线</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{task.timeline.map((item, index) => (
|
||||
<div key={index} className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-3 h-3 bg-blue-500 rounded-full" />
|
||||
{index < task.timeline.length - 1 && <div className="w-0.5 h-full bg-gray-200 mt-1" />}
|
||||
</div>
|
||||
<div className="flex-1 pb-4">
|
||||
<p className="text-sm font-medium text-gray-900">{item.event}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">{item.time} · {item.actor}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { AuthGuard } from '@/components/auth/AuthGuard'
|
||||
|
||||
export default function CreatorLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return <AuthGuard allowedRoles={['creator']}>{children}</AuthGuard>
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Bell, CheckCircle, XCircle, Clock, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { DesktopLayout } from '@/components/layout/DesktopLayout'
|
||||
import { MobileLayout } from '@/components/layout/MobileLayout'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type MessageStatus = 'info' | 'success' | 'error'
|
||||
|
||||
const mockMessages = [
|
||||
{
|
||||
id: 'msg-001',
|
||||
taskId: 'task-002',
|
||||
title: 'AI 审核通过',
|
||||
description: '已进入代理商审核,请等待最终结果。',
|
||||
time: '刚刚',
|
||||
status: 'success' as MessageStatus,
|
||||
read: false,
|
||||
},
|
||||
{
|
||||
id: 'msg-002',
|
||||
taskId: 'task-003',
|
||||
title: 'AI 审核未通过',
|
||||
description: '检测到竞品 Logo 与绝对化用语,请修改后重新提交。',
|
||||
time: '10 分钟前',
|
||||
status: 'error' as MessageStatus,
|
||||
read: false,
|
||||
},
|
||||
{
|
||||
id: 'msg-003',
|
||||
taskId: 'task-001',
|
||||
title: '等待提交脚本',
|
||||
description: '请先上传脚本,系统才能开始合规预审。',
|
||||
time: '1 小时前',
|
||||
status: 'info' as MessageStatus,
|
||||
read: true,
|
||||
},
|
||||
]
|
||||
|
||||
const statusConfig: Record<MessageStatus, { icon: React.ElementType; color: string; bg: string }> = {
|
||||
success: { icon: CheckCircle, color: 'text-accent-green', bg: 'bg-accent-green/15' },
|
||||
error: { icon: XCircle, color: 'text-accent-coral', bg: 'bg-accent-coral/15' },
|
||||
info: { icon: Clock, color: 'text-accent-indigo', bg: 'bg-accent-indigo/15' },
|
||||
}
|
||||
|
||||
function MessageCard({
|
||||
title,
|
||||
description,
|
||||
time,
|
||||
status,
|
||||
isRead,
|
||||
onClick,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
time: string
|
||||
status: MessageStatus
|
||||
isRead: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
const Icon = statusConfig[status].icon
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'w-full text-left bg-bg-card rounded-xl p-4 flex items-start gap-4 card-shadow hover:bg-bg-elevated/50 transition-colors',
|
||||
!isRead && 'ring-1 ring-accent-indigo/20'
|
||||
)}
|
||||
>
|
||||
<div className={cn('w-10 h-10 rounded-full flex items-center justify-center', statusConfig[status].bg)}>
|
||||
<Icon className={cn('w-5 h-5', statusConfig[status].color)} />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('text-sm', isRead ? 'text-text-primary' : 'text-text-primary font-semibold')}>
|
||||
{title}
|
||||
</span>
|
||||
{!isRead && <span className="w-2 h-2 rounded-full bg-accent-indigo" />}
|
||||
</div>
|
||||
<span className="text-xs text-text-tertiary">{time}</span>
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary">{description}</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CreatorMessagesPage() {
|
||||
const router = useRouter()
|
||||
const [isMobile, setIsMobile] = useState(true)
|
||||
const [messages, setMessages] = useState(mockMessages)
|
||||
const [showRead, setShowRead] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth < 1024)
|
||||
checkMobile()
|
||||
window.addEventListener('resize', checkMobile)
|
||||
return () => window.removeEventListener('resize', checkMobile)
|
||||
}, [])
|
||||
|
||||
const handleClick = (messageId: string, taskId: string) => {
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) => (msg.id === messageId ? { ...msg, read: true } : msg))
|
||||
)
|
||||
router.push(`/creator/task/${taskId}`)
|
||||
}
|
||||
const unreadCount = messages.filter((msg) => !msg.read).length
|
||||
const unreadMessages = messages.filter((msg) => !msg.read)
|
||||
const readMessages = messages.filter((msg) => msg.read)
|
||||
|
||||
const DesktopContent = (
|
||||
<DesktopLayout role="creator">
|
||||
<div className="flex flex-col gap-6 h-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-2xl bg-accent-indigo/15 flex items-center justify-center">
|
||||
<Bell className="w-6 h-6 text-accent-indigo" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-[28px] font-bold text-text-primary">消息中心</h1>
|
||||
<p className="text-[15px] text-text-secondary">追踪 AI 审核与人工审核进度</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm text-text-tertiary">未读 {unreadCount}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{unreadMessages.length === 0 && (
|
||||
<div className="bg-bg-card rounded-xl p-4 text-sm text-text-tertiary">
|
||||
暂无未读消息
|
||||
</div>
|
||||
)}
|
||||
{unreadMessages.map((msg) => (
|
||||
<MessageCard
|
||||
key={msg.id}
|
||||
title={msg.title}
|
||||
description={msg.description}
|
||||
time={msg.time}
|
||||
status={msg.status}
|
||||
isRead={msg.read}
|
||||
onClick={() => handleClick(msg.id, msg.taskId)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRead((prev) => !prev)}
|
||||
className="flex items-center gap-2 text-sm text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
{showRead ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
已读 ({readMessages.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showRead && readMessages.length === 0 && (
|
||||
<div className="bg-bg-card rounded-xl p-4 text-sm text-text-tertiary">
|
||||
暂无已读消息
|
||||
</div>
|
||||
)}
|
||||
{showRead && readMessages.map((msg) => (
|
||||
<MessageCard
|
||||
key={msg.id}
|
||||
title={msg.title}
|
||||
description={msg.description}
|
||||
time={msg.time}
|
||||
status={msg.status}
|
||||
isRead={msg.read}
|
||||
onClick={() => handleClick(msg.id, msg.taskId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DesktopLayout>
|
||||
)
|
||||
|
||||
const MobileContent = (
|
||||
<MobileLayout role="creator">
|
||||
<div className="flex flex-col gap-5 px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-2xl bg-accent-indigo/15 flex items-center justify-center">
|
||||
<Bell className="w-5 h-5 text-accent-indigo" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-text-primary">消息中心</h1>
|
||||
<p className="text-sm text-text-secondary">审核进度提醒</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{unreadMessages.length === 0 && (
|
||||
<div className="bg-bg-card rounded-xl p-4 text-sm text-text-tertiary">
|
||||
暂无未读消息
|
||||
</div>
|
||||
)}
|
||||
{unreadMessages.map((msg) => (
|
||||
<MessageCard
|
||||
key={msg.id}
|
||||
title={msg.title}
|
||||
description={msg.description}
|
||||
time={msg.time}
|
||||
status={msg.status}
|
||||
isRead={msg.read}
|
||||
onClick={() => handleClick(msg.id, msg.taskId)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRead((prev) => !prev)}
|
||||
className="flex items-center gap-2 text-sm text-text-secondary hover:text-text-primary pt-1"
|
||||
>
|
||||
{showRead ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
已读 ({readMessages.length})
|
||||
</button>
|
||||
|
||||
{showRead && readMessages.length === 0 && (
|
||||
<div className="bg-bg-card rounded-xl p-4 text-sm text-text-tertiary">
|
||||
暂无已读消息
|
||||
</div>
|
||||
)}
|
||||
{showRead && readMessages.map((msg) => (
|
||||
<MessageCard
|
||||
key={msg.id}
|
||||
title={msg.title}
|
||||
description={msg.description}
|
||||
time={msg.time}
|
||||
status={msg.status}
|
||||
isRead={msg.read}
|
||||
onClick={() => handleClick(msg.id, msg.taskId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</MobileLayout>
|
||||
)
|
||||
|
||||
return isMobile ? MobileContent : DesktopContent
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Check, Loader2, Video, Search, SlidersHorizontal, ChevronDown } from 'lucide-react'
|
||||
import { MobileLayout } from '@/components/layout/MobileLayout'
|
||||
import { DesktopLayout } from '@/components/layout/DesktopLayout'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { api } from '@/lib/api'
|
||||
import type { TaskResponse } from '@/types/task'
|
||||
|
||||
// 任务状态类型
|
||||
type TaskStatus = 'pending_script' | 'pending_video' | 'ai_reviewing' | 'agency_reviewing' | 'need_revision' | 'passed'
|
||||
|
||||
// 模拟任务数据
|
||||
const seedTasks = [
|
||||
{
|
||||
id: 'task-001',
|
||||
title: 'XX品牌618推广',
|
||||
platform: '抖音',
|
||||
description: '产品种草视频 · 时长要求 60-90秒',
|
||||
deadline: '2026-02-10',
|
||||
status: 'pending_script' as TaskStatus,
|
||||
currentStep: 1, // 1-已提交, 2-AI审核, 3-代理商审核, 4-品牌终审
|
||||
},
|
||||
{
|
||||
id: 'task-002',
|
||||
title: 'YY美妆新品',
|
||||
platform: '小红书',
|
||||
description: '口播测评 · 视频已上传 · 等待AI审核',
|
||||
submitTime: '今天 14:30',
|
||||
status: 'ai_reviewing' as TaskStatus,
|
||||
currentStep: 2,
|
||||
progress: 65,
|
||||
},
|
||||
{
|
||||
id: 'task-003',
|
||||
title: 'ZZ饮品夏日',
|
||||
platform: '抖音',
|
||||
description: '探店Vlog · 发现2处问题',
|
||||
reviewTime: '昨天 18:20',
|
||||
status: 'need_revision' as TaskStatus,
|
||||
currentStep: 2,
|
||||
issueCount: 2,
|
||||
},
|
||||
{
|
||||
id: 'task-004',
|
||||
title: 'AA数码新品发布',
|
||||
platform: '抖音',
|
||||
description: '开箱测评 · 已发布',
|
||||
status: 'passed' as TaskStatus,
|
||||
currentStep: 4,
|
||||
},
|
||||
{
|
||||
id: 'task-005',
|
||||
title: 'BB运动饮料',
|
||||
platform: '抖音',
|
||||
description: '脚本已通过 · 待提交成片',
|
||||
deadline: '2026-02-12',
|
||||
status: 'pending_video' as TaskStatus,
|
||||
currentStep: 1,
|
||||
},
|
||||
]
|
||||
|
||||
type UiTask = typeof seedTasks[number]
|
||||
|
||||
const taskProfiles = seedTasks.reduce<Record<string, UiTask>>((acc, task) => {
|
||||
acc[task.id] = task
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const platformLabelMap: Record<string, string> = {
|
||||
douyin: '抖音',
|
||||
xiaohongshu: '小红书',
|
||||
bilibili: 'B站',
|
||||
kuaishou: '快手',
|
||||
}
|
||||
|
||||
const getPlatformLabel = (platform?: string) => {
|
||||
if (!platform) return '未知平台'
|
||||
return platformLabelMap[platform] || platform
|
||||
}
|
||||
|
||||
const deriveTaskStatus = (task: TaskResponse): TaskStatus => {
|
||||
if (!task.has_script) {
|
||||
return 'pending_script'
|
||||
}
|
||||
if (!task.has_video) {
|
||||
return 'pending_video'
|
||||
}
|
||||
if (task.status === 'approved') {
|
||||
return 'passed'
|
||||
}
|
||||
if (task.status === 'rejected' || task.status === 'failed') {
|
||||
return 'need_revision'
|
||||
}
|
||||
if (task.status === 'pending' || task.status === 'processing') {
|
||||
return 'ai_reviewing'
|
||||
}
|
||||
return 'agency_reviewing'
|
||||
}
|
||||
|
||||
const getCurrentStep = (status: TaskStatus) => {
|
||||
if (status === 'ai_reviewing' || status === 'need_revision') {
|
||||
return 2
|
||||
}
|
||||
if (status === 'agency_reviewing') {
|
||||
return 3
|
||||
}
|
||||
if (status === 'passed') {
|
||||
return 4
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
const getStatusDescription = (status: TaskStatus) => {
|
||||
switch (status) {
|
||||
case 'pending_script':
|
||||
return '待提交脚本'
|
||||
case 'pending_video':
|
||||
return '待提交视频'
|
||||
case 'ai_reviewing':
|
||||
return 'AI 审核中'
|
||||
case 'agency_reviewing':
|
||||
return '待代理商审核'
|
||||
case 'need_revision':
|
||||
return '需修改后再提交'
|
||||
case 'passed':
|
||||
return '审核通过'
|
||||
default:
|
||||
return '任务进行中'
|
||||
}
|
||||
}
|
||||
|
||||
const mapApiTaskToUi = (task: TaskResponse): UiTask => {
|
||||
const profile = taskProfiles[task.task_id]
|
||||
const status = deriveTaskStatus(task)
|
||||
const platformLabel = getPlatformLabel(task.platform)
|
||||
const description = profile?.description || `${platformLabel} · ${getStatusDescription(status)}`
|
||||
|
||||
return {
|
||||
id: task.task_id,
|
||||
title: profile?.title || `任务 ${task.task_id}`,
|
||||
platform: platformLabel,
|
||||
description,
|
||||
deadline: profile?.deadline,
|
||||
submitTime: profile?.submitTime,
|
||||
reviewTime: profile?.reviewTime,
|
||||
status,
|
||||
currentStep: profile?.currentStep || getCurrentStep(status),
|
||||
progress: profile?.progress,
|
||||
issueCount: profile?.issueCount,
|
||||
}
|
||||
}
|
||||
|
||||
// 状态徽章配置
|
||||
function getStatusConfig(status: TaskStatus) {
|
||||
switch (status) {
|
||||
case 'pending_script':
|
||||
return { label: '待上传', bg: 'bg-accent-blue/15', text: 'text-accent-blue' }
|
||||
case 'pending_video':
|
||||
return { label: '待上传', bg: 'bg-accent-blue/15', text: 'text-accent-blue' }
|
||||
case 'ai_reviewing':
|
||||
return { label: '审核中', bg: 'bg-accent-indigo/15', text: 'text-accent-indigo' }
|
||||
case 'agency_reviewing':
|
||||
return { label: '审核中', bg: 'bg-accent-amber/15', text: 'text-accent-amber' }
|
||||
case 'need_revision':
|
||||
return { label: '需修改', bg: 'bg-accent-coral/15', text: 'text-accent-coral' }
|
||||
case 'passed':
|
||||
return { label: '已通过', bg: 'bg-accent-green/15', text: 'text-accent-green' }
|
||||
default:
|
||||
return { label: '未知', bg: 'bg-bg-elevated', text: 'text-text-secondary' }
|
||||
}
|
||||
}
|
||||
|
||||
type StepState = 'done' | 'current' | 'pending' | 'error'
|
||||
|
||||
function getStepTimeline(status: TaskStatus): Array<{ label: string; state: StepState }> {
|
||||
switch (status) {
|
||||
case 'pending_script':
|
||||
case 'pending_video':
|
||||
return [
|
||||
{ label: '待提交', state: 'current' },
|
||||
{ label: 'AI审核', state: 'pending' },
|
||||
{ label: '代理商', state: 'pending' },
|
||||
{ label: '终审', state: 'pending' },
|
||||
]
|
||||
case 'ai_reviewing':
|
||||
return [
|
||||
{ label: '已提交', state: 'done' },
|
||||
{ label: 'AI审核', state: 'current' },
|
||||
{ label: '代理商', state: 'pending' },
|
||||
{ label: '终审', state: 'pending' },
|
||||
]
|
||||
case 'need_revision':
|
||||
return [
|
||||
{ label: '已提交', state: 'done' },
|
||||
{ label: 'AI未通过', state: 'error' },
|
||||
{ label: '代理商', state: 'pending' },
|
||||
{ label: '终审', state: 'pending' },
|
||||
]
|
||||
case 'agency_reviewing':
|
||||
return [
|
||||
{ label: '已提交', state: 'done' },
|
||||
{ label: 'AI通过', state: 'done' },
|
||||
{ label: '代理商审核', state: 'current' },
|
||||
{ label: '终审', state: 'pending' },
|
||||
]
|
||||
case 'passed':
|
||||
return [
|
||||
{ label: '已提交', state: 'done' },
|
||||
{ label: 'AI通过', state: 'done' },
|
||||
{ label: '代理商通过', state: 'done' },
|
||||
{ label: '已通过', state: 'done' },
|
||||
]
|
||||
default:
|
||||
return [
|
||||
{ label: '已提交', state: 'pending' },
|
||||
{ label: 'AI审核', state: 'pending' },
|
||||
{ label: '代理商', state: 'pending' },
|
||||
{ label: '终审', state: 'pending' },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function TaskStepSummary({ status }: { status: TaskStatus }) {
|
||||
const steps = getStepTimeline(status)
|
||||
|
||||
const stateStyle = (state: StepState) => {
|
||||
if (state === 'done') return 'bg-accent-green text-text-secondary'
|
||||
if (state === 'current') return 'bg-accent-indigo text-accent-indigo'
|
||||
if (state === 'error') return 'bg-accent-coral text-accent-coral'
|
||||
return 'bg-border-subtle text-text-tertiary'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
{steps.map((step, index) => (
|
||||
<div key={`${step.label}-${index}`} className="flex items-center gap-1">
|
||||
<span className={cn('w-1.5 h-1.5 rounded-full', stateStyle(step.state))} />
|
||||
<span className={cn('text-[11px]', step.state === 'error' ? 'text-accent-coral' : 'text-text-tertiary')}>
|
||||
{step.label}
|
||||
</span>
|
||||
{index < steps.length - 1 && <span className="text-text-tertiary">·</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 审核进度条组件
|
||||
function ReviewProgressBar({ currentStep, status }: { currentStep: number; status: TaskStatus }) {
|
||||
const steps = [
|
||||
{ label: '已提交', step: 1 },
|
||||
{ label: 'AI审核', step: 2 },
|
||||
{ label: '代理商审核', step: 3 },
|
||||
{ label: '品牌终审', step: 4 },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex items-center w-full py-2">
|
||||
{steps.map((s, index) => {
|
||||
const isCompleted = s.step < currentStep || (s.step === currentStep && status === 'passed')
|
||||
const isCurrent = s.step === currentStep && status !== 'passed'
|
||||
const isError = isCurrent && status === 'need_revision'
|
||||
|
||||
return (
|
||||
<div key={s.step} className="flex items-center flex-1">
|
||||
<div className="flex flex-col items-center gap-1 w-[70px]">
|
||||
<div className={cn(
|
||||
'w-7 h-7 rounded-full flex items-center justify-center',
|
||||
isCompleted ? 'bg-accent-green' :
|
||||
isError ? 'bg-accent-coral' :
|
||||
isCurrent ? 'bg-accent-indigo' :
|
||||
'bg-bg-elevated border-[1.5px] border-border-subtle'
|
||||
)}>
|
||||
{isCompleted && <Check className="w-3.5 h-3.5 text-white" />}
|
||||
{isCurrent && !isError && <Loader2 className="w-3.5 h-3.5 text-white animate-spin" />}
|
||||
{isError && <span className="w-2 h-2 bg-white rounded-full" />}
|
||||
</div>
|
||||
<span className={cn(
|
||||
'text-xs',
|
||||
isCompleted ? 'text-text-secondary' :
|
||||
isError ? 'text-accent-coral font-semibold' :
|
||||
isCurrent ? 'text-accent-indigo font-semibold' :
|
||||
'text-text-tertiary'
|
||||
)}>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div className={cn(
|
||||
'h-0.5 flex-1',
|
||||
s.step < currentStep ? 'bg-accent-green' : 'bg-border-subtle'
|
||||
)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 桌面端任务卡片
|
||||
function DesktopTaskCard({ task, onClick }: { task: UiTask; onClick: () => void }) {
|
||||
const config = getStatusConfig(task.status)
|
||||
const showProgress = ['ai_reviewing', 'agency_reviewing', 'need_revision'].includes(task.status)
|
||||
|
||||
const getActionButton = () => {
|
||||
if (task.status === 'pending_script' || task.status === 'pending_video') {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="px-5 py-2.5 rounded-[10px] bg-accent-green text-white text-sm font-semibold"
|
||||
onClick={(e) => { e.stopPropagation(); onClick() }}
|
||||
>
|
||||
上传{task.status === 'pending_script' ? '脚本' : '视频'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
if (task.status === 'ai_reviewing') {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="px-5 py-2.5 rounded-[10px] bg-bg-elevated border border-border-subtle text-text-secondary text-sm font-medium"
|
||||
onClick={(e) => { e.stopPropagation(); onClick() }}
|
||||
>
|
||||
查看详情
|
||||
</button>
|
||||
)
|
||||
}
|
||||
if (task.status === 'need_revision') {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="px-5 py-2.5 rounded-[10px] bg-accent-coral text-white text-sm font-semibold"
|
||||
onClick={(e) => { e.stopPropagation(); onClick() }}
|
||||
>
|
||||
查看修改
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="px-5 py-2.5 rounded-[10px] bg-bg-elevated border border-border-subtle text-text-secondary text-sm font-medium"
|
||||
onClick={(e) => { e.stopPropagation(); onClick() }}
|
||||
>
|
||||
查看详情
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-bg-card rounded-2xl p-5 flex flex-col gap-4 card-shadow cursor-pointer hover:bg-bg-elevated/50 transition-colors"
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* 任务主行 */}
|
||||
<div className="flex items-center justify-between">
|
||||
{/* 左侧:缩略图 + 信息 */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* 缩略图占位 */}
|
||||
<div className="w-20 h-[60px] rounded-lg bg-[#1A1A1E] flex items-center justify-center flex-shrink-0">
|
||||
<Video className="w-6 h-6 text-text-tertiary" />
|
||||
</div>
|
||||
{/* 任务信息 */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-base font-semibold text-text-primary">{task.title}</span>
|
||||
<span className="text-[13px] text-text-secondary">{task.description}</span>
|
||||
<TaskStepSummary status={task.status} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:状态 + 操作按钮 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<span className={cn('px-3 py-1.5 rounded-lg text-[13px] font-semibold', config.bg, config.text)}>
|
||||
{config.label}
|
||||
</span>
|
||||
{getActionButton()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 审核进度条 */}
|
||||
{showProgress && <ReviewProgressBar currentStep={task.currentStep} status={task.status} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 移动端任务卡片
|
||||
function MobileTaskCard({ task, onClick }: { task: UiTask; onClick: () => void }) {
|
||||
const config = getStatusConfig(task.status)
|
||||
const showProgress = ['ai_reviewing', 'agency_reviewing', 'need_revision'].includes(task.status)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-bg-card rounded-xl p-4 flex flex-col gap-3 card-shadow cursor-pointer"
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[17px] font-semibold text-text-primary">{task.title}</span>
|
||||
<span className={cn('px-2.5 py-1 rounded-lg text-xs font-semibold', config.bg, config.text)}>
|
||||
{config.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
{showProgress && (
|
||||
<div className="py-1">
|
||||
<ReviewProgressBar currentStep={task.currentStep} status={task.status} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 描述 */}
|
||||
<p className="text-sm text-text-secondary">{task.description}</p>
|
||||
<TaskStepSummary status={task.status} />
|
||||
|
||||
{/* 底部 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[13px] text-text-tertiary">
|
||||
{task.deadline && `截止: ${task.deadline}`}
|
||||
{task.submitTime && `提交于: ${task.submitTime}`}
|
||||
{task.reviewTime && `审核于: ${task.reviewTime}`}
|
||||
</span>
|
||||
{(task.status === 'pending_script' || task.status === 'pending_video') && (
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 rounded-[10px] bg-accent-green text-white text-sm font-semibold"
|
||||
onClick={(e) => { e.stopPropagation(); onClick() }}
|
||||
>
|
||||
去上传
|
||||
</button>
|
||||
)}
|
||||
{task.status === 'need_revision' && (
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 rounded-[10px] bg-accent-coral text-white text-sm font-semibold"
|
||||
onClick={(e) => { e.stopPropagation(); onClick() }}
|
||||
>
|
||||
查看修改
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CreatorTasksPage() {
|
||||
const router = useRouter()
|
||||
const [isMobile, setIsMobile] = useState(true)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [tasks, setTasks] = useState<UiTask[]>(seedTasks)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth < 1024)
|
||||
checkMobile()
|
||||
window.addEventListener('resize', checkMobile)
|
||||
return () => window.removeEventListener('resize', checkMobile)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true
|
||||
|
||||
const fetchTasks = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const data = await api.listTasks()
|
||||
if (!isMounted) return
|
||||
const mapped = data.items.map(mapApiTaskToUi)
|
||||
setTasks(mapped)
|
||||
} catch (error) {
|
||||
console.error('加载任务失败:', error)
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchTasks()
|
||||
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const pendingCount = tasks.filter(t =>
|
||||
!['passed'].includes(t.status)
|
||||
).length
|
||||
|
||||
const handleTaskClick = (taskId: string) => {
|
||||
router.push(`/creator/task/${taskId}`)
|
||||
}
|
||||
|
||||
// 桌面端内容
|
||||
const DesktopContent = (
|
||||
<DesktopLayout role="creator">
|
||||
<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-[28px] font-bold text-text-primary">我的任务</h1>
|
||||
<p className="text-[15px] text-text-secondary">共 {pendingCount} 个进行中任务</p>
|
||||
</div>
|
||||
{/* 搜索和筛选 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 bg-bg-card rounded-xl border border-border-subtle">
|
||||
<Search className="w-[18px] h-[18px] text-text-secondary" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索任务..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="bg-transparent text-sm text-text-primary placeholder-text-tertiary focus:outline-none w-32"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-bg-card rounded-xl border border-border-subtle text-text-secondary text-sm font-medium"
|
||||
>
|
||||
<SlidersHorizontal className="w-[18px] h-[18px]" />
|
||||
<span>全部状态</span>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 任务列表 */}
|
||||
<div className="flex flex-col gap-4 flex-1 overflow-auto">
|
||||
{isLoading && (
|
||||
<div className="bg-bg-card rounded-2xl p-6 text-sm text-text-tertiary">
|
||||
正在加载任务...
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && tasks.length === 0 && (
|
||||
<div className="bg-bg-card rounded-2xl p-6 text-sm text-text-tertiary">
|
||||
暂无任务
|
||||
</div>
|
||||
)}
|
||||
{tasks.map((task) => (
|
||||
<DesktopTaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onClick={() => handleTaskClick(task.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DesktopLayout>
|
||||
)
|
||||
|
||||
// 移动端内容
|
||||
const MobileContent = (
|
||||
<MobileLayout role="creator">
|
||||
<div className="flex flex-col gap-5 px-5 py-4">
|
||||
{/* 头部 */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-[26px] font-bold text-text-primary">我的任务</h1>
|
||||
<p className="text-sm text-text-secondary">共 {pendingCount} 个进行中任务</p>
|
||||
</div>
|
||||
|
||||
{/* 任务列表 */}
|
||||
<div className="flex flex-col gap-3">
|
||||
{isLoading && (
|
||||
<div className="bg-bg-card rounded-xl p-4 text-sm text-text-tertiary">
|
||||
正在加载任务...
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && tasks.length === 0 && (
|
||||
<div className="bg-bg-card rounded-xl p-4 text-sm text-text-tertiary">
|
||||
暂无任务
|
||||
</div>
|
||||
)}
|
||||
{tasks.map((task) => (
|
||||
<MobileTaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onClick={() => handleTaskClick(task.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</MobileLayout>
|
||||
)
|
||||
|
||||
return isMobile ? MobileContent : DesktopContent
|
||||
}
|
||||
@@ -0,0 +1,635 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import {
|
||||
Upload, Check, X, Folder, Bell, Play, MessageCircle,
|
||||
XCircle, CheckCircle, Loader2, Scan, ArrowLeft
|
||||
} from 'lucide-react'
|
||||
import { DesktopLayout } from '@/components/layout/DesktopLayout'
|
||||
import { MobileLayout } from '@/components/layout/MobileLayout'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { api } from '@/lib/api'
|
||||
import type { TaskResponse } from '@/types/task'
|
||||
|
||||
// 任务状态类型
|
||||
type TaskStatus = 'pending_script' | 'pending_video' | 'ai_reviewing' | 'agency_reviewing' | 'need_revision' | 'passed'
|
||||
|
||||
type RequirementProfile = {
|
||||
title?: string
|
||||
platform?: string
|
||||
deadline?: string
|
||||
progress?: number
|
||||
statusHint?: TaskStatus
|
||||
issues?: Array<{ title: string; description: string; timestamp?: string }>
|
||||
reviewLogs?: Array<{ time: string; message: string; status: 'done' | 'loading' | 'pending' }>
|
||||
}
|
||||
|
||||
type TaskDetail = {
|
||||
id: string
|
||||
title: string
|
||||
platform: string
|
||||
deadline: string
|
||||
status: TaskStatus
|
||||
currentStep: number
|
||||
progress?: number
|
||||
issues?: Array<{ title: string; description: string; timestamp?: string }>
|
||||
reviewLogs?: Array<{ time: string; message: string; status: 'done' | 'loading' | 'pending' }>
|
||||
}
|
||||
|
||||
// 任务配置(占位数据)
|
||||
const taskRequirementProfiles: Record<string, RequirementProfile> = {
|
||||
'task-001': {
|
||||
title: 'XX品牌618推广',
|
||||
platform: '抖音',
|
||||
deadline: '2026-02-10',
|
||||
statusHint: 'pending_script',
|
||||
},
|
||||
'task-002': {
|
||||
title: 'YY美妆新品',
|
||||
platform: '小红书',
|
||||
deadline: '2026-02-15',
|
||||
progress: 62,
|
||||
statusHint: 'ai_reviewing',
|
||||
reviewLogs: [
|
||||
{ time: '14:32:01', message: '视频上传完成', status: 'done' },
|
||||
{ time: '14:32:15', message: '任务规则已加载', status: 'done' },
|
||||
{ time: '14:32:28', message: '开始 ASR 语音识别', status: 'done' },
|
||||
{ time: '14:33:45', message: '正在分析视觉合规性问题...', status: 'loading' },
|
||||
],
|
||||
},
|
||||
'task-003': {
|
||||
title: 'ZZ饮品夏日',
|
||||
platform: '抖音',
|
||||
deadline: '2026-02-08',
|
||||
statusHint: 'need_revision',
|
||||
issues: [
|
||||
{
|
||||
title: '检测到竞品 Logo',
|
||||
description: '画面中 0:15-0:18 出现竞品「百事可乐」的 Logo,可能造成合规风险。',
|
||||
timestamp: '0:15',
|
||||
},
|
||||
{
|
||||
title: '禁用词语出现',
|
||||
description: '视频中出现「最好喝」「第一」等绝对化用语,可能违反广告法。',
|
||||
timestamp: '0:42',
|
||||
},
|
||||
],
|
||||
},
|
||||
'task-004': {
|
||||
title: 'AA数码新品发布',
|
||||
platform: '抖音',
|
||||
deadline: '2026-02-20',
|
||||
statusHint: 'passed',
|
||||
},
|
||||
'task-005': {
|
||||
title: 'BB运动饮料',
|
||||
platform: '抖音',
|
||||
deadline: '2026-02-12',
|
||||
statusHint: 'pending_video',
|
||||
},
|
||||
}
|
||||
|
||||
const platformLabelMap: Record<string, string> = {
|
||||
douyin: '抖音',
|
||||
xiaohongshu: '小红书',
|
||||
bilibili: 'B站',
|
||||
kuaishou: '快手',
|
||||
}
|
||||
|
||||
const getPlatformLabel = (platform?: string) => {
|
||||
if (!platform) return '未知平台'
|
||||
return platformLabelMap[platform] || platform
|
||||
}
|
||||
|
||||
const deriveTaskStatus = (task: TaskResponse): TaskStatus => {
|
||||
if (!task.has_script) {
|
||||
return 'pending_script'
|
||||
}
|
||||
if (!task.has_video) {
|
||||
return 'pending_video'
|
||||
}
|
||||
if (task.status === 'approved') {
|
||||
return 'passed'
|
||||
}
|
||||
if (task.status === 'rejected' || task.status === 'failed') {
|
||||
return 'need_revision'
|
||||
}
|
||||
if (task.status === 'pending' || task.status === 'processing') {
|
||||
return 'ai_reviewing'
|
||||
}
|
||||
return 'agency_reviewing'
|
||||
}
|
||||
|
||||
const getCurrentStep = (status: TaskStatus) => {
|
||||
if (status === 'ai_reviewing' || status === 'need_revision') {
|
||||
return 2
|
||||
}
|
||||
if (status === 'agency_reviewing') {
|
||||
return 3
|
||||
}
|
||||
if (status === 'passed') {
|
||||
return 4
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
const buildTaskDetail = (task: TaskResponse): TaskDetail => {
|
||||
const profile = taskRequirementProfiles[task.task_id]
|
||||
const status = deriveTaskStatus(task)
|
||||
const platformLabel = profile?.platform || getPlatformLabel(task.platform)
|
||||
|
||||
return {
|
||||
id: task.task_id,
|
||||
title: profile?.title || `任务 ${task.task_id}`,
|
||||
platform: platformLabel,
|
||||
deadline: profile?.deadline || '待确认',
|
||||
status,
|
||||
currentStep: getCurrentStep(status),
|
||||
progress: profile?.progress,
|
||||
issues: profile?.issues,
|
||||
reviewLogs: profile?.reviewLogs,
|
||||
}
|
||||
}
|
||||
|
||||
// 审核进度条组件
|
||||
function ReviewProgressBar({ currentStep, status }: { currentStep: number; status: TaskStatus }) {
|
||||
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 || (s.step === currentStep && status === 'passed')
|
||||
const isCurrent = s.step === currentStep && status !== 'passed'
|
||||
const isError = isCurrent && status === 'need_revision'
|
||||
|
||||
return (
|
||||
<div key={s.step} className="flex items-center flex-1">
|
||||
<div className="flex flex-col items-center gap-1 w-20">
|
||||
<div className={cn(
|
||||
'w-8 h-8 rounded-2xl flex items-center justify-center',
|
||||
isCompleted ? 'bg-accent-green' :
|
||||
isError ? 'bg-accent-coral' :
|
||||
isCurrent ? 'bg-accent-indigo' :
|
||||
'bg-bg-elevated border-[1.5px] border-border-subtle'
|
||||
)}>
|
||||
{isCompleted && <Check className="w-4 h-4 text-white" />}
|
||||
{isCurrent && !isError && <Loader2 className="w-4 h-4 text-white animate-spin" />}
|
||||
{isError && <X className="w-4 h-4 text-white" />}
|
||||
</div>
|
||||
<span className={cn(
|
||||
'text-xs',
|
||||
isCompleted ? 'text-text-secondary' :
|
||||
isError ? 'text-accent-coral font-semibold' :
|
||||
isCurrent ? 'text-accent-indigo font-semibold' :
|
||||
'text-text-tertiary'
|
||||
)}>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div className={cn(
|
||||
'h-0.5 flex-1',
|
||||
s.step < currentStep || (s.step === currentStep && status === 'passed') ? 'bg-accent-green' : 'bg-border-subtle'
|
||||
)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 上传界面
|
||||
function UploadView({ task }: { task: TaskDetail }) {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const isScriptStep = task.status === 'pending_script'
|
||||
const title = isScriptStep ? '上传脚本' : '上传视频'
|
||||
const subtitle = isScriptStep
|
||||
? '支持粘贴文本或上传文档'
|
||||
: '支持 MP4/MOV 格式,≤ 100MB'
|
||||
const actionLabel = isScriptStep ? '选择脚本文档' : '选择视频文件'
|
||||
const hintText = isScriptStep ? '也可以直接粘贴脚本文本后提交' : '上传完成后将自动进入 AI 审核'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 h-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
||||
<p className="text-sm text-text-tertiary">{subtitle}</p>
|
||||
</div>
|
||||
<span className="px-2.5 py-1 rounded-full text-xs font-semibold bg-accent-indigo/15 text-accent-indigo">
|
||||
待提交
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 flex flex-col items-center justify-center gap-5 rounded-2xl border-2 border-dashed transition-colors card-shadow bg-bg-card',
|
||||
isDragging ? 'border-accent-indigo bg-accent-indigo/5' : 'border-border-subtle'
|
||||
)}
|
||||
onDragOver={(e) => { e.preventDefault(); setIsDragging(true) }}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={(e) => { e.preventDefault(); setIsDragging(false) }}
|
||||
>
|
||||
<div className="w-20 h-20 rounded-[40px] bg-gradient-to-br from-accent-indigo to-[#4F46E5] opacity-15 flex items-center justify-center">
|
||||
<Upload className="w-10 h-10 text-accent-indigo" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<p className="text-lg font-semibold text-text-primary">点击或拖拽文件到此处</p>
|
||||
<p className="text-sm text-text-tertiary">{subtitle}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 px-8 py-3.5 rounded-xl bg-gradient-to-r from-accent-indigo to-[#4F46E5] text-white font-semibold"
|
||||
>
|
||||
<Upload className="w-5 h-5" />
|
||||
{actionLabel}
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-text-tertiary">{hintText}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// AI 审核中界面
|
||||
function ReviewingView({ task }: { task: TaskDetail }) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="bg-bg-card rounded-[20px] p-10 card-shadow flex flex-col items-center gap-8 w-full max-w-md">
|
||||
{/* 任务标签 */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-bg-elevated rounded-lg">
|
||||
<Folder className="w-3.5 h-3.5 text-text-tertiary" />
|
||||
<span className="text-xs font-medium text-text-tertiary">产品种草视频 · 时长 60-90秒</span>
|
||||
</div>
|
||||
|
||||
{/* 扫描动画 */}
|
||||
<div className="relative w-[180px] h-[180px] flex items-center justify-center">
|
||||
{/* 外圈渐变 */}
|
||||
<div className="absolute inset-0 rounded-full bg-gradient-radial from-accent-indigo/50 via-accent-indigo/20 to-transparent" />
|
||||
{/* 中心圆 */}
|
||||
<div className="w-[72px] h-[72px] rounded-full bg-gradient-to-br from-accent-indigo to-[#4F46E5] flex items-center justify-center shadow-[0_0_24px_rgba(99,102,241,0.5)]">
|
||||
<Scan className="w-8 h-8 text-white animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 进度信息 */}
|
||||
<div className="flex flex-col items-center gap-2 w-full">
|
||||
<h2 className="text-[22px] font-semibold text-text-primary">AI 正在审核您的视频</h2>
|
||||
<p className="text-sm text-text-secondary">预计还需 2-3 分钟,可先离开页面</p>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className="flex items-center gap-3 w-full pt-3">
|
||||
<div className="flex-1 h-2 bg-bg-elevated rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-accent-indigo to-[#4F46E5] rounded-full transition-all duration-300"
|
||||
style={{ width: `${task.progress || 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-accent-indigo">{task.progress || 0}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 日志区 */}
|
||||
<div className="w-full bg-bg-elevated rounded-xl p-5 flex flex-col gap-2.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-accent-green" />
|
||||
<span className="text-xs font-medium text-text-secondary">处理日志</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{task.reviewLogs?.map((log, index) => (
|
||||
<div key={index} className="flex items-center gap-2 text-xs">
|
||||
<span className="text-text-tertiary font-mono">{log.time}</span>
|
||||
<span className={cn(
|
||||
log.status === 'done' ? 'text-text-secondary' :
|
||||
log.status === 'loading' ? 'text-accent-indigo' :
|
||||
'text-text-tertiary'
|
||||
)}>
|
||||
{log.message}
|
||||
</span>
|
||||
{log.status === 'loading' && <Loader2 className="w-3 h-3 text-accent-indigo animate-spin" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 通知按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 px-6 py-3 rounded-[10px] bg-bg-page border border-border-subtle text-text-secondary text-[13px] font-medium"
|
||||
>
|
||||
<Bell className="w-4 h-4" />
|
||||
完成后通过微信通知我
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 审核结果界面
|
||||
function ResultView({ task }: { task: TaskDetail }) {
|
||||
const isNeedRevision = task.status === 'need_revision'
|
||||
const isPassed = task.status === 'passed'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 h-full">
|
||||
{/* 审核流程进度 */}
|
||||
<div className="bg-bg-card rounded-xl p-4 px-6 flex items-center card-shadow">
|
||||
<div className="flex flex-col gap-1 w-[140px]">
|
||||
<span className="text-sm font-semibold text-text-primary">审核进度</span>
|
||||
<span className="text-xs text-text-tertiary">更新于 5分钟前</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<ReviewProgressBar currentStep={task.currentStep} status={task.status} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 状态横幅 */}
|
||||
<div className={cn(
|
||||
'flex items-center gap-3 px-6 py-4 rounded-xl',
|
||||
isNeedRevision ? 'bg-accent-coral' : 'bg-accent-green'
|
||||
)}>
|
||||
{isNeedRevision ? (
|
||||
<XCircle className="w-6 h-6 text-white" />
|
||||
) : (
|
||||
<CheckCircle className="w-6 h-6 text-white" />
|
||||
)}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-base font-semibold text-white">
|
||||
{isNeedRevision ? '需要修改' : '审核通过'}
|
||||
</span>
|
||||
<span className="text-sm text-white/90">
|
||||
{isNeedRevision
|
||||
? `发现 ${task.issues?.length || 0} 处违规问题,请修改后重新提交`
|
||||
: '恭喜!您的视频已通过所有审核'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容区 */}
|
||||
<div className="flex gap-6 flex-1 min-h-0">
|
||||
{/* 左侧:视频预览 */}
|
||||
<div className="flex-1">
|
||||
<div className="bg-bg-card rounded-2xl card-shadow h-full flex items-center justify-center">
|
||||
<div className="w-[560px] h-[315px] rounded-xl bg-black flex items-center justify-center">
|
||||
<div className="w-16 h-16 rounded-full bg-white/20 flex items-center justify-center cursor-pointer hover:bg-white/30 transition-colors">
|
||||
<Play className="w-8 h-8 text-white ml-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:问题清单 */}
|
||||
{isNeedRevision && task.issues && task.issues.length > 0 && (
|
||||
<div className="w-[420px]">
|
||||
<div className="bg-bg-card rounded-2xl p-6 card-shadow flex flex-col gap-4">
|
||||
<h3 className="text-lg font-semibold text-text-primary">问题清单</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
{task.issues.map((issue, index) => (
|
||||
<div key={index} className="bg-bg-elevated rounded-xl p-4 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2 py-0.5 rounded bg-accent-coral/15 text-accent-coral text-xs font-semibold">
|
||||
违规
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-text-primary">{issue.title}</span>
|
||||
</div>
|
||||
{issue.timestamp && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-accent-indigo font-medium"
|
||||
>
|
||||
定位到视频
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[13px] text-text-secondary leading-relaxed">{issue.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TaskDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const taskId = params.id as string
|
||||
const [isMobile, setIsMobile] = useState(true)
|
||||
const [taskDetail, setTaskDetail] = useState<TaskDetail | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth < 1024)
|
||||
checkMobile()
|
||||
window.addEventListener('resize', checkMobile)
|
||||
return () => window.removeEventListener('resize', checkMobile)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true
|
||||
|
||||
const fetchTask = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const data = await api.getTask(taskId)
|
||||
if (!isMounted) return
|
||||
setTaskDetail(buildTaskDetail(data))
|
||||
} catch (error) {
|
||||
console.error('加载任务详情失败:', error)
|
||||
if (isMounted) {
|
||||
const fallbackProfile = taskRequirementProfiles[taskId]
|
||||
if (fallbackProfile) {
|
||||
const status = fallbackProfile.statusHint || 'pending_script'
|
||||
setTaskDetail({
|
||||
id: taskId,
|
||||
title: fallbackProfile.title || `任务 ${taskId}`,
|
||||
platform: fallbackProfile.platform || '未知平台',
|
||||
deadline: fallbackProfile.deadline || '待确认',
|
||||
status,
|
||||
currentStep: getCurrentStep(status),
|
||||
progress: fallbackProfile.progress,
|
||||
issues: fallbackProfile.issues,
|
||||
reviewLogs: fallbackProfile.reviewLogs,
|
||||
})
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (taskId) {
|
||||
fetchTask()
|
||||
}
|
||||
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}, [taskId])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<DesktopLayout role="creator">
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-text-secondary">正在加载任务...</p>
|
||||
</div>
|
||||
</DesktopLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (!taskDetail) {
|
||||
return (
|
||||
<DesktopLayout role="creator">
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-text-secondary">任务不存在</p>
|
||||
</div>
|
||||
</DesktopLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// 根据状态获取页面标题
|
||||
const getPageTitle = () => {
|
||||
switch (taskDetail.status) {
|
||||
case 'pending_script':
|
||||
return '上传脚本'
|
||||
case 'pending_video':
|
||||
return '上传视频'
|
||||
case 'ai_reviewing':
|
||||
return 'AI 智能审核'
|
||||
case 'agency_reviewing':
|
||||
return '代理商审核中'
|
||||
case 'need_revision':
|
||||
case 'passed':
|
||||
return '审核结果'
|
||||
default:
|
||||
return '任务详情'
|
||||
}
|
||||
}
|
||||
|
||||
// 根据状态渲染内容
|
||||
const renderContent = () => {
|
||||
switch (taskDetail.status) {
|
||||
case 'pending_script':
|
||||
case 'pending_video':
|
||||
return <UploadView task={taskDetail} />
|
||||
case 'ai_reviewing':
|
||||
return <ReviewingView task={taskDetail} />
|
||||
case 'need_revision':
|
||||
case 'passed':
|
||||
return <ResultView task={taskDetail} />
|
||||
default:
|
||||
return <div>未知状态</div>
|
||||
}
|
||||
}
|
||||
|
||||
// 获取顶部操作按钮
|
||||
const getTopActions = () => {
|
||||
if (taskDetail.status === 'need_revision') {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-bg-card border border-border-subtle text-text-secondary text-sm font-medium"
|
||||
>
|
||||
<MessageCircle className="w-[18px] h-[18px]" />
|
||||
申诉
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-accent-green text-white text-sm font-semibold"
|
||||
>
|
||||
<Upload className="w-[18px] h-[18px]" />
|
||||
重新上传
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (taskDetail.status === 'ai_reviewing') {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-bg-card border border-border-subtle text-text-secondary text-sm font-medium"
|
||||
>
|
||||
<X className="w-[18px] h-[18px]" />
|
||||
取消
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 桌面端内容
|
||||
const DesktopContent = (
|
||||
<DesktopLayout role="creator">
|
||||
<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-[28px] font-bold text-text-primary">{getPageTitle()}</h1>
|
||||
<p className="text-[15px] text-text-secondary">
|
||||
{taskDetail.title} · 截止: {taskDetail.deadline}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{getTopActions()}
|
||||
{taskDetail.status === 'pending_video' && (
|
||||
<div className="px-4 py-2 rounded-[10px] bg-accent-indigo/15">
|
||||
<span className="text-sm font-semibold text-accent-indigo">{taskDetail.platform}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主内容 */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
</DesktopLayout>
|
||||
)
|
||||
|
||||
// 移动端内容
|
||||
const MobileContent = (
|
||||
<MobileLayout role="creator">
|
||||
<div className="flex flex-col gap-5 px-5 py-4 h-full">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="w-10 h-10 rounded-full bg-bg-card flex items-center justify-center"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-text-secondary" />
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-xl font-bold text-text-primary">{getPageTitle()}</h1>
|
||||
<p className="text-sm text-text-secondary">{taskDetail.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 简化的移动端内容 */}
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<p className="text-text-secondary">请在桌面端查看完整内容</p>
|
||||
</div>
|
||||
</div>
|
||||
</MobileLayout>
|
||||
)
|
||||
|
||||
return isMobile ? MobileContent : DesktopContent
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import '../styles/globals.css'
|
||||
import { AuthProvider } from '@/contexts/AuthContext'
|
||||
|
||||
export const metadata = {
|
||||
title: '秒思智能审核',
|
||||
description: 'AI 驱动的营销内容合规审核平台',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="zh-CN" className="h-full">
|
||||
<body className="h-full bg-bg-page text-text-primary font-sans">
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, Suspense } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { ShieldCheck, AlertCircle, ArrowLeft, Mail, Lock } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
function LoginForm() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { login } = useAuth()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [autoLoginAttempted, setAutoLoginAttempted] = useState(false)
|
||||
|
||||
// 如果 URL 有 role 参数,自动触发 demo 登录
|
||||
const roleFromUrl = searchParams.get('role') as 'creator' | 'agency' | 'brand' | null
|
||||
|
||||
const handleDemoLogin = async (role: 'creator' | 'agency' | 'brand') => {
|
||||
const emailMap = {
|
||||
creator: 'creator@demo.com',
|
||||
agency: 'agency@demo.com',
|
||||
brand: 'brand@demo.com',
|
||||
}
|
||||
const demoEmail = emailMap[role]
|
||||
setEmail(demoEmail)
|
||||
setPassword('demo123')
|
||||
setError('')
|
||||
setIsLoading(true)
|
||||
|
||||
const result = await login({ email: demoEmail, password: 'demo123' })
|
||||
|
||||
if (result.success) {
|
||||
switch (role) {
|
||||
case 'creator':
|
||||
router.push('/creator')
|
||||
break
|
||||
case 'agency':
|
||||
router.push('/agency')
|
||||
break
|
||||
case 'brand':
|
||||
router.push('/brand')
|
||||
break
|
||||
}
|
||||
} else {
|
||||
setError(result.error || '登录失败')
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (roleFromUrl && !isLoading && !autoLoginAttempted) {
|
||||
setAutoLoginAttempted(true)
|
||||
handleDemoLogin(roleFromUrl)
|
||||
}
|
||||
}, [roleFromUrl])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setIsLoading(true)
|
||||
|
||||
const result = await login({ email, password })
|
||||
|
||||
if (result.success) {
|
||||
const stored = localStorage.getItem('miaosi_auth')
|
||||
if (stored) {
|
||||
const user = JSON.parse(stored)
|
||||
switch (user.role) {
|
||||
case 'creator':
|
||||
router.push('/creator')
|
||||
break
|
||||
case 'agency':
|
||||
router.push('/agency')
|
||||
break
|
||||
case 'brand':
|
||||
router.push('/brand')
|
||||
break
|
||||
default:
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setError(result.error || '登录失败')
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-bg-page flex flex-col items-center justify-center px-6">
|
||||
<div className="w-full max-w-sm space-y-8">
|
||||
{/* 返回按钮 */}
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
返回首页
|
||||
</Link>
|
||||
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-accent-indigo to-[#4F46E5] flex items-center justify-center shadow-[0px_8px_24px_-4px_rgba(99,102,241,0.4)]">
|
||||
<ShieldCheck className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-2xl font-bold text-text-primary">秒思</span>
|
||||
<p className="text-sm text-text-secondary">AI 智能审核平台</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 登录表单 */}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-accent-coral/10 text-accent-coral rounded-lg text-sm">
|
||||
<AlertCircle size={16} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-text-primary">邮箱</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-text-tertiary" />
|
||||
<input
|
||||
type="email"
|
||||
placeholder="请输入邮箱"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-3.5 bg-bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-accent-indigo focus:border-transparent transition-all"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-text-primary">密码</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-text-tertiary" />
|
||||
<input
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-3.5 bg-bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-accent-indigo focus:border-transparent transition-all"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full py-3.5 rounded-xl bg-gradient-to-r from-accent-indigo to-[#4F46E5] text-white font-semibold text-base shadow-[0px_8px_24px_-4px_rgba(99,102,241,0.4)] hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Demo 登录 */}
|
||||
<div className="pt-6 border-t border-border-subtle">
|
||||
<p className="text-sm text-text-tertiary text-center mb-4">快速体验(Demo 账号)</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDemoLogin('creator')}
|
||||
className="w-full p-4 text-left bg-bg-card border border-border-subtle rounded-xl hover:bg-bg-elevated transition-colors"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<div className="font-medium text-text-primary">达人端</div>
|
||||
<div className="text-sm text-text-secondary">creator@demo.com</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDemoLogin('agency')}
|
||||
className="w-full p-4 text-left bg-bg-card border border-border-subtle rounded-xl hover:bg-bg-elevated transition-colors"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<div className="font-medium text-text-primary">代理商端</div>
|
||||
<div className="text-sm text-text-secondary">agency@demo.com</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDemoLogin('brand')}
|
||||
className="w-full p-4 text-left bg-bg-card border border-border-subtle rounded-xl hover:bg-bg-elevated transition-colors"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<div className="font-medium text-text-primary">品牌方端</div>
|
||||
<div className="text-sm text-text-secondary">brand@demo.com</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="min-h-screen bg-bg-page flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-2 border-accent-indigo border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { ShieldCheck, ArrowRight } from 'lucide-react'
|
||||
|
||||
export default function HomePage() {
|
||||
const router = useRouter()
|
||||
const { user, isAuthenticated, isLoading } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && isAuthenticated && user) {
|
||||
switch (user.role) {
|
||||
case 'creator':
|
||||
router.push('/creator')
|
||||
break
|
||||
case 'agency':
|
||||
router.push('/agency')
|
||||
break
|
||||
case 'brand':
|
||||
router.push('/brand')
|
||||
break
|
||||
}
|
||||
}
|
||||
}, [isLoading, isAuthenticated, user, router])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-bg-page">
|
||||
<div className="w-8 h-8 border-2 border-accent-indigo border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-bg-page flex flex-col items-center justify-center px-6">
|
||||
<div className="text-center space-y-8 max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-accent-indigo to-[#4F46E5] flex items-center justify-center shadow-[0px_8px_24px_-4px_rgba(99,102,241,0.4)]">
|
||||
<ShieldCheck className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<span className="text-3xl font-bold text-text-primary">秒思</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="space-y-3">
|
||||
<h1 className="text-2xl font-bold text-text-primary">
|
||||
AI 智能审核平台
|
||||
</h1>
|
||||
<p className="text-text-secondary">
|
||||
AI 驱动的营销内容合规审核平台,助力品牌安全投放
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login Button */}
|
||||
<div className="pt-4">
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex items-center gap-2 px-8 py-4 rounded-xl bg-gradient-to-r from-accent-indigo to-[#4F46E5] text-white font-semibold text-lg shadow-[0px_8px_24px_-4px_rgba(99,102,241,0.4)] hover:opacity-90 transition-opacity"
|
||||
>
|
||||
立即登录
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Role Selection */}
|
||||
<div className="pt-6 border-t border-border-subtle">
|
||||
<p className="text-sm text-text-tertiary mb-4">或选择角色体验</p>
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<Link
|
||||
href="/login?role=creator"
|
||||
className="px-6 py-3 rounded-xl bg-bg-card border border-border-subtle text-text-secondary font-medium hover:bg-bg-elevated transition-colors"
|
||||
>
|
||||
达人端
|
||||
</Link>
|
||||
<Link
|
||||
href="/login?role=agency"
|
||||
className="px-6 py-3 rounded-xl bg-bg-card border border-border-subtle text-text-secondary font-medium hover:bg-bg-elevated transition-colors"
|
||||
>
|
||||
代理商端
|
||||
</Link>
|
||||
<Link
|
||||
href="/login?role=brand"
|
||||
className="px-6 py-3 rounded-xl bg-bg-card border border-border-subtle text-text-secondary font-medium hover:bg-bg-elevated transition-colors"
|
||||
>
|
||||
品牌方端
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user