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

580 lines
24 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, useSearchParams } from 'next/navigation'
import {
ArrowLeft,
Upload,
X,
FileText,
Image,
AlertTriangle,
CheckCircle,
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'
// 申诉原因选项
const appealReasons = [
{ id: 'misjudge', label: '误判', description: 'AI或审核员误判了内容' },
{ id: 'unclear', label: '标准不清晰', description: '审核标准不明确或有歧义' },
{ id: 'evidence', label: '有证据支持', description: '有证据证明内容符合要求' },
{ id: 'context', label: '上下文理解', description: '审核未考虑完整上下文' },
{ id: 'other', label: '其他原因', description: '其他需要说明的情况' },
]
// Mock 任务信息类型
type TaskInfo = {
title: string
issue: string
issueDesc: string
type: string
appealRemaining: number
agencyName: string
}
// 任务信息模拟从URL参数获取
const getTaskInfo = (taskId: string): TaskInfo => {
const tasks: Record<string, TaskInfo> = {
'task-003': {
title: 'ZZ饮品夏日',
issue: '检测到竞品提及',
issueDesc: '脚本第3段提及了竞品「百事可乐」可能造成品牌冲突风险。',
type: 'ai',
appealRemaining: 1,
agencyName: '星辰传媒',
},
'task-010': {
title: 'GG智能手表',
issue: '品牌调性不符',
issueDesc: '脚本整体风格偏向娱乐化,与品牌科技专业形象不匹配。',
type: 'agency',
appealRemaining: 0,
agencyName: '星辰传媒',
},
'task-011': {
title: 'HH美妆代言',
issue: '创意不够新颖',
issueDesc: '脚本采用的是常见的口播形式,缺乏创新点和记忆点。',
type: 'brand',
appealRemaining: 1,
agencyName: '晨曦文化',
},
'task-013': {
title: 'JJ旅行vlog',
issue: '背景音乐版权问题',
issueDesc: '视频中使用的背景音乐存在版权风险。',
type: 'agency',
appealRemaining: 2,
agencyName: '晨曦文化',
},
'task-015': {
title: 'LL厨房电器',
issue: '使用场景不真实',
issueDesc: '视频中的厨房场景过于整洁,缺乏真实感。',
type: 'brand',
appealRemaining: 0,
agencyName: '星辰传媒',
},
}
return tasks[taskId] || { title: '未知任务', issue: '未知问题', issueDesc: '', type: 'ai', appealRemaining: 0, agencyName: '未知代理商' }
}
// 将 TaskResponse 映射为 TaskInfo
function mapTaskResponseToInfo(task: TaskResponse): TaskInfo {
let type = 'ai'
let issue = '审核驳回'
let issueDesc = ''
if (task.script_brand_status === 'rejected' || task.video_brand_status === 'rejected') {
type = 'brand'
issue = task.script_brand_comment || task.video_brand_comment || '品牌方审核驳回'
issueDesc = task.script_brand_comment || task.video_brand_comment || ''
} else if (task.script_agency_status === 'rejected' || task.video_agency_status === 'rejected') {
type = 'agency'
issue = task.script_agency_comment || task.video_agency_comment || '代理商审核驳回'
issueDesc = task.script_agency_comment || task.video_agency_comment || ''
} else {
// AI rejection or default
const aiResult = task.script_ai_result || task.video_ai_result
if (aiResult && aiResult.violations.length > 0) {
issue = aiResult.violations[0].content || 'AI审核不通过'
issueDesc = aiResult.summary || aiResult.violations.map(v => v.content).join('; ')
}
}
// Default appeal quota: 1 per task minus used appeals
const defaultQuota = 1
const appealRemaining = Math.max(0, defaultQuota - task.appeal_count)
return {
title: task.name,
issue,
issueDesc,
type,
appealRemaining,
agencyName: task.agency?.name || '未知代理商',
}
}
// 表单骨架屏
function FormSkeleton() {
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-24 bg-bg-elevated rounded" />
<div className="h-4 w-64 bg-bg-elevated rounded" />
</div>
<div className="h-10 w-48 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-24 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="grid grid-cols-2 gap-3">
<div className="h-16 bg-bg-elevated rounded-xl" />
<div className="h-16 bg-bg-elevated rounded-xl" />
<div className="h-16 bg-bg-elevated rounded-xl" />
<div className="h-16 bg-bg-elevated rounded-xl" />
</div>
</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-32 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-4">
<div className="h-4 w-full bg-bg-elevated rounded" />
<div className="h-4 w-full bg-bg-elevated rounded" />
<div className="h-4 w-full bg-bg-elevated rounded" />
</div>
<div className="h-12 bg-bg-elevated rounded-xl mt-6" />
</div>
</div>
</div>
</div>
)
}
export default function NewAppealPage() {
const router = useRouter()
const searchParams = useSearchParams()
const toast = useToast()
const taskId = searchParams.get('taskId') || ''
const [taskInfo, setTaskInfo] = useState<TaskInfo | null>(null)
const [loading, setLoading] = useState(true)
const [selectedReason, setSelectedReason] = useState<string>('')
const [content, setContent] = useState('')
const [attachments, setAttachments] = useState<{ name: string; type: 'image' | 'document' }[]>([])
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSubmitted, setIsSubmitted] = useState(false)
const [isRequestingQuota, setIsRequestingQuota] = useState(false)
const [quotaRequested, setQuotaRequested] = useState(false)
// Load task info
const loadTaskInfo = useCallback(async () => {
if (USE_MOCK) {
setTaskInfo(getTaskInfo(taskId))
setLoading(false)
return
}
if (!taskId) {
toast.error('缺少任务ID参数')
setLoading(false)
return
}
try {
setLoading(true)
const task = await api.getTask(taskId)
const info = mapTaskResponseToInfo(task)
setTaskInfo(info)
} catch (err) {
console.error('加载任务信息失败:', err)
toast.error('加载任务信息失败,请稍后重试')
// Fallback to a default
setTaskInfo({ title: '未知任务', issue: '未知问题', issueDesc: '', type: 'ai', appealRemaining: 0, agencyName: '未知代理商' })
} finally {
setLoading(false)
}
}, [taskId, toast])
useEffect(() => {
loadTaskInfo()
}, [loadTaskInfo])
const hasAppealQuota = taskInfo ? taskInfo.appealRemaining > 0 : false
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (files) {
const newAttachments = Array.from(files).map(file => ({
name: file.name,
type: file.type.startsWith('image/') ? 'image' as const : 'document' as const,
}))
setAttachments([...attachments, ...newAttachments])
}
}
const removeAttachment = (index: number) => {
setAttachments(attachments.filter((_, i) => i !== index))
}
const handleSubmit = async () => {
if (!selectedReason || !content.trim()) return
if (USE_MOCK) {
setIsSubmitting(true)
await new Promise(resolve => setTimeout(resolve, 1500))
setIsSubmitting(false)
setIsSubmitted(true)
setTimeout(() => {
router.push('/creator/appeals')
}, 2000)
return
}
try {
setIsSubmitting(true)
const reasonLabel = appealReasons.find(r => r.id === selectedReason)?.label || selectedReason
const appealReason = `[${reasonLabel}] ${content.trim()}`
await api.submitAppeal(taskId, { reason: appealReason })
toast.success('申诉提交成功')
setIsSubmitted(true)
setTimeout(() => {
router.push('/creator/appeals')
}, 2000)
} catch (err) {
console.error('提交申诉失败:', err)
toast.error('提交申诉失败,请稍后重试')
} finally {
setIsSubmitting(false)
}
}
const canSubmit = selectedReason && content.trim().length >= 20 && hasAppealQuota
// 申请增加申诉次数
const handleRequestQuota = async () => {
if (USE_MOCK) {
setIsRequestingQuota(true)
await new Promise(resolve => setTimeout(resolve, 1000))
setIsRequestingQuota(false)
setQuotaRequested(true)
return
}
try {
setIsRequestingQuota(true)
await api.increaseAppealCount(taskId)
toast.success('申请已发送,等待代理商处理')
setQuotaRequested(true)
// Reload task info to get updated appeal count
loadTaskInfo()
} catch (err) {
console.error('申请增加申诉次数失败:', err)
toast.error('申请失败,请稍后重试')
} finally {
setIsRequestingQuota(false)
}
}
// 加载中骨架屏
if (loading) {
return (
<ResponsiveLayout role="creator">
<FormSkeleton />
</ResponsiveLayout>
)
}
// 提交成功界面
if (isSubmitted) {
return (
<ResponsiveLayout role="creator">
<div className="flex items-center justify-center h-full">
<div className="flex flex-col items-center gap-6 max-w-md text-center">
<div className="w-20 h-20 rounded-full bg-accent-green/15 flex items-center justify-center">
<CheckCircle className="w-10 h-10 text-accent-green" />
</div>
<div className="flex flex-col gap-2">
<h2 className="text-2xl font-bold text-text-primary"></h2>
<p className="text-text-secondary">
1-3
</p>
</div>
<p className="text-sm text-text-tertiary">...</p>
</div>
</div>
</ResponsiveLayout>
)
}
// Use fallback if taskInfo is somehow null after loading
const info = taskInfo || { title: '未知任务', issue: '未知问题', issueDesc: '', type: 'ai', appealRemaining: 0, agencyName: '未知代理商' }
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"></p>
</div>
<div className={cn(
'flex items-center gap-2 px-4 py-2 rounded-xl',
hasAppealQuota ? 'bg-accent-indigo/15' : 'bg-accent-coral/15'
)}>
<AlertTriangle className={cn('w-5 h-5', hasAppealQuota ? 'text-accent-indigo' : 'text-accent-coral')} />
<span className={cn('text-sm font-medium', hasAppealQuota ? 'text-accent-indigo' : 'text-accent-coral')}>
{info.appealRemaining}
</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">
{/* 关联任务 */}
<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-bg-elevated rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-base font-semibold text-text-primary">{info.title}</span>
<span className="px-2.5 py-1 rounded-full text-xs font-medium bg-accent-coral/15 text-accent-coral">
{info.type === 'ai' ? 'AI审核' : info.type === 'agency' ? '代理商审核' : '品牌方审核'}
</span>
</div>
<div className="flex items-start gap-2">
<AlertTriangle className="w-4 h-4 text-accent-coral flex-shrink-0 mt-0.5" />
<div>
<span className="text-sm font-medium text-text-primary">{info.issue}</span>
<p className="text-xs text-text-secondary mt-1">{info.issueDesc}</p>
</div>
</div>
</div>
</div>
{/* 申诉次数不足提示 */}
{!hasAppealQuota && (
<div className="bg-accent-coral/10 border border-accent-coral/30 rounded-2xl p-4 lg:p-6">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-accent-coral flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="text-base font-semibold text-accent-coral mb-2"></h3>
<p className="text-sm text-text-secondary mb-4">
{info.agencyName}
</p>
{quotaRequested ? (
<div className="flex items-center gap-2 text-accent-green">
<CheckCircle className="w-4 h-4" />
<span className="text-sm font-medium"></span>
</div>
) : (
<button
type="button"
onClick={handleRequestQuota}
disabled={isRequestingQuota}
className="px-4 py-2 bg-accent-coral text-white rounded-lg text-sm font-medium hover:bg-accent-coral/90 transition-colors disabled:opacity-50 flex items-center gap-2"
>
{isRequestingQuota && <Loader2 className="w-4 h-4 animate-spin" />}
{isRequestingQuota ? '申请中...' : '申请增加申诉次数'}
</button>
)}
</div>
</div>
</div>
)}
{/* 申诉原因 */}
<div className={cn('bg-bg-card rounded-2xl p-4 lg:p-6 card-shadow', !hasAppealQuota && 'opacity-50 pointer-events-none')}>
<h3 className="text-base lg:text-lg font-semibold text-text-primary mb-4"> <span className="text-accent-coral">*</span></h3>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{appealReasons.map((reason) => (
<div
key={reason.id}
onClick={() => setSelectedReason(reason.id)}
className={cn(
'p-4 rounded-xl border-2 cursor-pointer transition-all',
selectedReason === reason.id
? 'border-accent-indigo bg-accent-indigo/5'
: 'border-border-subtle hover:border-text-tertiary'
)}
>
<span className="text-sm font-semibold text-text-primary">{reason.label}</span>
<p className="text-xs text-text-tertiary mt-1">{reason.description}</p>
</div>
))}
</div>
</div>
{/* 申诉说明 */}
<div className={cn('bg-bg-card rounded-2xl p-4 lg:p-6 card-shadow', !hasAppealQuota && 'opacity-50 pointer-events-none')}>
<h3 className="text-base lg:text-lg font-semibold text-text-primary mb-4">
<span className="text-accent-coral">*</span>
<span className="text-xs text-text-tertiary font-normal ml-2">20</span>
</h3>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="请详细描述您的申诉理由,包括为什么您认为审核结果不合理..."
className="w-full h-32 lg:h-40 p-4 bg-bg-elevated rounded-xl text-sm text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-accent-indigo resize-none"
/>
<div className="flex justify-end mt-2">
<span className={cn(
'text-xs',
content.length >= 20 ? 'text-text-tertiary' : 'text-accent-coral'
)}>
{content.length}/20
</span>
</div>
</div>
{/* 证明材料 */}
<div className={cn('bg-bg-card rounded-2xl p-4 lg:p-6 card-shadow', !hasAppealQuota && 'opacity-50 pointer-events-none')}>
<h3 className="text-base lg:text-lg font-semibold text-text-primary mb-4">
<span className="text-xs text-text-tertiary font-normal"></span>
</h3>
<div className="flex flex-wrap gap-3">
{attachments.map((file, index) => (
<div
key={index}
className="flex items-center gap-2 px-3 py-2 bg-bg-elevated rounded-lg"
>
{file.type === 'image' ? (
<Image className="w-4 h-4 text-accent-indigo" />
) : (
<FileText className="w-4 h-4 text-accent-indigo" />
)}
<span className="text-sm text-text-primary max-w-[150px] truncate">{file.name}</span>
<button
type="button"
onClick={() => removeAttachment(index)}
className="w-5 h-5 rounded-full bg-bg-page flex items-center justify-center hover:bg-accent-coral/15"
>
<X className="w-3 h-3 text-text-tertiary" />
</button>
</div>
))}
<label className="flex items-center gap-2 px-4 py-2 bg-bg-elevated rounded-lg cursor-pointer hover:bg-bg-page transition-colors">
<Upload className="w-4 h-4 text-accent-indigo" />
<span className="text-sm text-accent-indigo"></span>
<input
type="file"
multiple
accept="image/*,.pdf,.doc,.docx"
onChange={handleFileUpload}
className="hidden"
/>
</label>
</div>
<p className="text-xs text-text-tertiary mt-3">PDFWord 10MB</p>
</div>
{/* 移动端提交按钮 */}
<div className="lg:hidden">
<button
type="button"
onClick={handleSubmit}
disabled={!canSubmit || isSubmitting}
className={cn(
'w-full py-4 rounded-xl text-base font-semibold flex items-center justify-center gap-2',
canSubmit && !isSubmitting
? 'bg-accent-indigo text-white'
: 'bg-bg-elevated text-text-tertiary'
)}
>
{isSubmitting && <Loader2 className="w-5 h-5 animate-spin" />}
{isSubmitting ? '提交中...' : '提交申诉'}
</button>
</div>
</div>
{/* 右侧:提交信息(仅桌面端显示) */}
<div className="hidden lg:block lg:w-[320px] lg:flex-shrink-0">
<div className="bg-bg-card rounded-2xl p-6 card-shadow sticky top-0">
<h3 className="text-lg font-semibold text-text-primary mb-5"></h3>
<div className="flex flex-col gap-4 mb-6">
<div className="flex items-center justify-between">
<span className="text-sm text-text-tertiary"></span>
<span className="text-sm text-text-primary">{info.title}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-text-tertiary"></span>
<span className="text-sm text-text-primary">
{appealReasons.find(r => r.id === selectedReason)?.label || '未选择'}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-text-tertiary"></span>
<span className={cn(
'text-sm',
content.length >= 20 ? 'text-accent-green' : 'text-text-tertiary'
)}>
{content.length}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-text-tertiary"></span>
<span className="text-sm text-text-primary">{attachments.length} </span>
</div>
</div>
<div className="bg-amber-500/10 rounded-xl p-4 mb-6">
<div className="flex items-start gap-2">
<AlertTriangle className="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5" />
<p className="text-xs text-text-secondary">
1
</p>
</div>
</div>
<button
type="button"
onClick={handleSubmit}
disabled={!canSubmit || isSubmitting}
className={cn(
'w-full py-4 rounded-xl text-base font-semibold transition-colors flex items-center justify-center gap-2',
canSubmit && !isSubmitting
? 'bg-accent-indigo text-white hover:bg-accent-indigo/90'
: 'bg-bg-elevated text-text-tertiary cursor-not-allowed'
)}
>
{isSubmitting && <Loader2 className="w-5 h-5 animate-spin" />}
{isSubmitting ? '提交中...' : '提交申诉'}
</button>
</div>
</div>
</div>
</div>
</ResponsiveLayout>
)
}