为品牌方端(8页)、代理商端(10页)、达人端(6页)共24个页面添加真实API调用: - 每页新增 USE_MOCK 条件分支,开发环境使用 mock 数据,生产环境调用真实 API - 添加 loading 骨架屏、error toast 提示、submitting 状态管理 - 数据映射:TaskResponse → 页面视图模型,处理类型差异 - 审核操作(通过/驳回/强制通过)对接 api.reviewScript/reviewVideo - Brief/规则/AI配置对接 api.getBrief/updateBrief/listForbiddenWords 等 - 申诉/历史/额度管理对接 api.listTasks + 状态过滤映射 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
516 lines
20 KiB
TypeScript
516 lines
20 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useEffect, useCallback } from 'react'
|
||
import { useParams, useRouter } from 'next/navigation'
|
||
import {
|
||
ArrowLeft,
|
||
MessageCircle,
|
||
Clock,
|
||
CheckCircle,
|
||
XCircle,
|
||
FileText,
|
||
Image,
|
||
Send,
|
||
AlertTriangle,
|
||
Loader2
|
||
} from 'lucide-react'
|
||
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
|
||
import { cn } from '@/lib/utils'
|
||
import { api } from '@/lib/api'
|
||
import { USE_MOCK } from '@/contexts/AuthContext'
|
||
import { useToast } from '@/components/ui/Toast'
|
||
import type { TaskResponse } from '@/types/task'
|
||
|
||
// 申诉状态类型
|
||
type AppealStatus = 'pending' | 'processing' | 'approved' | 'rejected'
|
||
|
||
// 申诉详情数据类型
|
||
type AppealDetail = {
|
||
id: string
|
||
taskId: string
|
||
taskTitle: string
|
||
type: 'ai' | 'agency' | 'brand'
|
||
reason: string
|
||
content: string
|
||
status: AppealStatus
|
||
createdAt: string
|
||
updatedAt?: string
|
||
result?: string
|
||
attachments?: { name: string; type: 'image' | 'document'; url: string }[]
|
||
timeline?: { time: string; action: string; operator?: string }[]
|
||
originalIssue?: { title: string; description: string }
|
||
}
|
||
|
||
// 模拟申诉详情数据
|
||
const mockAppealDetails: Record<string, AppealDetail> = {
|
||
'appeal-001': {
|
||
id: 'appeal-001',
|
||
taskId: 'task-003',
|
||
taskTitle: 'ZZ饮品夏日',
|
||
type: 'ai',
|
||
reason: '误判',
|
||
content: '视频中出现的是我们自家品牌的历史产品,并非竞品。已附上品牌授权证明。',
|
||
status: 'approved',
|
||
createdAt: '2026-02-01 10:30',
|
||
updatedAt: '2026-02-02 15:20',
|
||
result: '经核实,该产品确为品牌方授权产品,申诉通过。AI已学习此案例,后续将避免类似误判。',
|
||
attachments: [
|
||
{ name: '品牌授权书.pdf', type: 'document', url: '#' },
|
||
{ name: '产品对比图.jpg', type: 'image', url: '#' },
|
||
],
|
||
timeline: [
|
||
{ time: '2026-02-01 10:30', action: '提交申诉' },
|
||
{ time: '2026-02-01 14:15', action: '进入处理队列' },
|
||
{ time: '2026-02-02 09:00', action: '开始审核', operator: '审核员 A' },
|
||
{ time: '2026-02-02 15:20', action: '申诉通过', operator: '审核员 A' },
|
||
],
|
||
originalIssue: {
|
||
title: '检测到竞品 Logo',
|
||
description: '画面中 0:15-0:18 出现竞品「百事可乐」的 Logo,可能造成合规风险。',
|
||
},
|
||
},
|
||
'appeal-002': {
|
||
id: 'appeal-002',
|
||
taskId: 'task-010',
|
||
taskTitle: 'GG智能手表',
|
||
type: 'agency',
|
||
reason: '审核标准不清晰',
|
||
content: '代理商反馈品牌调性不符,但Brief中并未明确说明科技专业形象的具体要求。请明确审核标准。',
|
||
status: 'processing',
|
||
createdAt: '2026-02-04 09:15',
|
||
timeline: [
|
||
{ time: '2026-02-04 09:15', action: '提交申诉' },
|
||
{ time: '2026-02-04 11:30', action: '进入处理队列' },
|
||
{ time: '2026-02-05 10:00', action: '开始审核', operator: '审核员 B' },
|
||
],
|
||
originalIssue: {
|
||
title: '品牌调性不符',
|
||
description: '脚本整体风格偏向娱乐化,与品牌科技专业形象不匹配。',
|
||
},
|
||
},
|
||
'appeal-003': {
|
||
id: 'appeal-003',
|
||
taskId: 'task-011',
|
||
taskTitle: 'HH美妆代言',
|
||
type: 'brand',
|
||
reason: '创意理解差异',
|
||
content: '品牌方认为创意不够新颖,但该创意形式在同类型产品推广中效果显著,已附上数据支持。',
|
||
status: 'pending',
|
||
createdAt: '2026-02-05 14:00',
|
||
attachments: [
|
||
{ name: '同类案例数据.xlsx', type: 'document', url: '#' },
|
||
],
|
||
timeline: [
|
||
{ time: '2026-02-05 14:00', action: '提交申诉' },
|
||
],
|
||
originalIssue: {
|
||
title: '创意不够新颖',
|
||
description: '脚本采用的是常见的口播形式,缺乏创新点和记忆点。',
|
||
},
|
||
},
|
||
'appeal-004': {
|
||
id: 'appeal-004',
|
||
taskId: 'task-013',
|
||
taskTitle: 'JJ旅行vlog',
|
||
type: 'agency',
|
||
reason: '版权问题异议',
|
||
content: '使用的背景音乐来自无版权音乐库 Epidemic Sound,已购买商用授权。附上授权证明截图。',
|
||
status: 'rejected',
|
||
createdAt: '2026-01-28 11:30',
|
||
updatedAt: '2026-01-30 16:45',
|
||
result: '经核实,该音乐虽有授权,但授权范围不包含商业广告用途。建议更换音乐后重新提交。',
|
||
attachments: [
|
||
{ name: '授权截图.png', type: 'image', url: '#' },
|
||
],
|
||
timeline: [
|
||
{ time: '2026-01-28 11:30', action: '提交申诉' },
|
||
{ time: '2026-01-28 15:00', action: '进入处理队列' },
|
||
{ time: '2026-01-29 09:30', action: '开始审核', operator: '审核员 C' },
|
||
{ time: '2026-01-30 16:45', action: '申诉驳回', operator: '审核员 C' },
|
||
],
|
||
originalIssue: {
|
||
title: '背景音乐版权问题',
|
||
description: '视频中使用的背景音乐「XXX」存在版权风险,平台可能会限流或下架。',
|
||
},
|
||
},
|
||
}
|
||
|
||
// 将 TaskResponse 映射为 AppealDetail UI 类型
|
||
function mapTaskToAppealDetail(task: TaskResponse): AppealDetail {
|
||
let type: 'ai' | 'agency' | 'brand' = 'ai'
|
||
if (task.script_brand_status === 'rejected' || task.video_brand_status === 'rejected') {
|
||
type = 'brand'
|
||
} else if (task.script_agency_status === 'rejected' || task.video_agency_status === 'rejected') {
|
||
type = 'agency'
|
||
}
|
||
|
||
let status: AppealStatus = 'pending'
|
||
if (task.stage === 'completed') {
|
||
status = 'approved'
|
||
} else if (task.stage === 'rejected') {
|
||
status = 'rejected'
|
||
} else if (task.is_appeal) {
|
||
status = 'processing'
|
||
}
|
||
|
||
const formatDate = (dateStr: string) => {
|
||
return new Date(dateStr).toLocaleString('zh-CN', {
|
||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||
hour: '2-digit', minute: '2-digit',
|
||
})
|
||
}
|
||
|
||
// Build original issue from review comments
|
||
let originalIssue: { title: string; description: string } | undefined
|
||
const rejectionComment =
|
||
task.script_brand_comment ||
|
||
task.script_agency_comment ||
|
||
task.video_brand_comment ||
|
||
task.video_agency_comment
|
||
if (rejectionComment) {
|
||
originalIssue = {
|
||
title: '审核驳回',
|
||
description: rejectionComment,
|
||
}
|
||
}
|
||
|
||
// Build timeline from task dates
|
||
const timeline: { time: string; action: string; operator?: string }[] = []
|
||
if (task.created_at) {
|
||
timeline.push({ time: formatDate(task.created_at), action: '任务创建' })
|
||
}
|
||
if (task.updated_at) {
|
||
timeline.push({ time: formatDate(task.updated_at), action: '提交申诉' })
|
||
}
|
||
|
||
return {
|
||
id: task.id,
|
||
taskId: task.id,
|
||
taskTitle: task.name,
|
||
type,
|
||
reason: task.appeal_reason || '申诉',
|
||
content: task.appeal_reason || '',
|
||
status,
|
||
createdAt: task.created_at ? formatDate(task.created_at) : '',
|
||
updatedAt: task.updated_at ? formatDate(task.updated_at) : undefined,
|
||
originalIssue,
|
||
timeline: timeline.length > 0 ? timeline : undefined,
|
||
}
|
||
}
|
||
|
||
// 状态配置
|
||
const statusConfig: Record<AppealStatus, { label: string; color: string; bgColor: string; icon: React.ElementType }> = {
|
||
pending: { label: '待处理', color: 'text-amber-500', bgColor: 'bg-amber-500/15', icon: Clock },
|
||
processing: { label: '处理中', color: 'text-accent-indigo', bgColor: 'bg-accent-indigo/15', icon: MessageCircle },
|
||
approved: { label: '已通过', color: 'text-accent-green', bgColor: 'bg-accent-green/15', icon: CheckCircle },
|
||
rejected: { label: '已驳回', color: 'text-accent-coral', bgColor: 'bg-accent-coral/15', icon: XCircle },
|
||
}
|
||
|
||
// 类型配置
|
||
const typeConfig: Record<string, { label: string; color: string }> = {
|
||
ai: { label: 'AI审核', color: 'text-accent-indigo' },
|
||
agency: { label: '代理商审核', color: 'text-purple-400' },
|
||
brand: { label: '品牌方审核', color: 'text-accent-blue' },
|
||
}
|
||
|
||
// 骨架屏组件
|
||
function DetailSkeleton() {
|
||
return (
|
||
<div className="flex flex-col gap-6 h-full animate-pulse">
|
||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||
<div className="flex flex-col gap-2">
|
||
<div className="h-8 w-16 bg-bg-elevated rounded-lg" />
|
||
<div className="h-6 w-32 bg-bg-elevated rounded" />
|
||
<div className="h-4 w-48 bg-bg-elevated rounded" />
|
||
</div>
|
||
<div className="h-10 w-24 bg-bg-elevated rounded-xl" />
|
||
</div>
|
||
<div className="flex flex-col lg:flex-row gap-6 flex-1">
|
||
<div className="flex-1 flex flex-col gap-5">
|
||
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
|
||
<div className="h-5 w-32 bg-bg-elevated rounded mb-4" />
|
||
<div className="h-20 bg-bg-elevated rounded-xl" />
|
||
</div>
|
||
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
|
||
<div className="h-5 w-24 bg-bg-elevated rounded mb-4" />
|
||
<div className="h-4 w-full bg-bg-elevated rounded mb-2" />
|
||
<div className="h-4 w-3/4 bg-bg-elevated rounded mb-4" />
|
||
<div className="h-16 bg-bg-elevated rounded-xl" />
|
||
</div>
|
||
</div>
|
||
<div className="lg:w-[320px]">
|
||
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
|
||
<div className="h-5 w-24 bg-bg-elevated rounded mb-5" />
|
||
<div className="flex flex-col gap-6">
|
||
<div className="h-10 bg-bg-elevated rounded" />
|
||
<div className="h-10 bg-bg-elevated rounded" />
|
||
<div className="h-10 bg-bg-elevated rounded" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function AppealDetailPage() {
|
||
const params = useParams()
|
||
const router = useRouter()
|
||
const toast = useToast()
|
||
const appealId = params.id as string
|
||
const [newComment, setNewComment] = useState('')
|
||
const [appeal, setAppeal] = useState<AppealDetail | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [submitting, setSubmitting] = useState(false)
|
||
|
||
const loadAppealDetail = useCallback(async () => {
|
||
if (USE_MOCK) {
|
||
const mockAppeal = mockAppealDetails[appealId]
|
||
setAppeal(mockAppeal || null)
|
||
setLoading(false)
|
||
return
|
||
}
|
||
|
||
try {
|
||
setLoading(true)
|
||
const task = await api.getTask(appealId)
|
||
const mapped = mapTaskToAppealDetail(task)
|
||
setAppeal(mapped)
|
||
} catch (err) {
|
||
console.error('加载申诉详情失败:', err)
|
||
toast.error('加载申诉详情失败,请稍后重试')
|
||
setAppeal(null)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [appealId, toast])
|
||
|
||
useEffect(() => {
|
||
loadAppealDetail()
|
||
}, [loadAppealDetail])
|
||
|
||
const handleSendComment = async () => {
|
||
if (!newComment.trim()) return
|
||
|
||
if (USE_MOCK) {
|
||
toast.success('补充说明已发送')
|
||
setNewComment('')
|
||
return
|
||
}
|
||
|
||
try {
|
||
setSubmitting(true)
|
||
// Use submitAppeal to add supplementary info (re-appeal with updated reason)
|
||
await api.submitAppeal(appealId, { reason: newComment.trim() })
|
||
toast.success('补充说明已发送')
|
||
setNewComment('')
|
||
// Reload to reflect any changes
|
||
loadAppealDetail()
|
||
} catch (err) {
|
||
console.error('发送补充说明失败:', err)
|
||
toast.error('发送失败,请稍后重试')
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<ResponsiveLayout role="creator">
|
||
<DetailSkeleton />
|
||
</ResponsiveLayout>
|
||
)
|
||
}
|
||
|
||
if (!appeal) {
|
||
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">申诉记录不存在</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 status = statusConfig[appeal.status]
|
||
const type = typeConfig[appeal.type]
|
||
const StatusIcon = status.icon
|
||
|
||
return (
|
||
<ResponsiveLayout role="creator">
|
||
<div className="flex flex-col gap-6 h-full">
|
||
{/* 顶部栏 */}
|
||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||
<div className="flex flex-col gap-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 w-fit mb-2"
|
||
>
|
||
<ArrowLeft className="w-4 h-4" />
|
||
返回
|
||
</button>
|
||
<h1 className="text-xl lg:text-[28px] font-bold text-text-primary">申诉详情</h1>
|
||
<p className="text-sm lg:text-[15px] text-text-secondary">申诉编号: {appeal.id}</p>
|
||
</div>
|
||
<div className={cn('px-4 py-2 rounded-xl flex items-center gap-2', status.bgColor)}>
|
||
<StatusIcon className={cn('w-5 h-5', status.color)} />
|
||
<span className={cn('font-semibold', status.color)}>{status.label}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 内容区 - 响应式布局 */}
|
||
<div className="flex flex-col lg:flex-row gap-6 flex-1 min-h-0 overflow-y-auto lg:overflow-hidden">
|
||
{/* 左侧:申诉信息 */}
|
||
<div className="flex-1 flex flex-col gap-5 lg:overflow-y-auto lg:pr-2">
|
||
{/* 原始问题 */}
|
||
{appeal.originalIssue && (
|
||
<div className="bg-bg-card rounded-2xl p-4 lg:p-6 card-shadow">
|
||
<h3 className="text-base lg:text-lg font-semibold text-text-primary mb-4">原始审核问题</h3>
|
||
<div className="bg-accent-coral/10 rounded-xl p-4">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<AlertTriangle className="w-5 h-5 text-accent-coral" />
|
||
<span className="font-semibold text-text-primary">{appeal.originalIssue.title}</span>
|
||
</div>
|
||
<p className="text-sm text-text-secondary">{appeal.originalIssue.description}</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 申诉内容 */}
|
||
<div className="bg-bg-card rounded-2xl p-4 lg:p-6 card-shadow">
|
||
<h3 className="text-base lg:text-lg font-semibold text-text-primary mb-4">申诉内容</h3>
|
||
<div className="flex flex-col gap-4">
|
||
<div className="flex flex-col lg:flex-row lg:items-center gap-2 lg:gap-6">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-sm text-text-tertiary">关联任务:</span>
|
||
<span className="text-sm font-medium text-text-primary">{appeal.taskTitle}</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-sm text-text-tertiary">申诉对象:</span>
|
||
<span className={cn('text-sm font-medium', type.color)}>{type.label}</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-sm text-text-tertiary">申诉原因:</span>
|
||
<span className="text-sm font-medium text-text-primary">{appeal.reason}</span>
|
||
</div>
|
||
<div className="bg-bg-elevated rounded-xl p-4">
|
||
<p className="text-sm text-text-secondary leading-relaxed">{appeal.content}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 附件 */}
|
||
{appeal.attachments && appeal.attachments.length > 0 && (
|
||
<div className="bg-bg-card rounded-2xl p-4 lg:p-6 card-shadow">
|
||
<h3 className="text-base lg:text-lg font-semibold text-text-primary mb-4">证明材料</h3>
|
||
<div className="flex flex-wrap gap-3">
|
||
{appeal.attachments.map((attachment, index) => (
|
||
<div
|
||
key={index}
|
||
className="flex items-center gap-3 px-4 py-3 bg-bg-elevated rounded-xl cursor-pointer hover:bg-bg-page transition-colors"
|
||
>
|
||
{attachment.type === 'image' ? (
|
||
<Image className="w-5 h-5 text-accent-indigo" />
|
||
) : (
|
||
<FileText className="w-5 h-5 text-accent-indigo" />
|
||
)}
|
||
<span className="text-sm text-text-primary">{attachment.name}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 处理结果 */}
|
||
{appeal.result && (
|
||
<div className="bg-bg-card rounded-2xl p-4 lg:p-6 card-shadow">
|
||
<h3 className="text-base lg:text-lg font-semibold text-text-primary mb-4">处理结果</h3>
|
||
<div className={cn(
|
||
'rounded-xl p-4',
|
||
appeal.status === 'approved' ? 'bg-accent-green/10' : 'bg-accent-coral/10'
|
||
)}>
|
||
<p className="text-sm text-text-secondary leading-relaxed">{appeal.result}</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 补充说明(处理中状态可用) */}
|
||
{(appeal.status === 'pending' || appeal.status === 'processing') && (
|
||
<div className="bg-bg-card rounded-2xl p-4 lg:p-6 card-shadow">
|
||
<h3 className="text-base lg:text-lg font-semibold text-text-primary mb-4">补充说明</h3>
|
||
<div className="flex items-center gap-3">
|
||
<input
|
||
type="text"
|
||
placeholder="输入补充说明..."
|
||
value={newComment}
|
||
onChange={(e) => setNewComment(e.target.value)}
|
||
className="flex-1 px-4 py-3 bg-bg-elevated rounded-xl text-sm text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
|
||
disabled={submitting}
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={handleSendComment}
|
||
disabled={submitting || !newComment.trim()}
|
||
className={cn(
|
||
'px-5 py-3 rounded-xl bg-accent-indigo text-white text-sm font-medium flex items-center gap-2',
|
||
(submitting || !newComment.trim()) && 'opacity-50 cursor-not-allowed'
|
||
)}
|
||
>
|
||
{submitting ? (
|
||
<Loader2 className="w-4 h-4 animate-spin" />
|
||
) : (
|
||
<Send className="w-4 h-4" />
|
||
)}
|
||
{submitting ? '发送中...' : '发送'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 右侧:时间线 */}
|
||
<div className="lg:w-[320px] lg:flex-shrink-0">
|
||
<div className="bg-bg-card rounded-2xl p-4 lg:p-6 card-shadow lg:h-full">
|
||
<h3 className="text-base lg:text-lg font-semibold text-text-primary mb-5">处理进度</h3>
|
||
<div className="flex flex-col gap-0">
|
||
{appeal.timeline?.map((item, index) => (
|
||
<div key={index} className="flex gap-4">
|
||
<div className="flex flex-col items-center">
|
||
<div className={cn(
|
||
'w-3 h-3 rounded-full',
|
||
index === (appeal.timeline?.length || 0) - 1 ? 'bg-accent-indigo' : 'bg-text-tertiary'
|
||
)} />
|
||
{index < (appeal.timeline?.length || 0) - 1 && (
|
||
<div className="w-0.5 h-16 bg-border-subtle" />
|
||
)}
|
||
</div>
|
||
<div className="flex flex-col gap-1 pb-6">
|
||
<span className="text-xs text-text-tertiary">{item.time}</span>
|
||
<span className="text-sm font-medium text-text-primary">{item.action}</span>
|
||
{item.operator && (
|
||
<span className="text-xs text-text-secondary">{item.operator}</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</ResponsiveLayout>
|
||
)
|
||
}
|