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

352 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 { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { SuccessTag, PendingTag, WarningTag } from '@/components/ui/Tag'
import {
FileText,
Search,
Filter,
Clock,
CheckCircle,
AlertTriangle,
ChevronRight,
Settings,
Loader2
} from 'lucide-react'
import { getPlatformInfo } from '@/lib/platforms'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import type { ProjectResponse } from '@/types/project'
import type { BriefResponse, SellingPoint, BlacklistWord } from '@/types/brief'
// ==================== 本地视图模型 ====================
interface BriefItem {
id: string
projectId: string
projectName: string
brandName: string
platform: string
status: 'configured' | 'pending'
uploadedAt: string
configuredAt: string | null
creatorCount: number
sellingPoints: number
blacklistWords: number
}
// ==================== Mock 数据 ====================
const mockBriefs: BriefItem[] = [
{
id: 'brief-001',
projectId: 'proj-001',
projectName: 'XX品牌618推广',
brandName: 'XX护肤品牌',
platform: 'douyin',
status: 'configured',
uploadedAt: '2026-02-01',
configuredAt: '2026-02-02',
creatorCount: 15,
sellingPoints: 5,
blacklistWords: 12,
},
{
id: 'brief-002',
projectId: 'proj-002',
projectName: '新品口红系列',
brandName: 'XX美妆品牌',
platform: 'xiaohongshu',
status: 'pending',
uploadedAt: '2026-02-05',
configuredAt: null,
creatorCount: 0,
sellingPoints: 0,
blacklistWords: 0,
},
{
id: 'brief-003',
projectId: 'proj-003',
projectName: '护肤品秋季活动',
brandName: 'XX护肤品牌',
platform: 'bilibili',
status: 'configured',
uploadedAt: '2025-09-15',
configuredAt: '2025-09-16',
creatorCount: 10,
sellingPoints: 4,
blacklistWords: 8,
},
]
function StatusTag({ status }: { status: string }) {
if (status === 'configured') return <SuccessTag></SuccessTag>
if (status === 'pending') return <WarningTag></WarningTag>
return <PendingTag></PendingTag>
}
function BriefsSkeleton() {
return (
<div className="space-y-6 animate-pulse">
<div className="flex items-center justify-between">
<div>
<div className="h-8 w-40 bg-bg-elevated rounded" />
<div className="h-4 w-56 bg-bg-elevated rounded mt-2" />
</div>
<div className="flex items-center gap-2">
<div className="h-8 w-20 bg-bg-elevated rounded-lg" />
<div className="h-8 w-20 bg-bg-elevated rounded-lg" />
</div>
</div>
<div className="flex items-center gap-4">
<div className="h-10 w-80 bg-bg-elevated rounded-lg" />
<div className="h-10 w-60 bg-bg-elevated rounded-lg" />
</div>
<div className="grid grid-cols-1 gap-4">
{[1, 2, 3].map(i => (
<div key={i} className="h-28 bg-bg-elevated rounded-xl" />
))}
</div>
</div>
)
}
export default function AgencyBriefsPage() {
const [searchQuery, setSearchQuery] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('all')
const [briefs, setBriefs] = useState<BriefItem[]>([])
const [loading, setLoading] = useState(true)
const loadData = useCallback(async () => {
if (USE_MOCK) {
setBriefs(mockBriefs)
setLoading(false)
return
}
try {
// 1. 获取所有项目
const projectsData = await api.listProjects(1, 100)
const projects = projectsData.items
// 2. 对每个项目获取 Brief并行请求
const briefResults = await Promise.allSettled(
projects.map(async (project): Promise<BriefItem> => {
try {
const brief = await api.getBrief(project.id)
const hasBrief = !!(brief.selling_points?.length || brief.blacklist_words?.length || brief.brand_tone)
return {
id: brief.id,
projectId: project.id,
projectName: project.name,
brandName: project.brand_name || '未知品牌',
platform: 'douyin', // 后端暂无 platform 字段,默认值
status: hasBrief ? 'configured' : 'pending',
uploadedAt: project.created_at.split('T')[0],
configuredAt: hasBrief ? brief.updated_at.split('T')[0] : null,
creatorCount: project.task_count || 0,
sellingPoints: brief.selling_points?.length || 0,
blacklistWords: brief.blacklist_words?.length || 0,
}
} catch {
// Brief 不存在,标记为待配置
return {
id: `no-brief-${project.id}`,
projectId: project.id,
projectName: project.name,
brandName: project.brand_name || '未知品牌',
platform: 'douyin',
status: 'pending',
uploadedAt: project.created_at.split('T')[0],
configuredAt: null,
creatorCount: project.task_count || 0,
sellingPoints: 0,
blacklistWords: 0,
}
}
})
)
const items: BriefItem[] = briefResults
.filter((r): r is PromiseFulfilledResult<BriefItem> => r.status === 'fulfilled')
.map(r => r.value)
setBriefs(items)
} catch (err) {
console.error('加载 Brief 列表失败:', err)
setBriefs([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadData()
}, [loadData])
if (loading) {
return <BriefsSkeleton />
}
const filteredBriefs = briefs.filter(brief => {
const matchesSearch = brief.projectName.toLowerCase().includes(searchQuery.toLowerCase()) ||
brief.brandName.toLowerCase().includes(searchQuery.toLowerCase())
const matchesStatus = statusFilter === 'all' || brief.status === statusFilter
return matchesSearch && matchesStatus
})
const pendingCount = briefs.filter(b => b.status === 'pending').length
const configuredCount = briefs.filter(b => b.status === 'configured').length
return (
<div className="space-y-6 min-h-0">
{/* 页面标题 */}
<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</p>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="px-3 py-1.5 bg-yellow-500/20 text-yellow-400 rounded-lg font-medium">
{pendingCount}
</span>
<span className="px-3 py-1.5 bg-accent-green/20 text-accent-green rounded-lg font-medium">
{configuredCount}
</span>
</div>
</div>
{/* 搜索和筛选 */}
<div className="flex items-center gap-4">
<div className="relative flex-1 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 border border-border-subtle rounded-lg bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
/>
</div>
<div className="flex items-center gap-1 p-1 bg-bg-elevated rounded-lg">
<button
type="button"
onClick={() => setStatusFilter('all')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
statusFilter === 'all' ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
}`}
>
</button>
<button
type="button"
onClick={() => setStatusFilter('pending')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
statusFilter === 'pending' ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
}`}
>
</button>
<button
type="button"
onClick={() => setStatusFilter('configured')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
statusFilter === 'configured' ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
}`}
>
</button>
</div>
</div>
{/* Brief 列表 */}
<div className="grid grid-cols-1 gap-4">
{filteredBriefs.map((brief) => {
const platform = getPlatformInfo(brief.platform)
return (
<Link key={brief.id} href={`/agency/briefs/${brief.projectId}`}>
<Card className="hover:border-accent-indigo/50 transition-colors cursor-pointer overflow-hidden">
{/* 平台顶部条 */}
{platform && (
<div className={`px-6 py-2 ${platform.bgColor} border-b ${platform.borderColor} flex items-center gap-2`}>
<span className="text-base">{platform.icon}</span>
<span className={`text-sm font-medium ${platform.textColor}`}>{platform.name}</span>
</div>
)}
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-lg flex items-center justify-center ${
brief.status === 'configured' ? 'bg-accent-green/20' : 'bg-yellow-500/20'
}`}>
{brief.status === 'configured' ? (
<CheckCircle size={24} className="text-accent-green" />
) : (
<AlertTriangle size={24} className="text-yellow-400" />
)}
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-medium text-text-primary">{brief.projectName}</h3>
<StatusTag status={brief.status} />
</div>
<div className="flex items-center gap-4 mt-1 text-sm text-text-secondary">
<span>{brief.brandName}</span>
<span className="flex items-center gap-1">
<Clock size={12} />
{brief.uploadedAt}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-8">
{brief.status === 'configured' && (
<div className="flex items-center gap-6 text-sm">
<div className="text-center">
<div className="text-lg font-bold text-text-primary">{brief.sellingPoints}</div>
<div className="text-text-tertiary"></div>
</div>
<div className="text-center">
<div className="text-lg font-bold text-text-primary">{brief.blacklistWords}</div>
<div className="text-text-tertiary"></div>
</div>
<div className="text-center">
<div className="text-lg font-bold text-text-primary">{brief.creatorCount}</div>
<div className="text-text-tertiary"></div>
</div>
</div>
)}
<Button variant={brief.status === 'pending' ? 'primary' : 'secondary'} size="sm">
{brief.status === 'pending' ? (
<>
<Settings size={14} />
</>
) : (
<>
<ChevronRight size={14} />
</>
)}
</Button>
</div>
</div>
</CardContent>
</Card>
</Link>
)
})}
</div>
{filteredBriefs.length === 0 && (
<div className="text-center py-16">
<FileText size={48} className="mx-auto text-text-tertiary opacity-50 mb-4" />
<p className="text-text-secondary"> Brief</p>
</div>
)}
</div>
)
}