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

402 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 React, { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import {
ArrowLeft,
AlertCircle,
CheckCircle,
Clock,
XCircle,
Send,
Info,
Loader2
} from 'lucide-react'
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
import { Button } from '@/components/ui/Button'
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 RequestStatus = 'none' | 'pending' | 'approved' | 'rejected'
// 任务申诉次数数据
interface TaskAppealQuota {
id: string
taskName: string
agencyName: string
remaining: number
used: number
requestStatus: RequestStatus
requestTime?: string
}
// 模拟任务申诉次数数据
const mockTaskQuotas: TaskAppealQuota[] = [
{
id: '1',
taskName: '618美妆推广视频',
agencyName: '星辰传媒',
remaining: 1,
used: 0,
requestStatus: 'none',
},
{
id: '2',
taskName: '双11护肤品种草',
agencyName: '星辰传媒',
remaining: 0,
used: 1,
requestStatus: 'pending',
requestTime: '2024-02-05 14:30',
},
{
id: '3',
taskName: '春节限定礼盒开箱',
agencyName: '晨曦文化',
remaining: 2,
used: 0,
requestStatus: 'approved',
requestTime: '2024-02-04 10:15',
},
{
id: '4',
taskName: '情人节香水测评',
agencyName: '晨曦文化',
remaining: 0,
used: 1,
requestStatus: 'rejected',
requestTime: '2024-02-03 16:20',
},
]
// 将 TaskResponse 映射为 TaskAppealQuota
function mapTaskToQuota(task: TaskResponse): TaskAppealQuota {
// Default quota is 1 per task
const defaultQuota = 1
const remaining = Math.max(0, defaultQuota - task.appeal_count)
// Determine request status based on task state
let requestStatus: RequestStatus = 'none'
if (task.is_appeal && task.appeal_count > 0) {
requestStatus = 'pending'
}
return {
id: task.id,
taskName: task.name,
agencyName: task.agency?.name || '未知代理商',
remaining,
used: task.appeal_count,
requestStatus,
}
}
// 状态标签组件
function StatusBadge({ status }: { status: RequestStatus }) {
const config = {
none: { label: '', icon: null, className: '' },
pending: {
label: '申请中',
icon: Clock,
className: 'bg-accent-amber/15 text-accent-amber',
},
approved: {
label: '已同意',
icon: CheckCircle,
className: 'bg-accent-green/15 text-accent-green',
},
rejected: {
label: '已拒绝',
icon: XCircle,
className: 'bg-accent-coral/15 text-accent-coral',
},
}
const { label, icon: Icon, className } = config[status]
if (status === 'none') return null
return (
<span className={cn('inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium', className)}>
{Icon && <Icon size={12} />}
{label}
</span>
)
}
// 骨架屏组件
function QuotaSkeleton() {
return (
<div className="bg-bg-card rounded-xl p-5 card-shadow flex flex-col gap-4 animate-pulse">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-2">
<div className="h-4 w-32 bg-bg-elevated rounded" />
<div className="h-3 w-20 bg-bg-elevated rounded" />
</div>
<div className="h-5 w-14 bg-bg-elevated rounded-full" />
</div>
<div className="flex items-center gap-6">
<div className="flex flex-col gap-1">
<div className="h-7 w-8 bg-bg-elevated rounded" />
<div className="h-3 w-14 bg-bg-elevated rounded" />
</div>
<div className="flex flex-col gap-1">
<div className="h-7 w-8 bg-bg-elevated rounded" />
<div className="h-3 w-14 bg-bg-elevated rounded" />
</div>
</div>
<div className="pt-3 border-t border-border-subtle">
<div className="h-8 w-24 bg-bg-elevated rounded" />
</div>
</div>
)
}
// 任务卡片组件
function TaskQuotaCard({
task,
onRequestIncrease,
requesting,
}: {
task: TaskAppealQuota
onRequestIncrease: (taskId: string) => void
requesting: boolean
}) {
const canRequest = task.requestStatus === 'none' || task.requestStatus === 'rejected'
return (
<div className="bg-bg-card rounded-xl p-5 card-shadow flex flex-col gap-4">
{/* 任务信息 */}
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1 min-w-0">
<h3 className="text-[15px] font-medium text-text-primary truncate">{task.taskName}</h3>
<p className="text-[13px] text-text-tertiary">{task.agencyName}</p>
</div>
<StatusBadge status={task.requestStatus} />
</div>
{/* 申诉次数 */}
<div className="flex items-center gap-6">
<div className="flex flex-col gap-0.5">
<span className="text-2xl font-bold text-accent-indigo">{task.remaining}</span>
<span className="text-xs text-text-tertiary"></span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-2xl font-bold text-text-secondary">{task.used}</span>
<span className="text-xs text-text-tertiary">使</span>
</div>
</div>
{/* 操作按钮 */}
<div className="flex items-center justify-between pt-3 border-t border-border-subtle">
{task.requestTime && (
<span className="text-xs text-text-tertiary">
{task.requestStatus === 'pending' ? '申请时间:' : '处理时间:'}
{task.requestTime}
</span>
)}
{!task.requestTime && <span />}
{canRequest ? (
<Button
variant="secondary"
size="sm"
onClick={() => onRequestIncrease(task.id)}
disabled={requesting}
className="gap-1.5"
>
{requesting ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
{requesting ? '申请中...' : '申请增加'}
</Button>
) : task.requestStatus === 'pending' ? (
<span className="text-xs text-accent-amber">...</span>
) : null}
</div>
</div>
)
}
export default function AppealQuotaPage() {
const router = useRouter()
const toast = useToast()
const [tasks, setTasks] = useState<TaskAppealQuota[]>([])
const [loading, setLoading] = useState(true)
const [requestingTaskId, setRequestingTaskId] = useState<string | null>(null)
const loadQuotas = useCallback(async () => {
if (USE_MOCK) {
setTasks(mockTaskQuotas)
setLoading(false)
return
}
try {
setLoading(true)
const response = await api.listTasks(1, 100)
const mapped = response.items.map(mapTaskToQuota)
setTasks(mapped)
} catch (err) {
console.error('加载申诉次数失败:', err)
toast.error('加载申诉次数信息失败,请稍后重试')
} finally {
setLoading(false)
}
}, [toast])
useEffect(() => {
loadQuotas()
}, [loadQuotas])
// 申请增加申诉次数
const handleRequestIncrease = async (taskId: string) => {
if (USE_MOCK) {
setTasks(prev =>
prev.map(task =>
task.id === taskId
? {
...task,
requestStatus: 'pending' as RequestStatus,
requestTime: new Date().toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}),
}
: task
)
)
toast.success('申请已发送,等待代理商处理')
return
}
try {
setRequestingTaskId(taskId)
await api.increaseAppealCount(taskId)
toast.success('申请已发送,等待代理商处理')
// Update local state optimistically
setTasks(prev =>
prev.map(task =>
task.id === taskId
? {
...task,
requestStatus: 'pending' as RequestStatus,
requestTime: new Date().toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}),
}
: task
)
)
} catch (err) {
console.error('申请增加申诉次数失败:', err)
toast.error('申请失败,请稍后重试')
} finally {
setRequestingTaskId(null)
}
}
// 统计数据
const totalRemaining = tasks.reduce((sum, t) => sum + t.remaining, 0)
const totalUsed = tasks.reduce((sum, t) => sum + t.used, 0)
const pendingRequests = tasks.filter(t => t.requestStatus === 'pending').length
return (
<ResponsiveLayout role="creator">
<div className="flex flex-col gap-6 h-full">
{/* 顶部栏 */}
<div className="flex items-center gap-4">
<button
type="button"
onClick={() => router.back()}
className="w-10 h-10 rounded-xl bg-bg-elevated flex items-center justify-center hover:bg-bg-elevated/80 transition-colors"
>
<ArrowLeft size={20} className="text-text-secondary" />
</button>
<div className="flex flex-col gap-1">
<h1 className="text-2xl lg:text-[28px] font-bold text-text-primary"></h1>
<p className="text-sm lg:text-[15px] text-text-secondary">
</p>
</div>
</div>
{/* 统计卡片 */}
{loading ? (
<div className="grid grid-cols-3 gap-4">
{[1, 2, 3].map(i => (
<div key={i} className="bg-bg-card rounded-xl p-4 card-shadow flex flex-col items-center gap-1 animate-pulse">
<div className="h-7 w-8 bg-bg-elevated rounded" />
<div className="h-3 w-14 bg-bg-elevated rounded" />
</div>
))}
</div>
) : (
<div className="grid grid-cols-3 gap-4">
<div className="bg-bg-card rounded-xl p-4 card-shadow flex flex-col items-center gap-1">
<span className="text-2xl font-bold text-accent-indigo">{totalRemaining}</span>
<span className="text-xs text-text-tertiary"></span>
</div>
<div className="bg-bg-card rounded-xl p-4 card-shadow flex flex-col items-center gap-1">
<span className="text-2xl font-bold text-text-secondary">{totalUsed}</span>
<span className="text-xs text-text-tertiary">使</span>
</div>
<div className="bg-bg-card rounded-xl p-4 card-shadow flex flex-col items-center gap-1">
<span className="text-2xl font-bold text-accent-amber">{pendingRequests}</span>
<span className="text-xs text-text-tertiary"></span>
</div>
</div>
)}
{/* 规则说明 */}
<div className="bg-accent-indigo/10 rounded-xl p-4 flex gap-3">
<Info size={20} className="text-accent-indigo flex-shrink-0 mt-0.5" />
<div className="flex flex-col gap-1">
<span className="text-sm font-medium text-text-primary"></span>
<span className="text-[13px] text-text-secondary leading-relaxed">
1 "申请增加"
</span>
</div>
</div>
{/* 任务列表 */}
<div className="flex flex-col gap-4 flex-1 min-h-0 overflow-y-auto pb-4">
<h2 className="text-base font-semibold text-text-primary sticky top-0 bg-bg-page py-2 -mt-2">
{!loading && `(${tasks.length})`}
</h2>
{loading ? (
<>
<QuotaSkeleton />
<QuotaSkeleton />
<QuotaSkeleton />
</>
) : tasks.length > 0 ? (
tasks.map(task => (
<TaskQuotaCard
key={task.id}
task={task}
onRequestIncrease={handleRequestIncrease}
requesting={requestingTaskId === task.id}
/>
))
) : (
<div className="flex flex-col items-center justify-center py-16">
<AlertCircle className="w-12 h-12 text-text-tertiary/50 mb-4" />
<p className="text-text-secondary text-center"></p>
</div>
)}
</div>
</div>
</ResponsiveLayout>
)
}