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

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

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

1118 lines
52 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useState, useEffect, useCallback } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { useToast } from '@/components/ui/Toast'
import {
Upload, Check, X, Folder, Bell, MessageCircle,
XCircle, CheckCircle, Loader2, Scan, ArrowLeft,
Bot, Users, Building2, Clock, FileText, Video,
ChevronRight, AlertTriangle, Download, Eye, Target, Ban,
ChevronDown, ChevronUp, File
} from 'lucide-react'
import { Modal } from '@/components/ui/Modal'
import { Button } from '@/components/ui/Button'
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
import { cn } from '@/lib/utils'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import { useSSE } from '@/contexts/SSEContext'
import type { TaskResponse, AIReviewResult, ReviewDimensions, SellingPointMatchResult, BriefMatchDetail } from '@/types/task'
import type { BriefResponse } from '@/types/brief'
// 前端 UI 使用的任务阶段类型
type TaskPhase = 'script' | 'video'
type TaskStage =
| 'upload'
| 'ai_reviewing'
| 'ai_result'
| 'agency_reviewing'
| 'agency_rejected'
| 'brand_reviewing'
| 'brand_approved'
| 'brand_rejected'
type Issue = {
title: string
description: string
timestamp?: string
severity?: 'error' | 'warning'
}
type ReviewLog = {
time: string
message: string
status: 'done' | 'loading' | 'pending'
}
type TaskData = {
id: string
title: string
subtitle: string
phase: TaskPhase
stage: TaskStage
progress?: number
issues?: Issue[]
reviewLogs?: ReviewLog[]
rejectionReason?: string
submittedAt?: string
scriptContent?: string
aiResult?: {
score: number
dimensions?: ReviewDimensions
sellingPointMatches?: SellingPointMatchResult[]
briefMatchDetail?: BriefMatchDetail
violations: Array<{ type: string; content: string; suggestion: string; dimension?: string }>
}
agencyReview?: { result: 'approved' | 'rejected'; comment: string; reviewer: string; time: string }
brandReview?: { result: 'approved' | 'rejected'; comment: string; reviewer: string; time: string }
}
type AgencyBriefFile = {
id: string
name: string
size: string
uploadedAt: string
description?: string
}
// 将后端 TaskResponse 映射为前端 UI 的 TaskData
function mapApiTaskToTaskData(task: TaskResponse): TaskData {
const stage = task.stage
let phase: TaskPhase = 'script'
let uiStage: TaskStage = 'upload'
let issues: Issue[] = []
let rejectionReason: string | undefined
let submittedAt: string | undefined
// 判断阶段
if (stage.startsWith('video_') || stage === 'completed') {
phase = 'video'
}
// 映射阶段
switch (stage) {
case 'script_upload': uiStage = 'upload'; break
case 'script_ai_review': uiStage = 'ai_reviewing'; break
case 'script_agency_review': uiStage = 'agency_reviewing'; submittedAt = task.script_uploaded_at || undefined; break
case 'script_brand_review': uiStage = 'brand_reviewing'; submittedAt = task.script_uploaded_at || undefined; break
case 'video_upload': uiStage = 'upload'; phase = 'video'; break
case 'video_ai_review': uiStage = 'ai_reviewing'; phase = 'video'; break
case 'video_agency_review': uiStage = 'agency_reviewing'; phase = 'video'; submittedAt = task.video_uploaded_at || undefined; break
case 'video_brand_review': uiStage = 'brand_reviewing'; phase = 'video'; submittedAt = task.video_uploaded_at || undefined; break
case 'completed': uiStage = 'brand_approved'; phase = 'video'; submittedAt = task.video_uploaded_at || undefined; break
case 'rejected': {
// 判断是哪个阶段被驳回
if (task.video_brand_status === 'rejected') {
phase = 'video'; uiStage = 'brand_rejected'
rejectionReason = task.video_brand_comment || undefined
} else if (task.video_agency_status === 'rejected') {
phase = 'video'; uiStage = 'agency_rejected'
rejectionReason = task.video_agency_comment || undefined
} else if (task.script_brand_status === 'rejected') {
phase = 'script'; uiStage = 'brand_rejected'
rejectionReason = task.script_brand_comment || undefined
} else if (task.script_agency_status === 'rejected') {
phase = 'script'; uiStage = 'agency_rejected'
rejectionReason = task.script_agency_comment || undefined
} else {
uiStage = 'ai_result'
}
break
}
}
// 处理驳回状态(非 rejected stage 但有驳回)
if (task.script_agency_status === 'rejected' && stage !== 'rejected') {
phase = 'script'; uiStage = 'agency_rejected'
rejectionReason = task.script_agency_comment || undefined
}
if (task.script_brand_status === 'rejected' && stage !== 'rejected') {
phase = 'script'; uiStage = 'brand_rejected'
rejectionReason = task.script_brand_comment || undefined
}
if (task.video_agency_status === 'rejected' && stage !== 'rejected') {
phase = 'video'; uiStage = 'agency_rejected'
rejectionReason = task.video_agency_comment || undefined
}
if (task.video_brand_status === 'rejected' && stage !== 'rejected') {
phase = 'video'; uiStage = 'brand_rejected'
rejectionReason = task.video_brand_comment || undefined
}
// 提取 AI 审核结果中的 issues
const aiResult = phase === 'script' ? task.script_ai_result : task.video_ai_result
if (aiResult?.violations) {
const dimLabels: Record<string, string> = { legal: '法规合规', platform: '平台规则', brand_safety: '品牌安全', brief_match: 'Brief 匹配' }
issues = aiResult.violations.map(v => ({
title: v.dimension ? `[${dimLabels[v.dimension] || v.dimension}] ${v.type}` : v.type,
description: `${v.content}${v.suggestion ? `${v.suggestion}` : ''}`,
timestamp: v.timestamp ? `${v.timestamp}s` : undefined,
severity: v.severity === 'warning' ? 'warning' as const : 'error' as const,
}))
}
const subtitle = `${task.project.name} · ${task.project.brand_name || ''}`
// AI 审核结果(完整,含维度)
const aiResultData = aiResult ? {
score: aiResult.score,
dimensions: aiResult.dimensions,
sellingPointMatches: aiResult.selling_point_matches,
briefMatchDetail: aiResult.brief_match_detail,
violations: aiResult.violations.map(v => ({ type: v.type, content: v.content, suggestion: v.suggestion, dimension: v.dimension })),
} : undefined
// 代理商审核反馈
const agencyStatus = phase === 'script' ? task.script_agency_status : task.video_agency_status
const agencyComment = phase === 'script' ? task.script_agency_comment : task.video_agency_comment
const agencyReview = agencyStatus && agencyStatus !== 'pending' ? {
result: (agencyStatus === 'passed' || agencyStatus === 'force_passed' ? 'approved' : 'rejected') as 'approved' | 'rejected',
comment: agencyComment || '',
reviewer: task.agency?.name || '代理商',
time: task.updated_at,
} : undefined
// 品牌方审核反馈
const brandStatus = phase === 'script' ? task.script_brand_status : task.video_brand_status
const brandComment = phase === 'script' ? task.script_brand_comment : task.video_brand_comment
const brandReview = brandStatus && brandStatus !== 'pending' ? {
result: (brandStatus === 'passed' || brandStatus === 'force_passed' ? 'approved' : 'rejected') as 'approved' | 'rejected',
comment: brandComment || '',
reviewer: '品牌方审核员',
time: task.updated_at,
} : undefined
return {
id: task.id,
title: task.name,
subtitle,
phase,
stage: uiStage,
issues: issues.length > 0 ? issues : undefined,
rejectionReason,
submittedAt,
aiResult: aiResultData,
agencyReview,
brandReview,
}
}
// Mock Brief 数据
const mockBriefData = {
files: [
{ id: 'af1', name: '达人拍摄指南.pdf', size: '1.5MB', uploadedAt: '2026-02-02', description: '详细的拍摄流程和注意事项' },
{ id: 'af2', name: '产品卖点话术.docx', size: '800KB', uploadedAt: '2026-02-02', description: '推荐使用的话术和表达方式' },
{ id: 'af3', name: '品牌视觉参考.pdf', size: '3.2MB', uploadedAt: '2026-02-02', description: '视觉风格和拍摄参考示例' },
] as AgencyBriefFile[],
sellingPoints: [
{ id: 'sp1', content: 'SPF50+ PA++++', priority: 'core' as const },
{ id: 'sp2', content: '轻薄质地,不油腻', priority: 'core' as const },
{ id: 'sp3', content: '延展性好,易推开', priority: 'recommended' as const },
{ id: 'sp4', content: '适合敏感肌', priority: 'recommended' as const },
{ id: 'sp5', content: '夏日必备防晒', priority: 'core' as const },
{ id: 'sp6', content: '产品成分天然', priority: 'reference' as const },
],
blacklistWords: [
{ id: 'bw1', word: '最好', reason: '绝对化用语' },
{ id: 'bw2', word: '第一', reason: '绝对化用语' },
{ id: 'bw3', word: '神器', reason: '夸大宣传' },
{ id: 'bw4', word: '完美', reason: '绝对化用语' },
],
}
// Mock 任务数据
const mockTasksData: Record<string, TaskData> = {
'task-001': { id: 'task-001', title: 'XX品牌618推广', subtitle: '产品种草视频 · 当前步骤:上传脚本', phase: 'script', stage: 'upload' },
'task-002': { id: 'task-002', title: 'YY美妆新品', subtitle: '口播测评 · 当前步骤AI审核中', phase: 'script', stage: 'ai_reviewing', progress: 62, reviewLogs: [
{ time: '14:32:01', message: '脚本上传完成', status: 'done' },
{ time: '14:32:15', message: '任务规则已加载', status: 'done' },
{ time: '14:33:45', message: '正在分析品牌调性匹配度...', status: 'loading' },
]},
'task-003': { id: 'task-003', title: 'ZZ饮品夏日', subtitle: '探店Vlog · 发现2处问题', phase: 'script', stage: 'ai_result', issues: [
{ title: '检测到竞品提及', description: '脚本第3段提及了竞品「百事可乐」', severity: 'error' },
{ title: '禁用词语出现', description: '脚本中出现「最好喝」等绝对化用语', severity: 'error' },
]},
'task-004': { id: 'task-004', title: 'AA数码新品发布', subtitle: '开箱测评 · 审核通过', phase: 'video', stage: 'brand_approved', submittedAt: '2026-02-01 10:30' },
'task-008': { id: 'task-008', title: 'EE食品试吃', subtitle: '美食测评 · 脚本通过 · 待上传视频', phase: 'video', stage: 'upload' },
}
// ========== UI 组件 ==========
function StepIcon({ status, icon }: { status: 'done' | 'current' | 'error' | 'pending'; icon: 'upload' | 'bot' | 'users' | 'building' }) {
const IconMap = { upload: Upload, bot: Bot, users: Users, building: Building2 }
const Icon = IconMap[icon]
const getStyle = () => {
switch (status) {
case 'done': return 'bg-accent-green'
case 'current': return 'bg-accent-indigo'
case 'error': return 'bg-accent-coral'
default: return 'bg-bg-elevated border-[1.5px] border-border-subtle'
}
}
const iconColor = status === 'pending' ? 'text-text-tertiary' : 'text-white'
return (
<div className={cn('w-8 h-8 rounded-full flex items-center justify-center', getStyle())}>
{status === 'done' && <Check size={16} className={iconColor} />}
{status === 'current' && <Loader2 size={16} className={cn(iconColor, 'animate-spin')} />}
{status === 'error' && <X size={16} className={iconColor} />}
{status === 'pending' && <Icon size={16} className={iconColor} />}
</div>
)
}
function ReviewProgressBar({ task }: { task: TaskData }) {
const { stage } = task
const getStepStatus = (stepIndex: number): 'done' | 'current' | 'error' | 'pending' => {
const stageMap: Record<TaskStage, number> = {
'upload': 0, 'ai_reviewing': 1, 'ai_result': 1,
'agency_reviewing': 2, 'agency_rejected': 2,
'brand_reviewing': 3, 'brand_approved': 4, 'brand_rejected': 3,
}
const currentStepIndex = stageMap[stage]
const isError = stage === 'ai_result' || stage === 'agency_rejected' || stage === 'brand_rejected'
if (stepIndex < currentStepIndex) return 'done'
if (stepIndex === currentStepIndex) {
if (isError) return 'error'
if (stage === 'brand_approved') return 'done'
return 'current'
}
return 'pending'
}
const steps = [
{ label: '已提交', icon: 'upload' as const },
{ label: 'AI审核', icon: 'bot' as const },
{ label: '代理商', icon: 'users' as const },
{ label: '品牌方', icon: 'building' as const },
]
return (
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
<h3 className="text-lg font-semibold text-text-primary mb-5">
{task.phase === 'script' ? '脚本审核流程' : '视频审核流程'}
</h3>
<div className="flex items-center">
{steps.map((step, index) => {
const status = getStepStatus(index)
return (
<div key={step.label} className="flex items-center flex-1">
<div className="flex flex-col items-center gap-2 w-20">
<StepIcon status={status} icon={step.icon} />
<span className={cn(
'text-xs',
status === 'done' ? 'text-text-secondary' :
status === 'error' ? 'text-accent-coral font-semibold' :
status === 'current' ? 'text-accent-indigo font-semibold' :
'text-text-tertiary'
)}>{step.label}</span>
</div>
{index < steps.length - 1 && (
<div className={cn('h-0.5 flex-1', getStepStatus(index) === 'done' ? 'bg-accent-green' : 'bg-border-subtle')} />
)}
</div>
)
})}
</div>
</div>
)
}
// Brief 组件
function AgencyBriefSection({ toast, briefData }: {
toast: ReturnType<typeof useToast>
briefData: { files: AgencyBriefFile[]; sellingPoints: { id: string; content: string; priority: 'core' | 'recommended' | 'reference' }[]; blacklistWords: { id: string; word: string; reason: string }[] }
}) {
const [isExpanded, setIsExpanded] = useState(true)
const [previewFile, setPreviewFile] = useState<AgencyBriefFile | null>(null)
const handleDownload = (file: AgencyBriefFile) => { toast.info(`下载文件: ${file.name}`) }
const corePoints = briefData.sellingPoints.filter(sp => sp.priority === 'core')
const recommendedPoints = briefData.sellingPoints.filter(sp => sp.priority === 'recommended')
const referencePoints = briefData.sellingPoints.filter(sp => sp.priority === 'reference')
return (
<>
<div className="bg-bg-card rounded-2xl card-shadow border border-accent-indigo/30">
<div className="flex items-center justify-between p-4 border-b border-border-subtle">
<div className="flex items-center gap-2">
<File className="w-5 h-5 text-accent-indigo" />
<span className="text-base font-semibold text-text-primary">Brief </span>
</div>
<button type="button" onClick={() => setIsExpanded(!isExpanded)} className="p-1.5 hover:bg-bg-elevated rounded-lg transition-colors">
{isExpanded ? <ChevronUp className="w-5 h-5 text-text-tertiary" /> : <ChevronDown className="w-5 h-5 text-text-tertiary" />}
</button>
</div>
{isExpanded && (
<div className="p-4 space-y-4">
<div>
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2">
<FileText className="w-4 h-4 text-accent-indigo" />
</h4>
<div className="space-y-2">
{briefData.files.map((file) => (
<div key={file.id} className="flex items-center justify-between p-3 bg-bg-elevated rounded-xl">
<div className="flex items-center gap-3 min-w-0">
<div className="w-9 h-9 rounded-lg bg-accent-indigo/15 flex items-center justify-center flex-shrink-0">
<FileText className="w-4 h-4 text-accent-indigo" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{file.name}</p>
<p className="text-xs text-text-tertiary">{file.size}</p>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<button type="button" onClick={() => setPreviewFile(file)} className="p-2 hover:bg-bg-page rounded-lg transition-colors">
<Eye className="w-4 h-4 text-text-secondary" />
</button>
<button type="button" onClick={() => handleDownload(file)} className="p-2 hover:bg-bg-page rounded-lg transition-colors">
<Download className="w-4 h-4 text-text-secondary" />
</button>
</div>
</div>
))}
</div>
</div>
<div>
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2">
<Target className="w-4 h-4 text-accent-green" />
</h4>
<div className="space-y-2">
{corePoints.length > 0 && (
<div className="p-3 bg-accent-coral/10 rounded-xl border border-accent-coral/30">
<p className="text-xs text-accent-coral font-medium mb-2"></p>
<div className="flex flex-wrap gap-2">
{corePoints.map((sp) => (
<span key={sp.id} className="px-2 py-1 text-xs bg-accent-coral/20 text-accent-coral rounded-lg">{sp.content}</span>
))}
</div>
</div>
)}
{recommendedPoints.length > 0 && (
<div className="p-3 bg-accent-amber/10 rounded-xl border border-accent-amber/30">
<p className="text-xs text-accent-amber font-medium mb-2"></p>
<div className="flex flex-wrap gap-2">
{recommendedPoints.map((sp) => (
<span key={sp.id} className="px-2 py-1 text-xs bg-accent-amber/20 text-accent-amber rounded-lg">{sp.content}</span>
))}
</div>
</div>
)}
{referencePoints.length > 0 && (
<div className="p-3 bg-bg-elevated rounded-xl">
<p className="text-xs text-text-tertiary font-medium mb-2"></p>
<div className="flex flex-wrap gap-2">
{referencePoints.map((sp) => (
<span key={sp.id} className="px-2 py-1 text-xs bg-bg-page text-text-secondary rounded-lg">{sp.content}</span>
))}
</div>
</div>
)}
</div>
</div>
<div>
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2">
<Ban className="w-4 h-4 text-accent-coral" /> 使
</h4>
<div className="flex flex-wrap gap-2">
{briefData.blacklistWords.map((bw) => (
<span key={bw.id} className="px-2 py-1 text-xs bg-accent-coral/15 text-accent-coral rounded-lg border border-accent-coral/30">
{bw.word}
</span>
))}
</div>
</div>
</div>
)}
</div>
<Modal isOpen={!!previewFile} onClose={() => setPreviewFile(null)} title={previewFile?.name || '文件预览'} size="lg">
<div className="space-y-4">
<div className="aspect-[4/3] bg-bg-elevated rounded-lg flex items-center justify-center">
<div className="text-center">
<FileText className="w-12 h-12 mx-auto text-accent-indigo mb-4" />
<p className="text-text-secondary"></p>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setPreviewFile(null)}></Button>
{previewFile && <Button onClick={() => handleDownload(previewFile)}><Download className="w-4 h-4" /></Button>}
</div>
</div>
</Modal>
</>
)
}
function FileUploadSection({ taskId, phase, onUploaded }: { taskId: string; phase: 'script' | 'video'; onUploaded: () => void }) {
const [file, setFile] = useState<File | null>(null)
const [isUploading, setIsUploading] = useState(false)
const [progress, setProgress] = useState(0)
const [uploadError, setUploadError] = useState<string | null>(null)
const toast = useToast()
const isScript = phase === 'script'
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0]
if (selectedFile) { setFile(selectedFile); setUploadError(null) }
}
const handleSubmit = async () => {
if (!file) return
setIsUploading(true); setProgress(0); setUploadError(null)
try {
if (USE_MOCK) {
for (let i = 0; i <= 100; i += 20) { await new Promise(r => setTimeout(r, 400)); setProgress(i) }
toast.success(isScript ? '脚本已提交,等待 AI 审核' : '视频已提交,等待 AI 审核')
onUploaded()
} else {
const result = await api.proxyUpload(file, isScript ? 'script' : 'video', (pct) => {
setProgress(Math.min(90, Math.round(pct * 0.9)))
})
setProgress(95)
if (isScript) {
await api.uploadTaskScript(taskId, { file_url: result.url, file_name: result.file_name })
} else {
await api.uploadTaskVideo(taskId, { file_url: result.url, file_name: result.file_name })
}
setProgress(100)
toast.success(isScript ? '脚本已提交,等待 AI 审核' : '视频已提交,等待 AI 审核')
onUploaded()
}
} catch (err) {
const msg = err instanceof Error ? err.message : '上传失败'
setUploadError(msg); toast.error(msg)
} finally { setIsUploading(false) }
}
const formatSize = (bytes: number) => {
if (bytes < 1024) return bytes + 'B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'KB'
return (bytes / (1024 * 1024)).toFixed(1) + 'MB'
}
const acceptTypes = isScript ? '.doc,.docx,.pdf,.txt,.xls,.xlsx' : '.mp4,.mov,.avi,.mkv'
const acceptHint = isScript ? '支持 Word、PDF、TXT、Excel 格式' : '支持 MP4/MOV 格式,≤ 100MB'
return (
<div className="bg-bg-card rounded-2xl card-shadow">
<div className="flex items-center gap-2 p-4 border-b border-border-subtle">
<Upload className="w-5 h-5 text-accent-indigo" />
<span className="text-base font-semibold text-text-primary">{isScript ? '上传脚本' : '上传视频'}</span>
<span className="ml-auto px-2.5 py-1 rounded-full text-xs font-semibold bg-accent-indigo/15 text-accent-indigo"></span>
</div>
<div className="p-4 space-y-4">
{!file ? (
<label className="border-2 border-dashed border-border-subtle rounded-xl p-8 text-center hover:border-accent-indigo/50 transition-colors cursor-pointer block">
<Upload className="w-8 h-8 mx-auto text-text-tertiary mb-3" />
<p className="text-text-secondary mb-1">{isScript ? '脚本' : '视频'}</p>
<p className="text-xs text-text-tertiary">{acceptHint}</p>
<input type="file" accept={acceptTypes} onChange={handleFileChange} className="hidden" />
</label>
) : (
<div className="border border-border-subtle rounded-xl overflow-hidden">
<div className="px-4 py-2.5 bg-bg-elevated border-b border-border-subtle">
<span className="text-xs font-medium text-text-secondary"></span>
</div>
<div className="px-4 py-3">
<div className="flex items-center gap-3">
{isUploading ? (
<Loader2 className="w-4 h-4 animate-spin text-accent-indigo flex-shrink-0" />
) : uploadError ? (
<AlertTriangle className="w-4 h-4 text-accent-coral flex-shrink-0" />
) : (
<CheckCircle className="w-4 h-4 text-accent-green flex-shrink-0" />
)}
<FileText className="w-4 h-4 text-accent-indigo flex-shrink-0" />
<span className="flex-1 text-sm text-text-primary truncate">{file.name}</span>
<span className="text-xs text-text-tertiary">{formatSize(file.size)}</span>
{!isUploading && (
<button type="button" onClick={() => { setFile(null); setUploadError(null) }} className="p-1 hover:bg-bg-elevated rounded">
<XCircle className="w-4 h-4 text-text-tertiary" />
</button>
)}
</div>
{isUploading && (
<>
<div className="mt-2 ml-[30px] h-2 bg-bg-page rounded-full overflow-hidden">
<div className="h-full bg-accent-indigo rounded-full transition-all duration-300" style={{ width: `${progress}%` }} />
</div>
<p className="mt-1 ml-[30px] text-xs text-text-tertiary"> {progress}%</p>
</>
)}
{uploadError && <p className="mt-1 ml-[30px] text-xs text-accent-coral">{uploadError}</p>}
</div>
</div>
)}
<button
type="button"
onClick={handleSubmit}
disabled={!file || isUploading}
className="w-full flex items-center justify-center gap-2 px-6 py-3 rounded-xl bg-gradient-to-r from-accent-indigo to-[#4F46E5] text-white font-semibold hover:opacity-90 transition-opacity disabled:opacity-40 disabled:cursor-not-allowed"
>
{isUploading ? <><Loader2 className="w-5 h-5 animate-spin" /> {progress}%</> : <><Upload className="w-5 h-5" />{isScript ? '提交脚本' : '提交视频'}</>}
</button>
</div>
</div>
)
}
function getDimensionLabel(key: string) {
const labels: Record<string, string> = { legal: '法规合规', platform: '平台规则', brand_safety: '品牌安全', brief_match: 'Brief 匹配' }
return labels[key] || key
}
function AIResultDetailSection({ task }: { task: TaskData }) {
if (!task.aiResult) return null
const { dimensions, sellingPointMatches, briefMatchDetail, violations } = task.aiResult
return (
<div className="bg-bg-card rounded-2xl card-shadow">
<div className="flex items-center justify-between p-4 border-b border-border-subtle">
<div className="flex items-center gap-2">
<Bot className="w-5 h-5 text-accent-indigo" />
<span className="text-base font-semibold text-text-primary">AI </span>
</div>
<span className={cn('text-xl font-bold', task.aiResult.score >= 85 ? 'text-accent-green' : task.aiResult.score >= 70 ? 'text-yellow-400' : 'text-accent-coral')}>
{task.aiResult.score}
</span>
</div>
<div className="p-4 space-y-4">
{dimensions && (
<div className="grid grid-cols-2 gap-3">
{(['legal', 'platform', 'brand_safety', 'brief_match'] as const).map(key => {
const dim = dimensions[key]
if (!dim) return null
return (
<div key={key} className={cn('p-3 rounded-xl border', dim.passed ? 'bg-accent-green/5 border-accent-green/20' : 'bg-accent-coral/5 border-accent-coral/20')}>
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-text-secondary">{getDimensionLabel(key)}</span>
{dim.passed ? <CheckCircle className="w-4 h-4 text-accent-green" /> : <XCircle className="w-4 h-4 text-accent-coral" />}
</div>
<span className={cn('text-lg font-bold', dim.passed ? (dim.score >= 85 ? 'text-accent-green' : 'text-yellow-400') : 'text-accent-coral')}>{dim.score}</span>
{dim.issue_count > 0 && <span className="text-xs text-text-tertiary ml-1">({dim.issue_count} )</span>}
</div>
)
})}
</div>
)}
{violations.length > 0 && (
<div>
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-accent-coral" /> ({violations.length})
</h4>
<div className="space-y-2">
{violations.map((v, idx) => (
<div key={idx} className="p-3 bg-accent-coral/10 rounded-xl border border-accent-coral/30">
<div className="flex items-center gap-2 mb-1">
<span className="px-2 py-0.5 rounded text-xs font-semibold bg-accent-coral/15 text-accent-coral">{v.type}</span>
{v.dimension && <span className="text-xs text-text-tertiary">{getDimensionLabel(v.dimension)}</span>}
</div>
<p className="text-sm text-text-primary">{v.content}</p>
<p className="text-xs text-accent-indigo mt-1">{v.suggestion}</p>
</div>
))}
</div>
</div>
)}
{/* Brief 匹配度详情 */}
{briefMatchDetail && (
<div>
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2">
<Target className="w-4 h-4 text-accent-indigo" /> Brief
</h4>
<div className="p-3 bg-bg-elevated rounded-xl space-y-3">
{/* 评分说明 */}
<p className="text-sm text-text-secondary">{briefMatchDetail.explanation}</p>
{/* 覆盖率进度条 */}
{briefMatchDetail.total_points > 0 && (
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-text-tertiary"></span>
<span className="text-text-primary font-medium">{briefMatchDetail.matched_points}/{briefMatchDetail.required_points > 0 ? briefMatchDetail.required_points : briefMatchDetail.total_points} </span>
</div>
<div className="h-2 bg-bg-page rounded-full overflow-hidden">
<div className={cn('h-full rounded-full transition-all', briefMatchDetail.coverage_score >= 80 ? 'bg-accent-green' : briefMatchDetail.coverage_score >= 50 ? 'bg-accent-amber' : 'bg-accent-coral')} style={{ width: `${briefMatchDetail.coverage_score}%` }} />
</div>
</div>
)}
{/* 亮点 */}
{briefMatchDetail.highlights.length > 0 && (
<div>
<p className="text-xs text-accent-green font-medium mb-1"></p>
<div className="space-y-1">
{briefMatchDetail.highlights.map((h, i) => (
<div key={i} className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 text-accent-green flex-shrink-0 mt-0.5" />
<span className="text-xs text-text-secondary">{h}</span>
</div>
))}
</div>
</div>
)}
{/* 问题点 */}
{briefMatchDetail.issues.length > 0 && (
<div>
<p className="text-xs text-accent-coral font-medium mb-1"></p>
<div className="space-y-1">
{briefMatchDetail.issues.map((issue, i) => (
<div key={i} className="flex items-start gap-2">
<AlertTriangle className="w-3.5 h-3.5 text-accent-coral flex-shrink-0 mt-0.5" />
<span className="text-xs text-text-secondary">{issue}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
)}
{/* 卖点匹配列表 */}
{sellingPointMatches && sellingPointMatches.length > 0 && (
<div>
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2">
<Target className="w-4 h-4 text-accent-green" />
</h4>
<div className="space-y-2">
{sellingPointMatches.map((sp, idx) => (
<div key={idx} className="flex items-start gap-2 p-2.5 rounded-xl bg-bg-elevated">
{sp.matched ? <CheckCircle className="w-4 h-4 text-accent-green flex-shrink-0 mt-0.5" /> : <XCircle className="w-4 h-4 text-accent-coral flex-shrink-0 mt-0.5" />}
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm text-text-primary">{sp.content}</span>
<span className={cn('px-1.5 py-0.5 text-xs rounded',
sp.priority === 'core' ? 'bg-accent-coral/20 text-accent-coral' :
sp.priority === 'recommended' ? 'bg-accent-amber/20 text-accent-amber' :
'bg-bg-page text-text-tertiary'
)}>{sp.priority === 'core' ? '核心' : sp.priority === 'recommended' ? '推荐' : '参考'}</span>
</div>
{sp.evidence && <p className="text-xs text-text-tertiary mt-0.5">{sp.evidence}</p>}
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
)
}
function ReviewFeedbackCard({ review, type }: { review: { result: string; comment: string; reviewer: string; time: string }; type: 'agency' | 'brand' }) {
const isApproved = review.result === 'approved'
const title = type === 'agency' ? '代理商审核意见' : '品牌方终审意见'
return (
<div className={cn('bg-bg-card rounded-2xl card-shadow border', isApproved ? 'border-accent-green/30' : 'border-accent-coral/30')}>
<div className="flex items-center gap-2 p-4 border-b border-border-subtle">
{isApproved ? <CheckCircle className="w-5 h-5 text-accent-green" /> : <XCircle className="w-5 h-5 text-accent-coral" />}
<span className="text-base font-semibold text-text-primary">{title}</span>
</div>
<div className="p-4">
<div className="flex items-center gap-2 mb-2">
<span className="font-medium text-text-primary">{review.reviewer}</span>
<span className={cn('px-2 py-0.5 rounded text-xs font-semibold', isApproved ? 'bg-accent-green/15 text-accent-green' : 'bg-accent-coral/15 text-accent-coral')}>
{isApproved ? '通过' : '驳回'}
</span>
</div>
{review.comment && <p className="text-sm text-text-secondary">{review.comment}</p>}
<p className="text-xs text-text-tertiary mt-2">{review.time}</p>
</div>
</div>
)
}
function UploadView({ task, toast, briefData, onUploaded }: { task: TaskData; toast: ReturnType<typeof useToast>; briefData: typeof mockBriefData; onUploaded: () => void }) {
const isScript = task.phase === 'script'
return (
<div className="flex flex-col gap-6 h-full">
{isScript && <AgencyBriefSection toast={toast} briefData={briefData} />}
<FileUploadSection taskId={task.id} phase={task.phase} onUploaded={onUploaded} />
</div>
)
}
function AIReviewingView({ task }: { task: TaskData }) {
return (
<div className="flex-1 flex items-center justify-center">
<div className="bg-bg-card rounded-2xl 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">
{task.phase === 'script' ? '脚本内容审核' : '视频内容审核'} ·
</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 animate-pulse" />
<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 {task.phase === 'script' ? '脚本' : '视频'}
</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>
{task.reviewLogs && task.reviewLogs.length > 0 && (
<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-xl bg-bg-page border border-border-subtle text-text-secondary text-[13px] font-medium">
<Bell className="w-4 h-4" />
</button>
</div>
</div>
)
}
function RejectionView({ task, onAppeal, onReupload }: { task: TaskData; onAppeal: () => void; onReupload: () => void }) {
const getTitle = () => {
switch (task.stage) {
case 'ai_result': return 'AI 审核结果'
case 'agency_rejected': return '代理商审核驳回'
case 'brand_rejected': return '品牌方审核驳回'
default: return '审核结果'
}
}
const getStatusText = () => {
switch (task.stage) {
case 'ai_result': return 'AI 检测到问题,请修改后重新上传'
case 'agency_rejected': return '代理商审核驳回,请根据意见修改'
case 'brand_rejected': return '品牌方审核驳回,请根据意见修改'
default: return '需要修改'
}
}
return (
<div className="flex flex-col gap-6 h-full">
<ReviewProgressBar task={task} />
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
<div className="flex items-center gap-3 pb-5 border-b border-border-subtle">
<div className="w-12 h-12 rounded-xl bg-accent-coral/15 flex items-center justify-center">
<XCircle className="w-6 h-6 text-accent-coral" />
</div>
<div className="flex flex-col gap-0.5">
<span className="text-lg font-semibold text-text-primary">{getTitle()}</span>
<span className="text-sm text-accent-coral font-medium">{getStatusText()}</span>
</div>
</div>
{task.rejectionReason && (
<div className="py-4 border-b border-border-subtle">
<p className="text-sm text-text-secondary leading-relaxed">{task.rejectionReason}</p>
</div>
)}
<div className="flex items-center justify-between pt-4">
<button type="button" onClick={onAppeal} className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-bg-elevated border border-border-subtle text-text-secondary text-sm font-medium hover:bg-bg-page transition-colors">
<MessageCircle className="w-[18px] h-[18px]" />
</button>
<button type="button" onClick={onReupload} className="flex items-center gap-2 px-6 py-2.5 rounded-xl bg-accent-green text-white text-sm font-semibold hover:bg-accent-green/90 transition-colors">
<Upload className="w-[18px] h-[18px]" />
</button>
</div>
</div>
{task.stage === 'agency_rejected' && task.agencyReview && <ReviewFeedbackCard review={task.agencyReview} type="agency" />}
{task.stage === 'brand_rejected' && task.brandReview && <ReviewFeedbackCard review={task.brandReview} type="brand" />}
{task.stage === 'brand_rejected' && task.agencyReview && <ReviewFeedbackCard review={task.agencyReview} type="agency" />}
<AIResultDetailSection task={task} />
</div>
)
}
function WaitingReviewView({ task }: { task: TaskData }) {
const isAgency = task.stage === 'agency_reviewing'
const title = isAgency ? '等待代理商审核' : '等待品牌方终审'
const description = isAgency ? '您的内容已进入代理商审核环节,请耐心等待' : '您的内容已进入品牌方终审环节,这是最后一步'
return (
<div className="flex flex-col gap-6 h-full">
<ReviewProgressBar task={task} />
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 rounded-xl bg-accent-indigo/15 flex items-center justify-center">
<Clock className="w-6 h-6 text-accent-indigo" />
</div>
<div className="flex flex-col gap-0.5">
<span className="text-lg font-semibold text-text-primary">{title}</span>
<span className="text-sm text-text-secondary">{description}</span>
</div>
</div>
<div className="bg-bg-elevated rounded-xl p-4 flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-sm text-text-tertiary"></span>
<span className="text-sm text-text-primary">{task.submittedAt || '刚刚'}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-text-tertiary">AI审核</span>
<span className="text-sm text-accent-green font-medium"></span>
</div>
{isAgency && (
<div className="flex items-center justify-between">
<span className="text-sm text-text-tertiary"></span>
<span className="text-sm text-accent-indigo font-medium">...</span>
</div>
)}
{!isAgency && (
<>
<div className="flex items-center justify-between">
<span className="text-sm text-text-tertiary"></span>
<span className="text-sm text-accent-green font-medium"></span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-text-tertiary"></span>
<span className="text-sm text-accent-indigo font-medium">...</span>
</div>
</>
)}
</div>
</div>
{!isAgency && task.agencyReview && <ReviewFeedbackCard review={task.agencyReview} type="agency" />}
<AIResultDetailSection task={task} />
</div>
)
}
function ApprovedView({ task }: { task: TaskData }) {
const isVideoPhase = task.phase === 'video'
return (
<div className="flex flex-col gap-6 h-full">
<ReviewProgressBar task={task} />
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 rounded-xl bg-accent-green/15 flex items-center justify-center">
<CheckCircle className="w-6 h-6 text-accent-green" />
</div>
<div className="flex flex-col gap-0.5">
<span className="text-lg font-semibold text-text-primary">{isVideoPhase ? '全部审核通过' : '脚本审核通过'}</span>
<span className="text-sm text-text-secondary">{isVideoPhase ? '可以安排发布了' : '请在 7 天内上传视频'}</span>
</div>
</div>
<div className="bg-accent-green/10 rounded-xl p-4">
<div className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-accent-green flex-shrink-0 mt-0.5" />
<div className="flex flex-col gap-1">
<span className="text-sm font-medium text-text-primary">{isVideoPhase ? '恭喜完成!' : '下一步'}</span>
<span className="text-[13px] text-text-secondary">
{isVideoPhase ? '您的视频已通过全部审核流程,可以在平台发布了。' : '脚本已通过审核,请在 7 天内上传对应视频。'}
</span>
</div>
</div>
</div>
</div>
{task.brandReview && <ReviewFeedbackCard review={task.brandReview} type="brand" />}
{task.agencyReview && <ReviewFeedbackCard review={task.agencyReview} type="agency" />}
<AIResultDetailSection task={task} />
{!isVideoPhase && (
<div className="flex justify-center pt-4">
<button type="button" className="flex items-center gap-2 px-12 py-4 rounded-xl bg-accent-green text-white text-base font-semibold">
<Video className="w-5 h-5" /> <ChevronRight className="w-5 h-5" />
</button>
</div>
)}
</div>
)
}
// ========== 主页面 ==========
export default function TaskDetailPage() {
const params = useParams()
const router = useRouter()
const toast = useToast()
const { subscribe } = useSSE()
const taskId = params.id as string
const [taskData, setTaskData] = useState<TaskData | null>(null)
const [briefData, setBriefData] = useState(mockBriefData)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showReupload, setShowReupload] = useState(false)
const loadTask = useCallback(async () => {
if (USE_MOCK) {
const mock = mockTasksData[taskId]
setTaskData(mock || null)
setIsLoading(false)
return
}
try {
const task = await api.getTask(taskId)
setTaskData(mapApiTaskToTaskData(task))
// 加载 Brief
if (task.project?.id) {
try {
const brief = await api.getBrief(task.project.id)
setBriefData({
files: (brief.attachments || []).map((a, i) => ({
id: a.id || `att-${i}`,
name: a.name,
size: a.size || '',
uploadedAt: brief.updated_at || '',
})),
sellingPoints: (brief.selling_points || []).map((sp, i) => ({
id: `sp-${i}`,
content: sp.content,
priority: (sp.priority || (sp.required ? 'core' : 'recommended')) as 'core' | 'recommended' | 'reference',
})),
blacklistWords: (brief.blacklist_words || []).map((bw, i) => ({
id: `bw-${i}`,
word: bw.word,
reason: bw.reason,
})),
})
} catch {
// Brief 可能不存在,不影响任务展示
}
}
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败')
} finally {
setIsLoading(false)
}
}, [taskId])
useEffect(() => {
loadTask()
}, [loadTask])
// SSE 实时更新
useEffect(() => {
const unsub1 = subscribe('task_updated', (data) => {
if ((data as { task_id?: string }).task_id === taskId) loadTask()
})
const unsub2 = subscribe('review_completed', (data) => {
if ((data as { task_id?: string }).task_id === taskId) loadTask()
})
return () => { unsub1(); unsub2() }
}, [subscribe, taskId, loadTask])
// AI 审核中时轮询SSE 后备方案)
useEffect(() => {
if (!taskData || (taskData.stage !== 'ai_reviewing') || USE_MOCK) return
const interval = setInterval(() => { loadTask() }, 5000)
return () => clearInterval(interval)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [taskData?.stage, loadTask])
if (isLoading) {
return (
<ResponsiveLayout role="creator">
<div className="flex items-center justify-center h-full">
<Loader2 className="w-8 h-8 text-accent-indigo animate-spin" />
</div>
</ResponsiveLayout>
)
}
if (error || !taskData) {
return (
<ResponsiveLayout role="creator">
<div className="flex items-center justify-center h-full">
<div className="flex flex-col items-center gap-4">
<XCircle className="w-16 h-16 text-text-tertiary" />
<p className="text-lg text-text-secondary">{error || '任务不存在'}</p>
<button type="button" onClick={() => router.back()} className="px-6 py-2.5 rounded-xl bg-accent-indigo text-white text-sm font-medium">
</button>
</div>
</div>
</ResponsiveLayout>
)
}
const handleAppeal = () => {
router.push(`/creator/appeals/new?taskId=${taskId}`)
}
const renderContent = () => {
// 驳回状态下选择重新上传时,显示上传界面
if (showReupload && (taskData.stage === 'ai_result' || taskData.stage === 'agency_rejected' || taskData.stage === 'brand_rejected')) {
return (
<div className="flex flex-col gap-6 h-full">
<div className="flex items-center gap-3">
<button type="button" onClick={() => setShowReupload(false)} className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-bg-elevated text-text-secondary text-sm hover:bg-bg-card transition-colors">
<ArrowLeft className="w-4 h-4" />
</button>
</div>
{taskData.phase === 'script' && <AgencyBriefSection toast={toast} briefData={briefData} />}
<FileUploadSection taskId={taskData.id} phase={taskData.phase} onUploaded={() => { setShowReupload(false); loadTask() }} />
</div>
)
}
switch (taskData.stage) {
case 'upload': return <UploadView task={taskData} toast={toast} briefData={briefData} onUploaded={loadTask} />
case 'ai_reviewing': return <AIReviewingView task={taskData} />
case 'ai_result':
case 'agency_rejected':
case 'brand_rejected': return <RejectionView task={taskData} onAppeal={handleAppeal} onReupload={() => setShowReupload(true)} />
case 'agency_reviewing':
case 'brand_reviewing': return <WaitingReviewView task={taskData} />
case 'brand_approved': return <ApprovedView task={taskData} />
default: return <div></div>
}
}
const getPageTitle = () => {
switch (taskData.stage) {
case 'upload': return taskData.phase === 'script' ? '上传脚本' : '上传视频'
case 'ai_reviewing': return 'AI 智能审核'
case 'ai_result': return 'AI 审核结果'
case 'agency_reviewing': return '等待代理商审核'
case 'agency_rejected': return '代理商审核驳回'
case 'brand_reviewing': return '等待品牌方终审'
case 'brand_approved': return '审核通过'
case 'brand_rejected': return '品牌方审核驳回'
default: return '任务详情'
}
}
return (
<ResponsiveLayout 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">
<div className="flex items-center gap-3 mb-1">
<button type="button" onClick={() => router.back()} className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-bg-elevated text-text-secondary text-sm hover:bg-bg-card transition-colors">
<ArrowLeft className="w-4 h-4" />
</button>
</div>
<h1 className="text-xl lg:text-[28px] font-bold text-text-primary">{taskData.title}</h1>
<p className="text-sm lg:text-[15px] text-text-secondary">{taskData.subtitle}</p>
</div>
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
{renderContent()}
</div>
</div>
</ResponsiveLayout>
)
}