feat: 完善代理商端业务逻辑与前后端框架

主要更新:
- 更新代理商端文档,明确项目由品牌方分配流程
- 新增Brief配置详情页(已配置)设计稿
- 完善工作台紧急待办中品牌新任务功能
- 整理Pencil设计文件中代理商端页面顺序
- 新增后端FastAPI框架及核心API
- 新增前端Next.js页面和组件库
- 添加.gitignore排除构建和缓存文件

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-02-05 19:27:31 +08:00
co-authored by Claude Opus 4.5
parent d52509d630
commit e4959d584f
132 changed files with 58539 additions and 21353 deletions
+18
View File
@@ -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>
)
}
+337
View File
@@ -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>
)
}
+367
View File
@@ -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>
)
}
+181
View File
@@ -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>
)
}
+335
View File
@@ -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">
OneAPIAnthropic ClaudeOpenAIDeepSeek
</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>
)
}
+168
View File
@@ -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>
)
}
+265
View File
@@ -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>
)
}
+18
View File
@@ -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>
)
}
+344
View File
@@ -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>
)
}
+198
View File
@@ -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>
)
}
+255
View File
@@ -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>
)
}
+11
View File
@@ -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>
}
+244
View File
@@ -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
}
+590
View File
@@ -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
}
+635
View File
@@ -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
}
+21
View File
@@ -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>
)
}
+213
View File
@@ -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>
)
}
+96
View File
@@ -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>
)
}