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
+184 -52
View File
@@ -1,6 +1,6 @@
'use client'
import React, { useState } from 'react'
import React, { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import {
ArrowLeft,
@@ -10,10 +10,15 @@ import {
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'
@@ -68,6 +73,28 @@ const mockTaskQuotas: TaskAppealQuota[] = [
},
]
// 将 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 = {
@@ -101,13 +128,43 @@ function StatusBadge({ status }: { status: RequestStatus }) {
)
}
// 骨架屏组件
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'
@@ -149,10 +206,11 @@ function TaskQuotaCard({
variant="secondary"
size="sm"
onClick={() => onRequestIncrease(task.id)}
disabled={requesting}
className="gap-1.5"
>
<Send size={14} />
{requesting ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
{requesting ? '申请中...' : '申请增加'}
</Button>
) : task.requestStatus === 'pending' ? (
<span className="text-xs text-accent-amber">...</span>
@@ -164,30 +222,87 @@ function TaskQuotaCard({
export default function AppealQuotaPage() {
const router = useRouter()
const [tasks, setTasks] = useState(mockTaskQuotas)
const [showSuccessToast, setShowSuccessToast] = useState(false)
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 = (taskId: string) => {
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
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
)
)
)
setShowSuccessToast(true)
setTimeout(() => setShowSuccessToast(false), 3000)
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)
}
}
// 统计数据
@@ -216,20 +331,31 @@ export default function AppealQuotaPage() {
</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>
{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="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 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-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">
@@ -245,25 +371,31 @@ export default function AppealQuotaPage() {
{/* 任务列表 */}
<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">
({tasks.length})
{!loading && `(${tasks.length})`}
</h2>
{tasks.map(task => (
<TaskQuotaCard
key={task.id}
task={task}
onRequestIncrease={handleRequestIncrease}
/>
))}
{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>
{/* 成功提示 */}
{showSuccessToast && (
<div className="fixed bottom-24 left-1/2 -translate-x-1/2 bg-accent-green text-white px-4 py-3 rounded-xl shadow-lg flex items-center gap-2 animate-fade-in z-50">
<CheckCircle size={18} />
<span className="text-sm font-medium"></span>
</div>
)}
</ResponsiveLayout>
)
}
+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}
+93 -13
View File
@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import {
ArrowLeft,
@@ -9,10 +9,15 @@ import {
Clock,
Video,
Filter,
ChevronRight
ChevronRight,
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 HistoryStatus = 'completed' | 'expired' | 'cancelled'
@@ -80,6 +85,17 @@ const mockHistory: HistoryTask[] = [
},
]
function mapTaskResponseToHistory(task: TaskResponse): HistoryTask {
return {
id: task.id,
title: task.name,
description: task.project.name,
status: task.stage === 'completed' ? 'completed' : 'completed',
completedAt: task.updated_at?.split('T')[0],
platform: '抖音', // backend doesn't return platform info yet
}
}
// 状态配置
const statusConfig: Record<HistoryStatus, { label: string; color: string; bgColor: string; icon: React.ElementType }> = {
completed: { label: '已完成', color: 'text-accent-green', bgColor: 'bg-accent-green/15', icon: CheckCircle },
@@ -87,6 +103,32 @@ const statusConfig: Record<HistoryStatus, { label: string; color: string; bgColo
cancelled: { label: '已取消', color: 'text-accent-coral', bgColor: 'bg-accent-coral/15', icon: XCircle },
}
// 骨架屏
function HistorySkeleton() {
return (
<div className="flex flex-col gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="bg-bg-card rounded-2xl p-5 card-shadow animate-pulse">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-16 h-12 rounded-lg bg-bg-elevated" />
<div className="flex flex-col gap-2">
<div className="h-4 w-40 bg-bg-elevated rounded" />
<div className="h-3 w-28 bg-bg-elevated rounded" />
<div className="h-3 w-20 bg-bg-elevated rounded" />
</div>
</div>
<div className="flex items-center gap-3">
<div className="h-8 w-20 bg-bg-elevated rounded-lg" />
<div className="w-5 h-5 bg-bg-elevated rounded" />
</div>
</div>
</div>
))}
</div>
)
}
// 历史任务卡片
function HistoryCard({ task, onClick }: { task: HistoryTask; onClick: () => void }) {
const status = statusConfig[task.status]
@@ -127,9 +169,37 @@ function HistoryCard({ task, onClick }: { task: HistoryTask; onClick: () => void
export default function CreatorHistoryPage() {
const router = useRouter()
const toast = useToast()
const [filter, setFilter] = useState<HistoryStatus | 'all'>('all')
const [loading, setLoading] = useState(true)
const [historyTasks, setHistoryTasks] = useState<HistoryTask[]>([])
const filteredHistory = filter === 'all' ? mockHistory : mockHistory.filter(t => t.status === filter)
const loadHistory = useCallback(async () => {
if (USE_MOCK) {
setHistoryTasks(mockHistory)
setLoading(false)
return
}
try {
setLoading(true)
const response = await api.listTasks(1, 50, 'completed')
const mapped = response.items.map(mapTaskResponseToHistory)
setHistoryTasks(mapped)
} catch (err) {
const message = err instanceof Error ? err.message : '加载历史记录失败'
toast.error(message)
console.error('加载历史记录失败:', err)
} finally {
setLoading(false)
}
}, [toast])
useEffect(() => {
loadHistory()
}, [loadHistory])
const filteredHistory = filter === 'all' ? historyTasks : historyTasks.filter(t => t.status === filter)
return (
<ResponsiveLayout role="creator">
@@ -167,21 +237,21 @@ export default function CreatorHistoryPage() {
<div className="flex items-center gap-6 bg-bg-card rounded-2xl p-5 card-shadow">
<div className="flex flex-col items-center gap-1 flex-1">
<span className="text-2xl font-bold text-accent-green">
{mockHistory.filter(t => t.status === 'completed').length}
{historyTasks.filter(t => t.status === 'completed').length}
</span>
<span className="text-xs text-text-tertiary"></span>
</div>
<div className="w-px h-10 bg-border-subtle" />
<div className="flex flex-col items-center gap-1 flex-1">
<span className="text-2xl font-bold text-text-tertiary">
{mockHistory.filter(t => t.status === 'expired').length}
{historyTasks.filter(t => t.status === 'expired').length}
</span>
<span className="text-xs text-text-tertiary"></span>
</div>
<div className="w-px h-10 bg-border-subtle" />
<div className="flex flex-col items-center gap-1 flex-1">
<span className="text-2xl font-bold text-accent-coral">
{mockHistory.filter(t => t.status === 'cancelled').length}
{historyTasks.filter(t => t.status === 'cancelled').length}
</span>
<span className="text-xs text-text-tertiary"></span>
</div>
@@ -189,13 +259,23 @@ export default function CreatorHistoryPage() {
{/* 任务列表 */}
<div className="flex flex-col gap-4 flex-1 overflow-y-auto pr-2">
{filteredHistory.map((task) => (
<HistoryCard
key={task.id}
task={task}
onClick={() => router.push(`/creator/task/${task.id}`)}
/>
))}
{loading ? (
<HistorySkeleton />
) : filteredHistory.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Clock className="w-12 h-12 text-text-tertiary mb-4" />
<p className="text-text-secondary"></p>
<p className="text-sm text-text-tertiary mt-1"></p>
</div>
) : (
filteredHistory.map((task) => (
<HistoryCard
key={task.id}
task={task}
onClick={() => router.push(`/creator/task/${task.id}`)}
/>
))
)}
</div>
</div>
</ResponsiveLayout>
+297 -121
View File
@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { useToast } from '@/components/ui/Toast'
import {
@@ -14,11 +14,16 @@ import {
Building2,
Calendar,
Clock,
ChevronRight
ChevronRight,
Loader2
} from 'lucide-react'
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
import { Modal } from '@/components/ui/Modal'
import { Button } from '@/components/ui/Button'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import type { BriefResponse } from '@/types/brief'
import type { TaskResponse } from '@/types/task'
// 代理商Brief文档类型
type AgencyBriefFile = {
@@ -29,6 +34,19 @@ type AgencyBriefFile = {
description?: string
}
// 页面视图模型
type BriefViewModel = {
taskName: string
agencyName: string
brandName: string
deadline: string
createdAt: string
files: AgencyBriefFile[]
sellingPoints: { id: string; content: string; required: boolean }[]
blacklistWords: { id: string; word: string; reason: string }[]
contentRequirements: string[]
}
// 模拟任务数据
const mockTaskInfo = {
id: 'task-001',
@@ -69,11 +87,151 @@ const mockAgencyBrief = {
],
}
function buildMockViewModel(): BriefViewModel {
return {
taskName: mockTaskInfo.taskName,
agencyName: mockTaskInfo.agencyName,
brandName: mockTaskInfo.brandName,
deadline: mockTaskInfo.deadline,
createdAt: mockTaskInfo.createdAt,
files: mockAgencyBrief.files,
sellingPoints: mockAgencyBrief.sellingPoints,
blacklistWords: mockAgencyBrief.blacklistWords,
contentRequirements: mockAgencyBrief.contentRequirements,
}
}
function buildViewModelFromAPI(task: TaskResponse, brief: BriefResponse): BriefViewModel {
// Map attachments to file list
const files: AgencyBriefFile[] = (brief.attachments ?? []).map((att, idx) => ({
id: att.id || `att-${idx}`,
name: att.name,
size: att.size || '',
uploadedAt: brief.updated_at?.split('T')[0] || '',
description: undefined,
}))
// Map selling points
const sellingPoints = (brief.selling_points ?? []).map((sp, idx) => ({
id: `sp-${idx}`,
content: sp.content,
required: sp.required,
}))
// Map blacklist words
const blacklistWords = (brief.blacklist_words ?? []).map((bw, idx) => ({
id: `bw-${idx}`,
word: bw.word,
reason: bw.reason,
}))
// Build content requirements
const contentRequirements: string[] = []
if (brief.min_duration != null || brief.max_duration != null) {
const minStr = brief.min_duration != null ? `${brief.min_duration}` : '?'
const maxStr = brief.max_duration != null ? `${brief.max_duration}` : '?'
contentRequirements.push(`视频时长:${minStr}-${maxStr}`)
}
if (brief.other_requirements) {
contentRequirements.push(brief.other_requirements)
}
return {
taskName: task.name,
agencyName: task.agency.name,
brandName: task.project.brand_name || task.project.name,
deadline: '', // backend task has no deadline field yet
createdAt: task.created_at.split('T')[0],
files,
sellingPoints,
blacklistWords,
contentRequirements,
}
}
// 骨架屏
function BriefSkeleton() {
return (
<div className="flex flex-col gap-6 h-full animate-pulse">
{/* 顶部导航骨架 */}
<div className="flex items-center justify-between">
<div className="flex flex-col gap-2">
<div className="h-8 w-16 bg-bg-elevated rounded-lg" />
<div className="h-7 w-48 bg-bg-elevated rounded" />
<div className="h-4 w-36 bg-bg-elevated rounded" />
</div>
<div className="h-10 w-28 bg-bg-elevated rounded-xl" />
</div>
{/* 任务信息骨架 */}
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
<div className="h-5 w-24 bg-bg-elevated rounded mb-4" />
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-bg-elevated" />
<div className="flex flex-col gap-1">
<div className="h-3 w-12 bg-bg-elevated rounded" />
<div className="h-4 w-20 bg-bg-elevated rounded" />
</div>
</div>
))}
</div>
</div>
{/* 内容区域骨架 */}
<div className="flex-1 space-y-6">
{[...Array(3)].map((_, i) => (
<div key={i} className="bg-bg-card rounded-2xl p-5 card-shadow">
<div className="h-5 w-32 bg-bg-elevated rounded mb-4" />
<div className="space-y-2">
<div className="h-4 w-full bg-bg-elevated rounded" />
<div className="h-4 w-3/4 bg-bg-elevated rounded" />
<div className="h-4 w-1/2 bg-bg-elevated rounded" />
</div>
</div>
))}
</div>
</div>
)
}
export default function TaskBriefPage() {
const router = useRouter()
const params = useParams()
const toast = useToast()
const taskId = params.id as string
const [previewFile, setPreviewFile] = useState<AgencyBriefFile | null>(null)
const [loading, setLoading] = useState(true)
const [viewModel, setViewModel] = useState<BriefViewModel | null>(null)
const loadBriefData = useCallback(async () => {
if (USE_MOCK) {
setViewModel(buildMockViewModel())
setLoading(false)
return
}
try {
setLoading(true)
// First get the task to find its project ID
const task = await api.getTask(taskId)
// Then get the brief for that project
const brief = await api.getBrief(task.project.id)
setViewModel(buildViewModelFromAPI(task, brief))
} catch (err) {
const message = err instanceof Error ? err.message : '加载Brief失败'
toast.error(message)
console.error('加载Brief失败:', err)
// Fallback: still show task info if brief load fails
} finally {
setLoading(false)
}
}, [taskId, toast])
useEffect(() => {
loadBriefData()
}, [loadBriefData])
const handleDownload = (file: AgencyBriefFile) => {
toast.info(`下载文件: ${file.name}`)
@@ -83,8 +241,16 @@ export default function TaskBriefPage() {
toast.info('下载全部文件')
}
const requiredPoints = mockAgencyBrief.sellingPoints.filter(sp => sp.required)
const optionalPoints = mockAgencyBrief.sellingPoints.filter(sp => !sp.required)
if (loading || !viewModel) {
return (
<ResponsiveLayout role="creator">
<BriefSkeleton />
</ResponsiveLayout>
)
}
const requiredPoints = viewModel.sellingPoints.filter(sp => sp.required)
const optionalPoints = viewModel.sellingPoints.filter(sp => !sp.required)
return (
<ResponsiveLayout role="creator">
@@ -102,7 +268,7 @@ export default function TaskBriefPage() {
</button>
</div>
<h1 className="text-xl lg:text-[28px] font-bold text-text-primary">{mockTaskInfo.taskName}</h1>
<h1 className="text-xl lg:text-[28px] font-bold text-text-primary">{viewModel.taskName}</h1>
<p className="text-sm lg:text-[15px] text-text-secondary">Brief文档</p>
</div>
<Button onClick={() => router.push(`/creator/task/${params.id}`)}>
@@ -121,7 +287,7 @@ export default function TaskBriefPage() {
</div>
<div>
<p className="text-xs text-text-tertiary"></p>
<p className="text-sm font-medium text-text-primary">{mockTaskInfo.agencyName}</p>
<p className="text-sm font-medium text-text-primary">{viewModel.agencyName}</p>
</div>
</div>
<div className="flex items-center gap-3">
@@ -130,7 +296,7 @@ export default function TaskBriefPage() {
</div>
<div>
<p className="text-xs text-text-tertiary"></p>
<p className="text-sm font-medium text-text-primary">{mockTaskInfo.brandName}</p>
<p className="text-sm font-medium text-text-primary">{viewModel.brandName}</p>
</div>
</div>
<div className="flex items-center gap-3">
@@ -139,142 +305,152 @@ export default function TaskBriefPage() {
</div>
<div>
<p className="text-xs text-text-tertiary"></p>
<p className="text-sm font-medium text-text-primary">{mockTaskInfo.createdAt}</p>
<p className="text-sm font-medium text-text-primary">{viewModel.createdAt}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-accent-coral/15 flex items-center justify-center">
<Clock className="w-5 h-5 text-accent-coral" />
{viewModel.deadline && (
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-accent-coral/15 flex items-center justify-center">
<Clock className="w-5 h-5 text-accent-coral" />
</div>
<div>
<p className="text-xs text-text-tertiary"></p>
<p className="text-sm font-medium text-text-primary">{viewModel.deadline}</p>
</div>
</div>
<div>
<p className="text-xs text-text-tertiary"></p>
<p className="text-sm font-medium text-text-primary">{mockTaskInfo.deadline}</p>
</div>
</div>
)}
</div>
</div>
{/* 主要内容区域 - 可滚动 */}
<div className="flex-1 overflow-y-auto space-y-6">
{/* Brief文档列表 */}
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<File className="w-5 h-5 text-accent-indigo" />
<h3 className="text-base font-semibold text-text-primary">Brief </h3>
<span className="text-sm text-text-tertiary">({mockAgencyBrief.files.length})</span>
</div>
<Button variant="secondary" size="sm" onClick={handleDownloadAll}>
<Download className="w-4 h-4" />
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{mockAgencyBrief.files.map((file) => (
<div
key={file.id}
className="flex items-center justify-between p-4 bg-bg-elevated rounded-xl hover:bg-bg-page transition-colors"
>
<div className="flex items-center gap-3 min-w-0">
<div className="w-11 h-11 rounded-xl bg-accent-indigo/15 flex items-center justify-center flex-shrink-0">
<FileText className="w-5 h-5 text-accent-indigo" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{file.name}</p>
<p className="text-xs text-text-tertiary">{file.size}</p>
{file.description && (
<p className="text-xs text-text-secondary mt-0.5 truncate">{file.description}</p>
)}
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
<button
type="button"
onClick={() => setPreviewFile(file)}
className="p-2.5 hover:bg-bg-card rounded-lg transition-colors"
>
<Eye className="w-4 h-4 text-text-secondary" />
</button>
<button
type="button"
onClick={() => handleDownload(file)}
className="p-2.5 hover:bg-bg-card rounded-lg transition-colors"
>
<Download className="w-4 h-4 text-text-secondary" />
</button>
</div>
{viewModel.files.length > 0 && (
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<File className="w-5 h-5 text-accent-indigo" />
<h3 className="text-base font-semibold text-text-primary">Brief </h3>
<span className="text-sm text-text-tertiary">({viewModel.files.length})</span>
</div>
))}
<Button variant="secondary" size="sm" onClick={handleDownloadAll}>
<Download className="w-4 h-4" />
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{viewModel.files.map((file) => (
<div
key={file.id}
className="flex items-center justify-between p-4 bg-bg-elevated rounded-xl hover:bg-bg-page transition-colors"
>
<div className="flex items-center gap-3 min-w-0">
<div className="w-11 h-11 rounded-xl bg-accent-indigo/15 flex items-center justify-center flex-shrink-0">
<FileText className="w-5 h-5 text-accent-indigo" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{file.name}</p>
<p className="text-xs text-text-tertiary">{file.size}</p>
{file.description && (
<p className="text-xs text-text-secondary mt-0.5 truncate">{file.description}</p>
)}
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
<button
type="button"
onClick={() => setPreviewFile(file)}
className="p-2.5 hover:bg-bg-card rounded-lg transition-colors"
>
<Eye className="w-4 h-4 text-text-secondary" />
</button>
<button
type="button"
onClick={() => handleDownload(file)}
className="p-2.5 hover:bg-bg-card rounded-lg transition-colors"
>
<Download className="w-4 h-4 text-text-secondary" />
</button>
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* 内容要求 */}
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
<div className="flex items-center gap-2 mb-4">
<FileText className="w-5 h-5 text-accent-amber" />
<h3 className="text-base font-semibold text-text-primary"></h3>
{viewModel.contentRequirements.length > 0 && (
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
<div className="flex items-center gap-2 mb-4">
<FileText className="w-5 h-5 text-accent-amber" />
<h3 className="text-base font-semibold text-text-primary"></h3>
</div>
<ul className="space-y-2">
{viewModel.contentRequirements.map((req, index) => (
<li key={index} className="flex items-start gap-2 text-sm text-text-secondary">
<span className="w-1.5 h-1.5 rounded-full bg-accent-amber mt-2 flex-shrink-0" />
{req}
</li>
))}
</ul>
</div>
<ul className="space-y-2">
{mockAgencyBrief.contentRequirements.map((req, index) => (
<li key={index} className="flex items-start gap-2 text-sm text-text-secondary">
<span className="w-1.5 h-1.5 rounded-full bg-accent-amber mt-2 flex-shrink-0" />
{req}
</li>
))}
</ul>
</div>
)}
{/* 卖点要求 */}
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
<div className="flex items-center gap-2 mb-4">
<Target className="w-5 h-5 text-accent-green" />
<h3 className="text-base font-semibold text-text-primary"></h3>
</div>
<div className="space-y-3">
{requiredPoints.length > 0 && (
<div className="p-4 bg-accent-coral/10 rounded-xl border border-accent-coral/30">
<p className="text-xs text-accent-coral font-semibold mb-2"></p>
<div className="flex flex-wrap gap-2">
{requiredPoints.map((sp) => (
<span key={sp.id} className="px-3 py-1.5 text-sm bg-accent-coral/20 text-accent-coral rounded-lg font-medium">
{sp.content}
</span>
))}
{viewModel.sellingPoints.length > 0 && (
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
<div className="flex items-center gap-2 mb-4">
<Target className="w-5 h-5 text-accent-green" />
<h3 className="text-base font-semibold text-text-primary"></h3>
</div>
<div className="space-y-3">
{requiredPoints.length > 0 && (
<div className="p-4 bg-accent-coral/10 rounded-xl border border-accent-coral/30">
<p className="text-xs text-accent-coral font-semibold mb-2"></p>
<div className="flex flex-wrap gap-2">
{requiredPoints.map((sp) => (
<span key={sp.id} className="px-3 py-1.5 text-sm bg-accent-coral/20 text-accent-coral rounded-lg font-medium">
{sp.content}
</span>
))}
</div>
</div>
</div>
)}
{optionalPoints.length > 0 && (
<div className="p-4 bg-bg-elevated rounded-xl">
<p className="text-xs text-text-tertiary font-semibold mb-2"></p>
<div className="flex flex-wrap gap-2">
{optionalPoints.map((sp) => (
<span key={sp.id} className="px-3 py-1.5 text-sm bg-bg-page text-text-secondary rounded-lg">
{sp.content}
</span>
))}
)}
{optionalPoints.length > 0 && (
<div className="p-4 bg-bg-elevated rounded-xl">
<p className="text-xs text-text-tertiary font-semibold mb-2"></p>
<div className="flex flex-wrap gap-2">
{optionalPoints.map((sp) => (
<span key={sp.id} className="px-3 py-1.5 text-sm bg-bg-page text-text-secondary rounded-lg">
{sp.content}
</span>
))}
</div>
</div>
</div>
)}
)}
</div>
</div>
</div>
)}
{/* 违禁词 */}
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
<div className="flex items-center gap-2 mb-4">
<Ban className="w-5 h-5 text-accent-coral" />
<h3 className="text-base font-semibold text-text-primary">使</h3>
{viewModel.blacklistWords.length > 0 && (
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
<div className="flex items-center gap-2 mb-4">
<Ban className="w-5 h-5 text-accent-coral" />
<h3 className="text-base font-semibold text-text-primary">使</h3>
</div>
<div className="flex flex-wrap gap-2">
{viewModel.blacklistWords.map((bw) => (
<span
key={bw.id}
className="px-3 py-1.5 text-sm bg-accent-coral/15 text-accent-coral rounded-lg border border-accent-coral/30"
>
{bw.word}<span className="text-xs opacity-75 ml-1">{bw.reason}</span>
</span>
))}
</div>
</div>
<div className="flex flex-wrap gap-2">
{mockAgencyBrief.blacklistWords.map((bw) => (
<span
key={bw.id}
className="px-3 py-1.5 text-sm bg-accent-coral/15 text-accent-coral rounded-lg border border-accent-coral/30"
>
{bw.word}<span className="text-xs opacity-75 ml-1">{bw.reason}</span>
</span>
))}
</div>
</div>
)}
{/* 底部操作按钮 */}
<div className="flex justify-center py-4">