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

383 lines
14 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 { useRouter } from 'next/navigation'
import {
MessageCircle,
Clock,
CheckCircle,
XCircle,
ChevronRight,
AlertTriangle,
Filter,
Search,
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 Appeal = {
id: string
taskId: string
taskTitle: string
type: 'ai' | 'agency' | 'brand'
reason: string
content: string
status: AppealStatus
createdAt: string
updatedAt?: string
result?: string
}
// 模拟申诉数据
const mockAppeals: Appeal[] = [
{
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已学习此案例。',
},
{
id: 'appeal-002',
taskId: 'task-010',
taskTitle: 'GG智能手表',
type: 'agency',
reason: '审核标准不清晰',
content: '代理商反馈品牌调性不符但Brief中并未明确说明科技专业形象的具体要求。请明确审核标准。',
status: 'processing',
createdAt: '2026-02-04 09:15',
},
{
id: 'appeal-003',
taskId: 'task-011',
taskTitle: 'HH美妆代言',
type: 'brand',
reason: '创意理解差异',
content: '品牌方认为创意不够新颖,但该创意形式在同类型产品推广中效果显著,已附上数据支持。',
status: 'pending',
createdAt: '2026-02-05 14:00',
},
{
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: '经核实,该音乐虽有授权,但授权范围不包含商业广告用途。建议更换音乐后重新提交。',
},
]
// 将 TaskResponse 映射为 Appeal UI 类型
function mapTaskToAppeal(task: TaskResponse): Appeal {
// 判断申诉类型:根据当前阶段判断被驳回的审核类型
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'
}
return {
id: task.id,
taskId: task.id,
taskTitle: task.name,
type,
reason: task.appeal_reason || '申诉',
content: task.appeal_reason || '',
status,
createdAt: task.updated_at ? new Date(task.updated_at).toLocaleString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
}) : '',
updatedAt: task.updated_at ? new Date(task.updated_at).toLocaleString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
}) : 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 AppealSkeleton() {
return (
<div className="bg-bg-card rounded-2xl p-5 card-shadow animate-pulse">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-bg-elevated" />
<div className="flex flex-col gap-1.5">
<div className="h-4 w-28 bg-bg-elevated rounded" />
<div className="h-3 w-36 bg-bg-elevated rounded" />
</div>
</div>
<div className="h-6 w-16 bg-bg-elevated rounded-full" />
</div>
<div className="flex flex-col gap-3">
<div className="h-3 w-40 bg-bg-elevated rounded" />
<div className="h-3 w-32 bg-bg-elevated rounded" />
<div className="h-4 w-full bg-bg-elevated rounded" />
</div>
<div className="flex items-center justify-between mt-4 pt-4 border-t border-border-subtle">
<div className="h-3 w-32 bg-bg-elevated rounded" />
</div>
</div>
)
}
// 申诉卡片组件
function AppealCard({ appeal, onClick }: { appeal: Appeal; onClick: () => void }) {
const status = statusConfig[appeal.status]
const type = typeConfig[appeal.type]
const StatusIcon = status.icon
return (
<div
className="bg-bg-card rounded-2xl p-5 card-shadow cursor-pointer hover:bg-bg-elevated/30 transition-colors"
onClick={onClick}
>
{/* 头部 */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className={cn('w-10 h-10 rounded-xl flex items-center justify-center', status.bgColor)}>
<StatusIcon className={cn('w-5 h-5', status.color)} />
</div>
<div className="flex flex-col gap-0.5">
<span className="text-base font-semibold text-text-primary">{appeal.taskTitle}</span>
<span className="text-xs text-text-tertiary">: {appeal.id}</span>
</div>
</div>
<div className="flex items-center gap-2">
<span className={cn('px-2.5 py-1 rounded-full text-xs font-medium', status.bgColor, status.color)}>
{status.label}
</span>
<ChevronRight className="w-5 h-5 text-text-tertiary" />
</div>
</div>
{/* 内容 */}
<div className="flex flex-col gap-3">
<div className="flex items-center gap-4 text-sm">
<span className="text-text-tertiary">:</span>
<span className={cn('font-medium', type.color)}>{type.label}</span>
</div>
<div className="flex items-center gap-4 text-sm">
<span className="text-text-tertiary">:</span>
<span className="text-text-primary">{appeal.reason}</span>
</div>
<p className="text-sm text-text-secondary line-clamp-2">{appeal.content}</p>
</div>
{/* 底部时间 */}
<div className="flex items-center justify-between mt-4 pt-4 border-t border-border-subtle">
<span className="text-xs text-text-tertiary">: {appeal.createdAt}</span>
{appeal.updatedAt && (
<span className="text-xs text-text-tertiary">: {appeal.updatedAt}</span>
)}
</div>
</div>
)
}
// 申诉次数入口卡片
function AppealQuotaEntryCard({ onClick }: { onClick: () => void }) {
return (
<div
className="bg-bg-card rounded-2xl p-5 card-shadow cursor-pointer hover:bg-bg-elevated/30 transition-colors"
onClick={onClick}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-accent-indigo/15 flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-accent-indigo" />
</div>
<div className="flex flex-col gap-0.5">
<span className="text-base font-semibold text-text-primary"></span>
<span className="text-sm text-text-secondary"></span>
</div>
</div>
<ChevronRight className="w-5 h-5 text-text-tertiary" />
</div>
</div>
)
}
export default function CreatorAppealsPage() {
const router = useRouter()
const toast = useToast()
const [filter, setFilter] = useState<AppealStatus | 'all'>('all')
const [searchQuery, setSearchQuery] = useState('')
const [appeals, setAppeals] = useState<Appeal[]>([])
const [loading, setLoading] = useState(true)
const loadAppeals = useCallback(async () => {
if (USE_MOCK) {
setAppeals(mockAppeals)
setLoading(false)
return
}
try {
setLoading(true)
const response = await api.listTasks(1, 50)
// Filter for tasks that have appeals (is_appeal === true or have appeal_reason)
const appealTasks = response.items.filter(
(task) => task.is_appeal || task.appeal_reason || task.appeal_count > 0
)
const mapped = appealTasks.map(mapTaskToAppeal)
setAppeals(mapped)
} catch (err) {
console.error('加载申诉列表失败:', err)
toast.error('加载申诉列表失败,请稍后重试')
} finally {
setLoading(false)
}
}, [toast])
useEffect(() => {
loadAppeals()
}, [loadAppeals])
// 搜索和筛选
const filteredAppeals = appeals.filter(appeal => {
const matchesSearch = searchQuery === '' ||
appeal.taskTitle.toLowerCase().includes(searchQuery.toLowerCase()) ||
appeal.id.toLowerCase().includes(searchQuery.toLowerCase()) ||
appeal.reason.toLowerCase().includes(searchQuery.toLowerCase())
const matchesFilter = filter === 'all' || appeal.status === filter
return matchesSearch && matchesFilter
})
const handleAppealClick = (appealId: string) => {
router.push(`/creator/appeals/${appealId}`)
}
// 跳转到申诉次数管理页面
const handleGoToQuotaPage = () => {
router.push('/creator/appeal-quota')
}
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">
<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="flex items-center gap-3">
<div className="flex items-center gap-2 px-4 py-2.5 bg-bg-card rounded-xl border border-border-subtle">
<Search className="w-[18px] h-[18px] text-text-secondary" />
<input
type="text"
placeholder="搜索申诉..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="bg-transparent text-sm text-text-primary placeholder-text-tertiary focus:outline-none w-32"
/>
</div>
<div className="flex items-center gap-2 px-4 py-2.5 bg-bg-card rounded-xl border border-border-subtle">
<Filter className="w-[18px] h-[18px] text-text-secondary" />
<select
value={filter}
onChange={(e) => setFilter(e.target.value as AppealStatus | 'all')}
className="bg-transparent text-sm text-text-primary focus:outline-none"
>
<option value="all"></option>
<option value="pending"></option>
<option value="processing"></option>
<option value="approved"></option>
<option value="rejected"></option>
</select>
</div>
</div>
</div>
{/* 申诉次数管理入口 */}
<AppealQuotaEntryCard onClick={handleGoToQuotaPage} />
{/* 申诉列表 */}
<div className="flex flex-col gap-4 flex-1 overflow-y-auto pr-2">
<h2 className="text-lg font-semibold text-text-primary">
{!loading && `(${filteredAppeals.length})`}
</h2>
{loading ? (
<>
<AppealSkeleton />
<AppealSkeleton />
<AppealSkeleton />
</>
) : filteredAppeals.length > 0 ? (
filteredAppeals.map((appeal) => (
<AppealCard
key={appeal.id}
appeal={appeal}
onClick={() => handleAppealClick(appeal.id)}
/>
))
) : (
<div className="flex flex-col items-center justify-center py-16">
<MessageCircle className="w-12 h-12 text-text-tertiary/50 mb-4" />
<p className="text-text-secondary text-center">
{searchQuery || filter !== 'all'
? '没有找到匹配的申诉记录'
: '暂无申诉记录'}
</p>
{(searchQuery || filter !== 'all') && (
<button
type="button"
onClick={() => { setSearchQuery(''); setFilter('all'); }}
className="mt-3 text-sm text-accent-indigo hover:underline"
>
</button>
)}
</div>
)}
</div>
</div>
</ResponsiveLayout>
)
}