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
+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">