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

373 lines
13 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 Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import {
MessageSquare,
Clock,
CheckCircle,
XCircle,
AlertTriangle,
Search,
Filter,
ChevronRight,
User,
FileText,
Video,
Loader2
} from 'lucide-react'
import { getPlatformInfo } from '@/lib/platforms'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import type { TaskResponse } from '@/types/task'
// 申诉状态类型
type AppealStatus = 'pending' | 'processing' | 'approved' | 'rejected'
// 申诉类型
type AppealType = 'ai' | 'agency'
// 申诉数据类型
interface Appeal {
id: string
taskId: string
taskTitle: string
creatorId: string
creatorName: string
platform: string
type: AppealType
contentType: 'script' | 'video'
reason: string
content: string
status: AppealStatus
createdAt: string
updatedAt?: string
}
// 模拟申诉数据
const mockAppeals: Appeal[] = [
{
id: 'appeal-001',
taskId: 'task-001',
taskTitle: '夏日护肤推广脚本',
creatorId: 'creator-001',
creatorName: '小美护肤',
platform: 'douyin',
type: 'ai',
contentType: 'script',
reason: 'AI误判',
content: '脚本中提到的"某品牌"是泛指,并非特指竞品,请重新审核。',
status: 'pending',
createdAt: '2026-02-06 10:30',
},
{
id: 'appeal-002',
taskId: 'task-002',
taskTitle: '新品口红试色',
creatorId: 'creator-002',
creatorName: '美妆Lisa',
platform: 'xiaohongshu',
type: 'agency',
contentType: 'video',
reason: '审核标准不清晰',
content: '视频中的背景音乐已获得授权,附上授权证明。代理商反馈的"版权问题"不成立。',
status: 'processing',
createdAt: '2026-02-05 14:20',
},
{
id: 'appeal-003',
taskId: 'task-003',
taskTitle: '健身器材推荐',
creatorId: 'creator-003',
creatorName: '健身教练王',
platform: 'bilibili',
type: 'ai',
contentType: 'script',
reason: '违禁词误判',
content: 'AI标记的"最好"是在描述个人体验,并非绝对化用语,符合广告法要求。',
status: 'approved',
createdAt: '2026-02-04 09:15',
updatedAt: '2026-02-04 16:30',
},
{
id: 'appeal-004',
taskId: 'task-004',
taskTitle: '美妆新品测评',
creatorId: 'creator-004',
creatorName: '达人小红',
platform: 'xiaohongshu',
type: 'agency',
contentType: 'video',
reason: '品牌调性理解差异',
content: '代理商认为风格不符但Brief中未明确禁止该风格请提供更具体的标准。',
status: 'rejected',
createdAt: '2026-02-03 11:00',
updatedAt: '2026-02-03 18:45',
},
]
// 状态配置
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 },
}
// 类型配置
const typeConfig: Record<AppealType, { label: string; color: string }> = {
ai: { label: 'AI审核申诉', color: 'text-accent-indigo' },
agency: { label: '代理商审核申诉', color: 'text-purple-400' },
}
/**
* Map a TaskResponse (with is_appeal === true) to the Appeal UI model.
*/
function mapTaskToAppeal(task: TaskResponse): Appeal {
// Determine which stage the task was appealing from
const isVideoStage = task.stage.startsWith('video')
const contentType: 'script' | 'video' = isVideoStage ? 'video' : 'script'
// Determine appeal type based on stage
const type: AppealType = task.stage.includes('ai') ? 'ai' : 'agency'
// Derive appeal status from the task stage
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'
}
return {
id: task.id,
taskId: task.id,
taskTitle: task.name,
creatorId: task.creator.id,
creatorName: task.creator.name,
platform: 'douyin', // Backend does not expose platform on task; default for now
type,
contentType,
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' }).replace(/\//g, '-') : '',
updatedAt: task.stage === 'completed' || task.stage === 'rejected'
? new Date(task.updated_at).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).replace(/\//g, '-')
: undefined,
}
}
function AppealCard({ appeal }: { appeal: Appeal }) {
const status = statusConfig[appeal.status]
const type = typeConfig[appeal.type]
const StatusIcon = status.icon
const platform = getPlatformInfo(appeal.platform)
return (
<Link href={`/agency/appeals/${appeal.id}`}>
<div className="rounded-xl bg-bg-elevated hover:bg-bg-elevated/80 transition-colors cursor-pointer overflow-hidden">
{/* 平台顶部条 */}
{platform && (
<div className={`px-4 py-1.5 ${platform.bgColor} border-b ${platform.borderColor} flex items-center gap-1.5`}>
<span className="text-sm">{platform.icon}</span>
<span className={`text-xs font-medium ${platform.textColor}`}>{platform.name}</span>
</div>
)}
<div className="p-4">
{/* 顶部:状态和类型 */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className={`w-8 h-8 rounded-lg ${status.bgColor} flex items-center justify-center`}>
<StatusIcon size={16} className={status.color} />
</div>
<div>
<span className="font-medium text-text-primary">{appeal.taskTitle}</span>
<div className="flex items-center gap-2 text-xs text-text-tertiary">
<span className="flex items-center gap-1">
<User size={10} />
{appeal.creatorName}
</span>
<span>·</span>
<span className="flex items-center gap-1">
{appeal.contentType === 'script' ? <FileText size={10} /> : <Video size={10} />}
{appeal.contentType === 'script' ? '脚本' : '视频'}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${status.bgColor} ${status.color}`}>
{status.label}
</span>
<ChevronRight size={16} className="text-text-tertiary" />
</div>
</div>
{/* 申诉信息 */}
<div className="space-y-2 mb-3">
<div className="flex items-center gap-2 text-sm">
<span className="text-text-tertiary">:</span>
<span className={type.color}>{type.label}</span>
</div>
<div className="flex items-center gap-2 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 text-xs text-text-tertiary pt-3 border-t border-border-subtle">
<span>: {appeal.createdAt}</span>
{appeal.updatedAt && <span>: {appeal.updatedAt}</span>}
</div>
</div>
</div>
</Link>
)
}
export default function AgencyAppealsPage() {
const [filter, setFilter] = useState<AppealStatus | 'all'>('all')
const [searchQuery, setSearchQuery] = useState('')
const [appeals, setAppeals] = useState<Appeal[]>([])
const [loading, setLoading] = useState(true)
const fetchAppeals = useCallback(async () => {
if (USE_MOCK) {
setAppeals(mockAppeals)
setLoading(false)
return
}
try {
setLoading(true)
// Fetch tasks and filter for those with is_appeal === true
const response = await api.listTasks(1, 50)
const appealTasks = response.items.filter((t) => t.is_appeal === true)
setAppeals(appealTasks.map(mapTaskToAppeal))
} catch (err) {
console.error('Failed to fetch appeals:', err)
setAppeals([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchAppeals()
}, [fetchAppeals])
// 统计
const pendingCount = appeals.filter(a => a.status === 'pending').length
const processingCount = appeals.filter(a => a.status === 'processing').length
// 筛选
const filteredAppeals = appeals.filter(appeal => {
const matchesSearch = searchQuery === '' ||
appeal.taskTitle.toLowerCase().includes(searchQuery.toLowerCase()) ||
appeal.creatorName.toLowerCase().includes(searchQuery.toLowerCase()) ||
appeal.reason.toLowerCase().includes(searchQuery.toLowerCase())
const matchesFilter = filter === 'all' || appeal.status === filter
return matchesSearch && matchesFilter
})
return (
<div className="space-y-6">
{/* 页面标题 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary"></h1>
<p className="text-sm text-text-secondary mt-1"></p>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="px-3 py-1.5 bg-accent-amber/20 text-accent-amber rounded-lg font-medium">
{pendingCount}
</span>
<span className="px-3 py-1.5 bg-accent-indigo/20 text-accent-indigo rounded-lg font-medium">
{processingCount}
</span>
</div>
</div>
{/* 搜索和筛选 */}
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-md">
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-tertiary" />
<input
type="text"
placeholder="搜索任务名称或达人..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 border border-border-subtle rounded-xl bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
/>
</div>
<div className="flex items-center gap-1 p-1 bg-bg-elevated rounded-lg">
{[
{ value: 'all', label: '全部' },
{ value: 'pending', label: '待处理' },
{ value: 'processing', label: '处理中' },
{ value: 'approved', label: '已通过' },
{ value: 'rejected', label: '已驳回' },
].map((tab) => (
<button
key={tab.value}
type="button"
onClick={() => setFilter(tab.value as AppealStatus | 'all')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
filter === tab.value ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
{/* 申诉列表 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageSquare size={18} className="text-accent-indigo" />
<span className="ml-auto text-sm font-normal text-text-secondary">
{filteredAppeals.length}
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{loading ? (
<div className="flex flex-col items-center justify-center py-12 text-text-tertiary">
<Loader2 size={32} className="animate-spin mb-4" />
<p>...</p>
</div>
) : filteredAppeals.length > 0 ? (
filteredAppeals.map((appeal) => (
<AppealCard key={appeal.id} appeal={appeal} />
))
) : (
<div className="text-center py-12 text-text-tertiary">
<MessageSquare size={48} className="mx-auto mb-4 opacity-50" />
<p>{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>
)}
</CardContent>
</Card>
</div>
)
}