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
+185 -6
View File
@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { useParams, useRouter } from 'next/navigation'
import {
ArrowLeft,
@@ -11,10 +11,15 @@ import {
FileText,
Image,
Send,
AlertTriangle
AlertTriangle,
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'
@@ -130,6 +135,69 @@ const mockAppealDetails: Record<string, AppealDetail> = {
},
}
// 将 TaskResponse 映射为 AppealDetail UI 类型
function mapTaskToAppealDetail(task: TaskResponse): AppealDetail {
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'
}
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',
})
}
// Build original issue from review comments
let originalIssue: { title: string; description: string } | undefined
const rejectionComment =
task.script_brand_comment ||
task.script_agency_comment ||
task.video_brand_comment ||
task.video_agency_comment
if (rejectionComment) {
originalIssue = {
title: '审核驳回',
description: rejectionComment,
}
}
// Build timeline from task dates
const timeline: { time: string; action: string; operator?: string }[] = []
if (task.created_at) {
timeline.push({ time: formatDate(task.created_at), action: '任务创建' })
}
if (task.updated_at) {
timeline.push({ time: formatDate(task.updated_at), action: '提交申诉' })
}
return {
id: task.id,
taskId: task.id,
taskTitle: task.name,
type,
reason: task.appeal_reason || '申诉',
content: task.appeal_reason || '',
status,
createdAt: task.created_at ? formatDate(task.created_at) : '',
updatedAt: task.updated_at ? formatDate(task.updated_at) : undefined,
originalIssue,
timeline: timeline.length > 0 ? timeline : 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 },
@@ -145,13 +213,114 @@ const typeConfig: Record<string, { label: string; color: string }> = {
brand: { label: '品牌方审核', color: 'text-accent-blue' },
}
// 骨架屏组件
function DetailSkeleton() {
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-32 bg-bg-elevated rounded" />
<div className="h-4 w-48 bg-bg-elevated rounded" />
</div>
<div className="h-10 w-24 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-32 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="h-4 w-full bg-bg-elevated rounded mb-2" />
<div className="h-4 w-3/4 bg-bg-elevated rounded mb-4" />
<div className="h-16 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-6">
<div className="h-10 bg-bg-elevated rounded" />
<div className="h-10 bg-bg-elevated rounded" />
<div className="h-10 bg-bg-elevated rounded" />
</div>
</div>
</div>
</div>
</div>
)
}
export default function AppealDetailPage() {
const params = useParams()
const router = useRouter()
const toast = useToast()
const appealId = params.id as string
const [newComment, setNewComment] = useState('')
const [appeal, setAppeal] = useState<AppealDetail | null>(null)
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const appeal = mockAppealDetails[appealId]
const loadAppealDetail = useCallback(async () => {
if (USE_MOCK) {
const mockAppeal = mockAppealDetails[appealId]
setAppeal(mockAppeal || null)
setLoading(false)
return
}
try {
setLoading(true)
const task = await api.getTask(appealId)
const mapped = mapTaskToAppealDetail(task)
setAppeal(mapped)
} catch (err) {
console.error('加载申诉详情失败:', err)
toast.error('加载申诉详情失败,请稍后重试')
setAppeal(null)
} finally {
setLoading(false)
}
}, [appealId, toast])
useEffect(() => {
loadAppealDetail()
}, [loadAppealDetail])
const handleSendComment = async () => {
if (!newComment.trim()) return
if (USE_MOCK) {
toast.success('补充说明已发送')
setNewComment('')
return
}
try {
setSubmitting(true)
// Use submitAppeal to add supplementary info (re-appeal with updated reason)
await api.submitAppeal(appealId, { reason: newComment.trim() })
toast.success('补充说明已发送')
setNewComment('')
// Reload to reflect any changes
loadAppealDetail()
} catch (err) {
console.error('发送补充说明失败:', err)
toast.error('发送失败,请稍后重试')
} finally {
setSubmitting(false)
}
}
if (loading) {
return (
<ResponsiveLayout role="creator">
<DetailSkeleton />
</ResponsiveLayout>
)
}
if (!appeal) {
return (
@@ -288,13 +457,23 @@ export default function AppealDetailPage() {
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
className="flex-1 px-4 py-3 bg-bg-elevated rounded-xl text-sm text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
disabled={submitting}
/>
<button
type="button"
className="px-5 py-3 rounded-xl bg-accent-indigo text-white text-sm font-medium flex items-center gap-2"
onClick={handleSendComment}
disabled={submitting || !newComment.trim()}
className={cn(
'px-5 py-3 rounded-xl bg-accent-indigo text-white text-sm font-medium flex items-center gap-2',
(submitting || !newComment.trim()) && 'opacity-50 cursor-not-allowed'
)}
>
<Send className="w-4 h-4" />
{submitting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4" />
)}
{submitting ? '发送中...' : '发送'}
</button>
</div>
</div>
+213 -29
View File
@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import {
ArrowLeft,
@@ -9,10 +9,15 @@ import {
FileText,
Image,
AlertTriangle,
CheckCircle
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 = [
@@ -23,9 +28,19 @@ const appealReasons = [
{ id: 'other', label: '其他原因', description: '其他需要说明的情况' },
]
// Mock 任务信息类型
type TaskInfo = {
title: string
issue: string
issueDesc: string
type: string
appealRemaining: number
agencyName: string
}
// 任务信息(模拟从URL参数获取)
const getTaskInfo = (taskId: string) => {
const tasks: Record<string, { title: string; issue: string; issueDesc: string; type: string; appealRemaining: number; agencyName: string }> = {
const getTaskInfo = (taskId: string): TaskInfo => {
const tasks: Record<string, TaskInfo> = {
'task-003': {
title: 'ZZ饮品夏日',
issue: '检测到竞品提及',
@@ -70,12 +85,99 @@ const getTaskInfo = (taskId: string) => {
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 = getTaskInfo(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' }[]>([])
@@ -84,7 +186,40 @@ export default function NewAppealPage() {
const [isRequestingQuota, setIsRequestingQuota] = useState(false)
const [quotaRequested, setQuotaRequested] = useState(false)
const hasAppealQuota = taskInfo.appealRemaining > 0
// 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
@@ -104,26 +239,69 @@ export default function NewAppealPage() {
const handleSubmit = async () => {
if (!selectedReason || !content.trim()) return
setIsSubmitting(true)
// 模拟提交
await new Promise(resolve => setTimeout(resolve, 1500))
setIsSubmitting(false)
setIsSubmitted(true)
if (USE_MOCK) {
setIsSubmitting(true)
await new Promise(resolve => setTimeout(resolve, 1500))
setIsSubmitting(false)
setIsSubmitted(true)
setTimeout(() => {
router.push('/creator/appeals')
}, 2000)
return
}
// 2秒后跳转到申诉列表
setTimeout(() => {
router.push('/creator/appeals')
}, 2000)
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 () => {
setIsRequestingQuota(true)
await new Promise(resolve => setTimeout(resolve, 1000))
setIsRequestingQuota(false)
setQuotaRequested(true)
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>
)
}
// 提交成功界面
@@ -148,6 +326,9 @@ export default function NewAppealPage() {
)
}
// 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">
@@ -171,7 +352,7 @@ export default function NewAppealPage() {
)}>
<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')}>
{taskInfo.appealRemaining}
{info.appealRemaining}
</span>
</div>
</div>
@@ -185,16 +366,16 @@ export default function NewAppealPage() {
<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">{taskInfo.title}</span>
<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">
{taskInfo.type === 'ai' ? 'AI审核' : taskInfo.type === 'agency' ? '代理商审核' : '品牌方审核'}
{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">{taskInfo.issue}</span>
<p className="text-xs text-text-secondary mt-1">{taskInfo.issueDesc}</p>
<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>
@@ -208,7 +389,7 @@ export default function NewAppealPage() {
<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">
{taskInfo.agencyName}
{info.agencyName}
</p>
{quotaRequested ? (
<div className="flex items-center gap-2 text-accent-green">
@@ -220,8 +401,9 @@ export default function NewAppealPage() {
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"
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>
)}
@@ -322,12 +504,13 @@ export default function NewAppealPage() {
onClick={handleSubmit}
disabled={!canSubmit || isSubmitting}
className={cn(
'w-full py-4 rounded-xl text-base font-semibold',
'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>
@@ -341,7 +524,7 @@ export default function NewAppealPage() {
<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">{taskInfo.title}</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>
@@ -378,12 +561,13 @@ export default function NewAppealPage() {
onClick={handleSubmit}
disabled={!canSubmit || isSubmitting}
className={cn(
'w-full py-4 rounded-xl text-base font-semibold transition-colors',
'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>
+113 -5
View File
@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import {
MessageCircle,
@@ -10,10 +10,15 @@ import {
ChevronRight,
AlertTriangle,
Filter,
Search
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'
@@ -80,6 +85,45 @@ const mockAppeals: Appeal[] = [
},
]
// 将 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 },
@@ -95,6 +139,32 @@ const typeConfig: Record<string, { label: string; color: string }> = {
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]
@@ -174,9 +244,39 @@ function AppealQuotaEntryCard({ onClick }: { onClick: () => void }) {
export default function CreatorAppealsPage() {
const router = useRouter()
const toast = useToast()
const [filter, setFilter] = useState<AppealStatus | 'all'>('all')
const [searchQuery, setSearchQuery] = useState('')
const [appeals] = useState<Appeal[]>(mockAppeals)
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 => {
@@ -239,8 +339,16 @@ export default function CreatorAppealsPage() {
{/* 申诉列表 */}
<div className="flex flex-col gap-4 flex-1 overflow-y-auto pr-2">
<h2 className="text-lg font-semibold text-text-primary"> ({filteredAppeals.length})</h2>
{filteredAppeals.length > 0 ? (
<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}