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

362 lines
14 KiB
TypeScript
Raw Permalink 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 { useState, useEffect, useCallback } from 'react'
import { Plus, FileText, Trash2, Edit, Search, Eye, Loader2 } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Modal } from '@/components/ui/Modal'
import { SuccessTag, PendingTag } from '@/components/ui/Tag'
import { useToast } from '@/components/ui/Toast'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import type { ProjectResponse } from '@/types/project'
import type { BriefResponse } from '@/types/brief'
// Brief + Project 联合视图
interface BriefItem {
projectId: string
projectName: string
projectStatus: string
brief: BriefResponse | null
updatedAt: string
}
// ==================== Mock 数据 ====================
const mockBriefItems: BriefItem[] = [
{
projectId: 'PJ000001',
projectName: '2024 夏日护肤活动',
projectStatus: 'active',
brief: {
id: 'BF000001',
project_id: 'PJ000001',
brand_tone: '清新自然',
selling_points: [{ content: 'SPF50+ PA++++', required: true }, { content: '轻薄不油腻', required: false }],
blacklist_words: [{ word: '最好', reason: '极限词' }],
competitors: ['竞品A'],
min_duration: 30,
max_duration: 180,
other_requirements: '需在开头3秒内展示产品',
attachments: [],
created_at: '2024-01-15',
updated_at: '2024-02-01',
},
updatedAt: '2024-02-01',
},
{
projectId: 'PJ000002',
projectName: '新品口红上市',
projectStatus: 'active',
brief: {
id: 'BF000002',
project_id: 'PJ000002',
brand_tone: '时尚摩登',
selling_points: [{ content: '持久不脱色', required: true }],
blacklist_words: [],
competitors: [],
min_duration: 15,
max_duration: 120,
other_requirements: '',
attachments: [],
created_at: '2024-02-01',
updated_at: '2024-02-03',
},
updatedAt: '2024-02-03',
},
{
projectId: 'PJ000003',
projectName: '年货节活动',
projectStatus: 'completed',
brief: null,
updatedAt: '2024-01-20',
},
]
function BriefSkeleton() {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 animate-pulse">
{[1, 2, 3].map(i => (
<div key={i} className="h-64 bg-bg-elevated rounded-xl" />
))}
</div>
)
}
export default function BriefsPage() {
const toast = useToast()
const [briefItems, setBriefItems] = useState<BriefItem[]>([])
const [loading, setLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState('')
// 查看详情
const [showDetailModal, setShowDetailModal] = useState(false)
const [selectedItem, setSelectedItem] = useState<BriefItem | null>(null)
const loadData = useCallback(async () => {
if (USE_MOCK) {
setBriefItems(mockBriefItems)
setLoading(false)
return
}
try {
const projectRes = await api.listProjects(1, 100)
const items: BriefItem[] = []
// 并行获取每个项目的 Brief
const briefPromises = projectRes.items.map(async (project: ProjectResponse) => {
try {
const brief = await api.getBrief(project.id)
return {
projectId: project.id,
projectName: project.name,
projectStatus: project.status,
brief,
updatedAt: brief.updated_at || project.updated_at,
}
} catch {
// Brief 不存在返回 null
return {
projectId: project.id,
projectName: project.name,
projectStatus: project.status,
brief: null,
updatedAt: project.updated_at,
}
}
})
const results = await Promise.all(briefPromises)
setBriefItems(results)
} catch (err) {
console.error('Failed to load briefs:', err)
toast.error('加载 Brief 列表失败')
} finally {
setLoading(false)
}
}, [toast])
useEffect(() => { loadData() }, [loadData])
const filteredItems = briefItems.filter((item) =>
item.projectName.toLowerCase().includes(searchQuery.toLowerCase())
)
// 查看 Brief 详情
const viewBriefDetail = (item: BriefItem) => {
setSelectedItem(item)
setShowDetailModal(true)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Brief </h1>
<p className="text-sm text-text-secondary mt-1"> Brief Brief</p>
</div>
</div>
{/* 搜索 */}
<div className="relative max-w-md">
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-tertiary" />
<input
type="text"
placeholder="搜索项目名称..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 border border-border-subtle rounded-xl bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
/>
</div>
{/* Brief 列表 */}
{loading ? (
<BriefSkeleton />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredItems.map((item) => (
<Card key={item.projectId} className="hover:shadow-md transition-shadow border border-border-subtle">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="p-2 bg-accent-indigo/15 rounded-lg">
<FileText size={24} className="text-accent-indigo" />
</div>
{item.brief ? (
<SuccessTag></SuccessTag>
) : (
<PendingTag></PendingTag>
)}
</div>
<h3 className="font-semibold text-text-primary mb-1">{item.projectName}</h3>
<p className="text-sm text-text-tertiary mb-3">
{item.brief ? (
<>
{item.brief.brand_tone && `调性: ${item.brief.brand_tone}`}
{(item.brief.selling_points?.length ?? 0) > 0 && ` · ${item.brief.selling_points!.length} 个卖点`}
</>
) : (
'该项目尚未配置 Brief'
)}
</p>
{item.brief && (
<div className="flex gap-4 text-sm text-text-tertiary mb-4">
<span>{item.brief.selling_points?.length || 0} </span>
<span>{item.brief.blacklist_words?.length || 0} </span>
{item.brief.min_duration && item.brief.max_duration && (
<span>{item.brief.min_duration}-{item.brief.max_duration}</span>
)}
</div>
)}
<div className="flex items-center justify-between pt-3 border-t border-border-subtle">
<span className="text-xs text-text-tertiary">
{item.updatedAt?.split('T')[0] || '-'}
</span>
<div className="flex gap-1">
{item.brief && (
<button
type="button"
onClick={() => viewBriefDetail(item)}
className="p-1.5 hover:bg-bg-elevated rounded-lg transition-colors"
title="查看详情"
>
<Eye size={16} className="text-text-tertiary hover:text-accent-indigo" />
</button>
)}
<button
type="button"
onClick={() => {
window.location.href = `/brand/projects/${item.projectId}/config`
}}
className="p-1.5 hover:bg-bg-elevated rounded-lg transition-colors"
title="编辑 Brief"
>
<Edit size={16} className="text-text-tertiary hover:text-accent-indigo" />
</button>
</div>
</div>
</CardContent>
</Card>
))}
{filteredItems.length === 0 && !loading && (
<div className="col-span-3 text-center py-12 text-text-tertiary">
<FileText size={48} className="mx-auto mb-4 opacity-50" />
<p></p>
</div>
)}
</div>
)}
{/* Brief 详情弹窗 */}
<Modal
isOpen={showDetailModal}
onClose={() => {
setShowDetailModal(false)
setSelectedItem(null)
}}
title={selectedItem?.projectName ? `Brief - ${selectedItem.projectName}` : 'Brief 详情'}
size="lg"
>
{selectedItem?.brief && (
<div className="space-y-5">
<div className="flex items-center gap-4 p-4 rounded-xl bg-bg-elevated">
<div className="p-3 bg-accent-indigo/15 rounded-xl">
<FileText size={28} className="text-accent-indigo" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-text-primary">{selectedItem.projectName}</h3>
{selectedItem.brief.brand_tone && (
<p className="text-sm text-text-tertiary mt-0.5">: {selectedItem.brief.brand_tone}</p>
)}
</div>
<SuccessTag></SuccessTag>
</div>
{/* 卖点列表 */}
{(selectedItem.brief.selling_points?.length ?? 0) > 0 && (
<div>
<h4 className="text-sm font-medium text-text-primary mb-3"></h4>
<div className="space-y-2">
{selectedItem.brief.selling_points!.map((sp, idx) => (
<div key={idx} className="flex items-center justify-between p-3 rounded-lg bg-bg-elevated">
<span className="text-sm text-text-primary">{sp.content}</span>
{sp.required && (
<span className="text-xs px-2 py-0.5 bg-accent-coral/15 text-accent-coral rounded"></span>
)}
</div>
))}
</div>
</div>
)}
{/* 违禁词 */}
{(selectedItem.brief.blacklist_words?.length ?? 0) > 0 && (
<div>
<h4 className="text-sm font-medium text-text-primary mb-3"></h4>
<div className="flex flex-wrap gap-2">
{selectedItem.brief.blacklist_words!.map((bw, idx) => (
<span key={idx} className="px-3 py-1.5 rounded-lg bg-accent-coral/10 text-accent-coral text-sm">
{bw.word}
{bw.reason && <span className="text-xs text-text-tertiary ml-1">({bw.reason})</span>}
</span>
))}
</div>
</div>
)}
{/* 时长要求 */}
{(selectedItem.brief.min_duration || selectedItem.brief.max_duration) && (
<div className="grid grid-cols-2 gap-4">
<div className="p-4 rounded-xl bg-accent-indigo/10 border border-accent-indigo/20 text-center">
<p className="text-2xl font-bold text-accent-indigo">{selectedItem.brief.min_duration || '-'}</p>
<p className="text-sm text-text-secondary mt-1"></p>
</div>
<div className="p-4 rounded-xl bg-accent-green/10 border border-accent-green/20 text-center">
<p className="text-2xl font-bold text-accent-green">{selectedItem.brief.max_duration || '-'}</p>
<p className="text-sm text-text-secondary mt-1"></p>
</div>
</div>
)}
{/* 其他要求 */}
{selectedItem.brief.other_requirements && (
<div>
<h4 className="text-sm font-medium text-text-primary mb-2"></h4>
<p className="text-sm text-text-secondary p-3 rounded-lg bg-bg-elevated">
{selectedItem.brief.other_requirements}
</p>
</div>
)}
{/* 时间信息 */}
<div className="flex items-center justify-between p-4 rounded-xl bg-bg-elevated text-sm">
<div>
<span className="text-text-tertiary"></span>
<span className="text-text-primary">{selectedItem.brief.created_at?.split('T')[0]}</span>
</div>
<div>
<span className="text-text-tertiary"></span>
<span className="text-text-primary">{selectedItem.brief.updated_at?.split('T')[0]}</span>
</div>
</div>
<div className="flex gap-3 justify-end pt-4 border-t border-border-subtle">
<Button variant="ghost" onClick={() => setShowDetailModal(false)}>
</Button>
<Button onClick={() => {
setShowDetailModal(false)
window.location.href = `/brand/projects/${selectedItem.projectId}/config`
}}>
Brief
</Button>
</div>
</div>
)}
</Modal>
</div>
)
}