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

371 lines
13 KiB
TypeScript

'use client'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import {
ArrowLeft,
History,
CheckCircle,
XCircle,
Search,
Filter,
FileText,
Video,
User,
Calendar,
Download,
Loader2
} from 'lucide-react'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import type { TaskResponse } from '@/types/task'
// 审核历史记录类型
interface ReviewHistoryItem {
id: string
taskId: string
taskTitle: string
creatorName: string
contentType: 'script' | 'video'
result: 'approved' | 'rejected'
reason?: string
reviewedAt: string
projectName: string
}
// 模拟审核历史数据
const mockHistoryData: ReviewHistoryItem[] = [
{
id: 'h-001',
taskId: 'task-101',
taskTitle: '夏日护肤推广脚本',
creatorName: '小美护肤',
contentType: 'script',
result: 'approved',
reviewedAt: '2026-02-06 14:30',
projectName: 'XX品牌618推广',
},
{
id: 'h-002',
taskId: 'task-102',
taskTitle: '新品口红试色视频',
creatorName: '美妆Lisa',
contentType: 'video',
result: 'rejected',
reason: '背景音乐版权问题',
reviewedAt: '2026-02-06 11:20',
projectName: 'YY口红新品发布',
},
{
id: 'h-003',
taskId: 'task-103',
taskTitle: '健身器材推荐脚本',
creatorName: '健身教练王',
contentType: 'script',
result: 'approved',
reviewedAt: '2026-02-05 16:45',
projectName: 'ZZ运动品牌推广',
},
{
id: 'h-004',
taskId: 'task-104',
taskTitle: '美妆新品测评视频',
creatorName: '达人小红',
contentType: 'video',
result: 'rejected',
reason: '品牌调性不符',
reviewedAt: '2026-02-05 10:15',
projectName: 'XX品牌618推广',
},
{
id: 'h-005',
taskId: 'task-105',
taskTitle: '数码产品开箱脚本',
creatorName: '科技小哥',
contentType: 'script',
result: 'approved',
reviewedAt: '2026-02-04 15:30',
projectName: 'AA数码新品上市',
},
]
/**
* Map a completed TaskResponse to the ReviewHistoryItem UI model.
*/
function mapTaskToHistoryItem(task: TaskResponse): ReviewHistoryItem {
// Determine content type based on the latest stage info
// If the task reached video stages, it's a video review; otherwise script
const hasVideoReview = task.video_agency_status !== null && task.video_agency_status !== undefined
const contentType: 'script' | 'video' = hasVideoReview ? 'video' : 'script'
// Determine result
let result: 'approved' | 'rejected' = 'approved'
let reason: string | undefined
if (task.stage === 'rejected') {
result = 'rejected'
// Try to pick up the rejection reason
if (hasVideoReview) {
reason = task.video_agency_comment || task.video_brand_comment || undefined
} else {
reason = task.script_agency_comment || task.script_brand_comment || undefined
}
} else if (task.stage === 'completed') {
result = 'approved'
}
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, '-')
}
return {
id: task.id,
taskId: task.id,
taskTitle: task.name,
creatorName: task.creator.name,
contentType,
result,
reason,
reviewedAt: formatDate(task.updated_at),
projectName: task.project.name,
}
}
export default function AgencyReviewHistoryPage() {
const router = useRouter()
const [searchQuery, setSearchQuery] = useState('')
const [filterResult, setFilterResult] = useState<'all' | 'approved' | 'rejected'>('all')
const [filterType, setFilterType] = useState<'all' | 'script' | 'video'>('all')
const [historyData, setHistoryData] = useState<ReviewHistoryItem[]>([])
const [loading, setLoading] = useState(true)
const fetchHistory = useCallback(async () => {
if (USE_MOCK) {
setHistoryData(mockHistoryData)
setLoading(false)
return
}
try {
setLoading(true)
const response = await api.listTasks(1, 50, 'completed')
setHistoryData(response.items.map(mapTaskToHistoryItem))
} catch (err) {
console.error('Failed to fetch review history:', err)
setHistoryData([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchHistory()
}, [fetchHistory])
// 筛选数据
const filteredHistory = historyData.filter(item => {
const matchesSearch = searchQuery === '' ||
item.taskTitle.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.creatorName.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.projectName.toLowerCase().includes(searchQuery.toLowerCase())
const matchesResult = filterResult === 'all' || item.result === filterResult
const matchesType = filterType === 'all' || item.contentType === filterType
return matchesSearch && matchesResult && matchesType
})
// 统计
const approvedCount = historyData.filter(i => i.result === 'approved').length
const rejectedCount = historyData.filter(i => i.result === 'rejected').length
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"></p>
</div>
</div>
<Button variant="secondary">
<Download size={16} />
</Button>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-3 gap-4">
<div className="p-4 rounded-xl bg-bg-card card-shadow">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-accent-indigo/15 flex items-center justify-center">
<History size={20} className="text-accent-indigo" />
</div>
<div>
<p className="text-2xl font-bold text-text-primary">{historyData.length}</p>
<p className="text-sm text-text-secondary"></p>
</div>
</div>
</div>
<div className="p-4 rounded-xl bg-bg-card card-shadow">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-accent-green/15 flex items-center justify-center">
<CheckCircle size={20} className="text-accent-green" />
</div>
<div>
<p className="text-2xl font-bold text-accent-green">{approvedCount}</p>
<p className="text-sm text-text-secondary"></p>
</div>
</div>
</div>
<div className="p-4 rounded-xl bg-bg-card card-shadow">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-accent-coral/15 flex items-center justify-center">
<XCircle size={20} className="text-accent-coral" />
</div>
<div>
<p className="text-2xl font-bold text-accent-coral">{rejectedCount}</p>
<p className="text-sm text-text-secondary"></p>
</div>
</div>
</div>
</div>
{/* 搜索和筛选 */}
<div className="flex flex-wrap items-center gap-4">
<div className="relative flex-1 min-w-[240px] 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-2">
<span className="text-sm text-text-tertiary">:</span>
<div className="flex items-center gap-1 p-1 bg-bg-elevated rounded-lg">
{[
{ value: 'all', label: '全部' },
{ value: 'approved', label: '通过' },
{ value: 'rejected', label: '驳回' },
].map((tab) => (
<button
key={tab.value}
type="button"
onClick={() => setFilterResult(tab.value as typeof filterResult)}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
filterResult === tab.value ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-text-tertiary">:</span>
<div className="flex items-center gap-1 p-1 bg-bg-elevated rounded-lg">
{[
{ value: 'all', label: '全部' },
{ value: 'script', label: '脚本' },
{ value: 'video', label: '视频' },
].map((tab) => (
<button
key={tab.value}
type="button"
onClick={() => setFilterType(tab.value as typeof filterType)}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
filterType === tab.value ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
</div>
{/* 历史列表 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<History size={18} className="text-accent-indigo" />
<span className="ml-auto text-sm font-normal text-text-secondary">
{filteredHistory.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>
) : filteredHistory.length > 0 ? (
filteredHistory.map((item) => (
<div
key={item.id}
className="p-4 rounded-xl bg-bg-elevated hover:bg-bg-elevated/80 transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
item.result === 'approved'
? 'bg-accent-green/15 text-accent-green'
: 'bg-accent-coral/15 text-accent-coral'
}`}>
{item.result === 'approved' ? '已通过' : '已驳回'}
</span>
<span className="flex items-center gap-1 text-xs text-text-tertiary">
{item.contentType === 'script' ? <FileText size={12} /> : <Video size={12} />}
{item.contentType === 'script' ? '脚本' : '视频'}
</span>
</div>
<h3 className="font-medium text-text-primary mb-1">{item.taskTitle}</h3>
<div className="flex items-center gap-4 text-sm text-text-secondary">
<span className="flex items-center gap-1">
<User size={14} />
{item.creatorName}
</span>
<span>{item.projectName}</span>
</div>
{item.reason && (
<p className="mt-2 text-sm text-accent-coral">: {item.reason}</p>
)}
</div>
<div className="text-right">
<div className="flex items-center gap-1 text-sm text-text-tertiary">
<Calendar size={14} />
{item.reviewedAt}
</div>
</div>
</div>
</div>
))
) : (
<div className="text-center py-12 text-text-tertiary">
<History size={48} className="mx-auto mb-4 opacity-50" />
<p></p>
</div>
)}
</CardContent>
</Card>
</div>
)
}