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>
This commit is contained in:
Your Name
2026-02-09 16:29:43 +08:00
co-authored by Claude Opus 4.6
parent 54eaa54966
commit a8be7bbca9
24 changed files with 5244 additions and 1845 deletions
+181 -15
View File
@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
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'
@@ -18,14 +18,47 @@ import {
Download,
File,
Send,
Image as ImageIcon
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 = {
const mockAppealDetail: AppealDetail = {
id: 'appeal-001',
taskId: 'task-001',
taskTitle: '夏日护肤推广脚本',
@@ -38,6 +71,7 @@ const mockAppealDetail = {
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' },
@@ -58,6 +92,66 @@ const mockAppealDetail = {
},
}
// 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 },
@@ -70,9 +164,35 @@ export default function AgencyAppealDetailPage() {
const router = useRouter()
const toast = useToast()
const params = useParams()
const [appeal] = useState(mockAppealDetail)
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
@@ -83,10 +203,26 @@ export default function AgencyAppealDetailPage() {
return
}
setIsSubmitting(true)
// 模拟提交
await new Promise(resolve => setTimeout(resolve, 1000))
toast.success('申诉已通过')
router.push('/agency/appeals')
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 () => {
@@ -95,10 +231,34 @@ export default function AgencyAppealDetailPage() {
return
}
setIsSubmitting(true)
// 模拟提交
await new Promise(resolve => setTimeout(resolve, 1000))
toast.success('申诉已驳回')
router.push('/agency/appeals')
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 (
@@ -186,7 +346,9 @@ export default function AgencyAppealDetailPage() {
<span className="font-medium text-text-primary">{appeal.originalIssue.title}</span>
</div>
<p className="text-sm text-text-secondary">{appeal.originalIssue.description}</p>
<p className="text-xs text-text-tertiary mt-2">: {appeal.originalIssue.location}</p>
{appeal.originalIssue.location && (
<p className="text-xs text-text-tertiary mt-2">: {appeal.originalIssue.location}</p>
)}
</div>
</CardContent>
</Card>
@@ -209,6 +371,10 @@ export default function AgencyAppealDetailPage() {
<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 && (
@@ -303,7 +469,7 @@ export default function AgencyAppealDetailPage() {
onClick={handleApprove}
disabled={isSubmitting}
>
<CheckCircle size={16} />
{isSubmitting ? <Loader2 size={16} className="animate-spin" /> : <CheckCircle size={16} />}
</Button>
<Button
@@ -312,7 +478,7 @@ export default function AgencyAppealDetailPage() {
onClick={handleReject}
disabled={isSubmitting}
>
<XCircle size={16} />
{isSubmitting ? <Loader2 size={16} className="animate-spin" /> : <XCircle size={16} />}
</Button>
</div>