Your Name 8eb8100cf4 fix: P0 安全加固 + 前端错误边界 + ESLint 修复
后端:
- 实现登出 API(清除 refresh token)
- 清除 videos.py 中已被 Celery 任务取代的死代码
- 添加速率限制中间件(60次/分钟,登录10次/分钟)
- 添加 SECRET_KEY/ENCRYPTION_KEY 默认值警告
- OSS STS 方法回退到 Policy 签名(不再抛异常)

前端:
- 添加全局 404/error/loading 页面
- 添加三端 error.tsx + loading.tsx 错误边界
- 修复 useId 条件调用违反 Hooks 规则
- 修复未转义引号和 Image 命名冲突
- 添加 ESLint 配置

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 17:18:04 +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 &ldquo;&rdquo;
</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>
)
}