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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user