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:
co-authored by
Claude Opus 4.6
parent
54eaa54966
commit
a8be7bbca9
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user