Your Name a8be7bbca9 feat: 前端剩余页面全面对接后端 API(Phase 2 完成)
为品牌方端(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>
2026-02-09 16:29:43 +08:00

517 lines
19 KiB
TypeScript
Raw 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 { useRouter, useParams } from 'next/navigation'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import {
ArrowLeft,
MessageSquare,
Clock,
CheckCircle,
XCircle,
AlertTriangle,
User,
FileText,
Video,
Download,
File,
Send,
Image as ImageIcon,
Loader2
} from 'lucide-react'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import type { TaskResponse } from '@/types/task'
// 申诉状态类型
type AppealStatus = 'pending' | 'processing' | 'approved' | 'rejected'
// 申诉详情类型
interface AppealDetail {
id: string
taskId: string
taskTitle: string
creatorId: string
creatorName: string
creatorAvatar: string
type: 'ai' | 'agency'
contentType: 'script' | 'video'
reason: string
content: string
status: AppealStatus
createdAt: string
appealCount: number
attachments: { id: string; name: string; size: string; type: string }[]
originalIssue: {
type: string
title: string
description: string
location: string
}
taskInfo: {
projectName: string
scriptFileName: string
scriptFileSize: string
}
}
// 模拟申诉详情数据
const mockAppealDetail: AppealDetail = {
id: 'appeal-001',
taskId: 'task-001',
taskTitle: '夏日护肤推广脚本',
creatorId: 'creator-001',
creatorName: '小美护肤',
creatorAvatar: '小',
type: 'ai' as const,
contentType: 'script' as const,
reason: 'AI误判',
content: '脚本中提到的"某品牌"是泛指并非特指竞品AI系统可能误解了语境。我在脚本中使用的是泛化表述并没有提及任何具体的竞品名称。请代理商重新审核此处谢谢',
status: 'pending' as AppealStatus,
createdAt: '2026-02-06 10:30',
appealCount: 1,
// 附件
attachments: [
{ id: 'att-001', name: '品牌授权证明.pdf', size: '1.2 MB', type: 'pdf' },
{ id: 'att-002', name: '脚本原文截图.png', size: '345 KB', type: 'image' },
],
// 原审核问题
originalIssue: {
type: 'ai',
title: '疑似竞品提及',
description: '脚本第3段提到"某品牌",可能涉及竞品露出风险。',
location: '脚本第3段第2行',
},
// 相关任务信息
taskInfo: {
projectName: 'XX品牌618推广',
scriptFileName: '夏日护肤推广_脚本v2.docx',
scriptFileSize: '245 KB',
},
}
// Derive a UI-compatible appeal detail from a TaskResponse
function mapTaskToAppealDetail(task: TaskResponse) {
const isVideoStage = task.stage.startsWith('video')
const contentType: 'script' | 'video' = isVideoStage ? 'video' : 'script'
const type: 'ai' | 'agency' = task.stage.includes('ai') ? 'ai' : 'agency'
let status: AppealStatus = 'pending'
if (task.stage === 'completed') {
status = 'approved'
} else if (task.stage === 'rejected') {
status = 'rejected'
} else if (task.stage.includes('review')) {
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',
}).replace(/\//g, '-')
}
// Extract original issue from AI results if available
const aiResult = isVideoStage ? task.video_ai_result : task.script_ai_result
const agencyComment = isVideoStage ? task.video_agency_comment : task.script_agency_comment
const originalIssueTitle = aiResult?.violations?.[0]?.type || agencyComment || '审核问题'
const originalIssueDesc = aiResult?.violations?.[0]?.content || agencyComment || ''
const originalIssueLocation = aiResult?.violations?.[0]?.source || ''
return {
id: task.id,
taskId: task.id,
taskTitle: task.name,
creatorId: task.creator.id,
creatorName: task.creator.name,
creatorAvatar: task.creator.name.charAt(0),
type,
contentType,
reason: task.appeal_reason || '申诉',
content: task.appeal_reason || '',
status,
createdAt: formatDate(task.updated_at),
appealCount: task.appeal_count,
attachments: [] as { id: string; name: string; size: string; type: string }[],
originalIssue: {
type: type === 'ai' ? 'ai' : 'agency',
title: originalIssueTitle,
description: originalIssueDesc,
location: originalIssueLocation,
},
taskInfo: {
projectName: task.project.name,
scriptFileName: isVideoStage
? (task.video_file_name || '视频文件')
: (task.script_file_name || '脚本文件'),
scriptFileSize: '-',
},
}
}
// 状态配置
const statusConfig: Record<AppealStatus, { label: string; color: string; bgColor: string; icon: React.ElementType }> = {
pending: { label: '待处理', color: 'text-accent-amber', bgColor: 'bg-accent-amber/15', icon: Clock },
processing: { label: '处理中', color: 'text-accent-indigo', bgColor: 'bg-accent-indigo/15', icon: MessageSquare },
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 },
}
export default function AgencyAppealDetailPage() {
const router = useRouter()
const toast = useToast()
const params = useParams()
const taskId = params.id as string
const [appeal, setAppeal] = useState(mockAppealDetail)
const [replyContent, setReplyContent] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [loading, setLoading] = useState(true)
const fetchAppeal = useCallback(async () => {
if (USE_MOCK) {
setAppeal(mockAppealDetail)
setLoading(false)
return
}
try {
setLoading(true)
const task = await api.getTask(taskId)
setAppeal(mapTaskToAppealDetail(task))
} catch (err) {
console.error('Failed to fetch appeal detail:', err)
toast.error('加载申诉详情失败')
} finally {
setLoading(false)
}
}, [taskId, toast])
useEffect(() => {
fetchAppeal()
}, [fetchAppeal])
const status = statusConfig[appeal.status]
const StatusIcon = status.icon
const handleApprove = async () => {
if (!replyContent.trim()) {
toast.error('请填写处理意见')
return
}
setIsSubmitting(true)
try {
if (USE_MOCK) {
await new Promise(resolve => setTimeout(resolve, 1000))
} else {
// Determine if this is script or video review based on the appeal's content type
const isVideo = appeal.contentType === 'video'
if (isVideo) {
await api.reviewVideo(taskId, { action: 'pass', comment: replyContent })
} else {
await api.reviewScript(taskId, { action: 'pass', comment: replyContent })
}
}
toast.success('申诉已通过')
router.push('/agency/appeals')
} catch (err) {
console.error('Failed to approve appeal:', err)
toast.error('操作失败,请重试')
setIsSubmitting(false)
}
}
const handleReject = async () => {
if (!replyContent.trim()) {
toast.error('请填写驳回原因')
return
}
setIsSubmitting(true)
try {
if (USE_MOCK) {
await new Promise(resolve => setTimeout(resolve, 1000))
} else {
const isVideo = appeal.contentType === 'video'
if (isVideo) {
await api.reviewVideo(taskId, { action: 'reject', comment: replyContent })
} else {
await api.reviewScript(taskId, { action: 'reject', comment: replyContent })
}
}
toast.success('申诉已驳回')
router.push('/agency/appeals')
} catch (err) {
console.error('Failed to reject appeal:', err)
toast.error('操作失败,请重试')
setIsSubmitting(false)
}
}
if (loading) {
return (
<div className="flex flex-col items-center justify-center py-24 text-text-tertiary">
<Loader2 size={32} className="animate-spin mb-4" />
<p>...</p>
</div>
)
}
return (
<div className="space-y-6">
{/* 顶部导航 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<button
type="button"
onClick={() => router.back()}
className="p-2 rounded-lg hover:bg-bg-elevated transition-colors"
>
<ArrowLeft size={20} className="text-text-secondary" />
</button>
<div>
<h1 className="text-2xl font-bold text-text-primary"></h1>
<p className="text-sm text-text-secondary mt-0.5">: {appeal.id}</p>
</div>
</div>
<div className={`flex items-center gap-2 px-4 py-2 rounded-xl ${status.bgColor}`}>
<StatusIcon size={18} className={status.color} />
<span className={`font-medium ${status.color}`}>{status.label}</span>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 左侧:申诉详情 */}
<div className="lg:col-span-2 space-y-6">
{/* 申诉人信息 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User size={18} className="text-accent-indigo" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-accent-indigo to-purple-500 flex items-center justify-center text-white font-bold text-lg">
{appeal.creatorAvatar}
</div>
<div>
<p className="font-medium text-text-primary">{appeal.creatorName}</p>
<p className="text-sm text-text-secondary">ID: {appeal.creatorId}</p>
</div>
</div>
<div className="mt-4 pt-4 border-t border-border-subtle grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-text-tertiary"></span>
<p className="text-text-primary mt-1">{appeal.taskTitle}</p>
</div>
<div>
<span className="text-text-tertiary"></span>
<p className="text-text-primary mt-1">{appeal.taskInfo.projectName}</p>
</div>
<div>
<span className="text-text-tertiary"></span>
<p className="text-text-primary mt-1 flex items-center gap-1">
{appeal.contentType === 'script' ? <FileText size={14} /> : <Video size={14} />}
{appeal.contentType === 'script' ? '脚本' : '视频'}
</p>
</div>
<div>
<span className="text-text-tertiary"></span>
<p className="text-text-primary mt-1">{appeal.createdAt}</p>
</div>
</div>
</CardContent>
</Card>
{/* 原审核问题 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle size={18} className="text-accent-coral" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="p-4 rounded-xl bg-accent-coral/10 border border-accent-coral/20">
<div className="flex items-center gap-2 mb-2">
<span className="px-2 py-0.5 text-xs rounded bg-accent-coral/20 text-accent-coral">
{appeal.originalIssue.type === 'ai' ? 'AI检测' : '人工审核'}
</span>
<span className="font-medium text-text-primary">{appeal.originalIssue.title}</span>
</div>
<p className="text-sm text-text-secondary">{appeal.originalIssue.description}</p>
{appeal.originalIssue.location && (
<p className="text-xs text-text-tertiary mt-2">: {appeal.originalIssue.location}</p>
)}
</div>
</CardContent>
</Card>
{/* 申诉内容 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageSquare size={18} className="text-accent-indigo" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<span className="text-sm text-text-tertiary"></span>
<p className="text-text-primary mt-1 font-medium">{appeal.reason}</p>
</div>
<div>
<span className="text-sm text-text-tertiary"></span>
<p className="text-text-primary mt-1 leading-relaxed">{appeal.content}</p>
</div>
<div>
<span className="text-sm text-text-tertiary"></span>
<p className="text-text-primary mt-1">{appeal.appealCount} </p>
</div>
{/* 附件 */}
{appeal.attachments.length > 0 && (
<div>
<span className="text-sm text-text-tertiary"></span>
<div className="mt-2 space-y-2">
{appeal.attachments.map((att) => (
<div
key={att.id}
className="flex items-center gap-3 p-3 rounded-lg bg-bg-elevated"
>
<div className="w-10 h-10 rounded-lg bg-accent-indigo/15 flex items-center justify-center">
{att.type === 'image' ? (
<ImageIcon size={20} className="text-accent-indigo" />
) : (
<File size={20} className="text-accent-indigo" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{att.name}</p>
<p className="text-xs text-text-tertiary">{att.size}</p>
</div>
<button
type="button"
className="p-2 rounded-lg hover:bg-bg-page transition-colors"
title="下载"
>
<Download size={16} className="text-text-secondary" />
</button>
</div>
))}
</div>
</div>
)}
</div>
</CardContent>
</Card>
</div>
{/* 右侧:处理面板 */}
<div className="space-y-6">
{/* 相关文件 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText size={18} className="text-accent-indigo" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-3 p-3 rounded-lg bg-bg-elevated">
<div className="w-10 h-10 rounded-lg bg-accent-indigo/15 flex items-center justify-center">
<File size={20} className="text-accent-indigo" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">
{appeal.taskInfo.scriptFileName}
</p>
<p className="text-xs text-text-tertiary">{appeal.taskInfo.scriptFileSize}</p>
</div>
<button
type="button"
className="p-2 rounded-lg hover:bg-bg-page transition-colors"
title="下载"
>
<Download size={16} className="text-text-secondary" />
</button>
</div>
</CardContent>
</Card>
{/* 处理决策 */}
{appeal.status === 'pending' || appeal.status === 'processing' ? (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="text-sm text-text-secondary mb-2 block"></label>
<textarea
value={replyContent}
onChange={(e) => setReplyContent(e.target.value)}
placeholder="请输入处理意见或驳回原因..."
className="w-full h-32 p-3 rounded-xl bg-bg-elevated border border-border-subtle text-text-primary placeholder-text-tertiary resize-none focus:outline-none focus:ring-2 focus:ring-accent-indigo"
/>
</div>
<div className="flex gap-3">
<Button
variant="primary"
className="flex-1 bg-accent-green hover:bg-accent-green/80"
onClick={handleApprove}
disabled={isSubmitting}
>
{isSubmitting ? <Loader2 size={16} className="animate-spin" /> : <CheckCircle size={16} />}
</Button>
<Button
variant="secondary"
className="flex-1 border-accent-coral text-accent-coral hover:bg-accent-coral/10"
onClick={handleReject}
disabled={isSubmitting}
>
{isSubmitting ? <Loader2 size={16} className="animate-spin" /> : <XCircle size={16} />}
</Button>
</div>
<p className="text-xs text-text-tertiary text-center">
</p>
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className={`p-4 rounded-xl ${status.bgColor}`}>
<div className="flex items-center gap-2 mb-2">
<StatusIcon size={18} className={status.color} />
<span className={`font-medium ${status.color}`}>
{appeal.status === 'approved' ? '申诉已通过' : '申诉已驳回'}
</span>
</div>
<p className="text-sm text-text-secondary">
{appeal.status === 'approved'
? '经核实,达人申诉理由成立,已撤销原审核问题。'
: '经核实,原审核问题有效,申诉不成立。'}
</p>
</div>
</CardContent>
</Card>
)}
</div>
</div>
</div>
)
}