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
+216 -16
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
@@ -26,9 +26,14 @@ import {
Save,
Upload,
Trash2,
File
File,
Loader2
} from 'lucide-react'
import { getPlatformInfo } from '@/lib/platforms'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import type { BriefResponse, SellingPoint, BlacklistWord, BriefAttachment } from '@/types/brief'
import type { ProjectResponse } from '@/types/project'
// 文件类型
type BriefFile = {
@@ -39,8 +44,32 @@ type BriefFile = {
uploadedAt: string
}
// 代理商上传的Brief文档(可编辑)
type AgencyFile = {
id: string
name: string
size: string
uploadedAt: string
description?: string
}
// ==================== 视图类型 ====================
interface BrandBriefView {
id: string
projectName: string
brandName: string
platform: string
files: BriefFile[]
brandRules: {
restrictions: string
competitors: string[]
}
}
// ==================== Mock 数据 ====================
// 模拟品牌方 Brief(只读)
const mockBrandBrief = {
const mockBrandBrief: BrandBriefView = {
id: 'brief-001',
projectName: 'XX品牌618推广',
brandName: 'XX护肤品牌',
@@ -58,15 +87,6 @@ const mockBrandBrief = {
},
}
// 代理商上传的Brief文档(可编辑)
type AgencyFile = {
id: string
name: string
size: string
uploadedAt: string
description?: string
}
// 代理商自己的配置(可编辑)
const mockAgencyConfig = {
status: 'configured',
@@ -125,13 +145,50 @@ const platformRules = {
},
}
// ==================== 组件 ====================
function BriefDetailSkeleton() {
return (
<div className="space-y-6 animate-pulse">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-bg-elevated rounded-full" />
<div className="flex-1">
<div className="h-6 w-48 bg-bg-elevated rounded" />
<div className="h-4 w-32 bg-bg-elevated rounded mt-2" />
</div>
<div className="h-10 w-24 bg-bg-elevated rounded-lg" />
<div className="h-10 w-24 bg-bg-elevated rounded-lg" />
</div>
<div className="h-20 bg-bg-elevated rounded-lg" />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 h-48 bg-bg-elevated rounded-xl" />
<div className="h-48 bg-bg-elevated rounded-xl" />
</div>
<div className="h-20 bg-bg-elevated rounded-lg" />
<div className="h-48 bg-bg-elevated rounded-xl" />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<div className="h-40 bg-bg-elevated rounded-xl" />
<div className="h-48 bg-bg-elevated rounded-xl" />
</div>
<div className="h-64 bg-bg-elevated rounded-xl" />
</div>
</div>
)
}
export default function BriefConfigPage() {
const router = useRouter()
const params = useParams()
const toast = useToast()
const projectId = params.id as string
// 加载状态
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
// 品牌方 Brief(只读)
const [brandBrief] = useState(mockBrandBrief)
const [brandBrief, setBrandBrief] = useState(mockBrandBrief)
// 代理商配置(可编辑)
const [agencyConfig, setAgencyConfig] = useState(mockAgencyConfig)
@@ -148,6 +205,94 @@ export default function BriefConfigPage() {
const [isAIParsing, setIsAIParsing] = useState(false)
const [isUploading, setIsUploading] = useState(false)
// 加载数据
const loadData = useCallback(async () => {
if (USE_MOCK) {
// Mock 模式使用默认数据
setLoading(false)
return
}
try {
// 1. 获取项目信息
const project = await api.getProject(projectId)
// 2. 获取 Brief
let brief: BriefResponse | null = null
try {
brief = await api.getBrief(projectId)
} catch {
// Brief 不存在,保持空状态
}
// 映射到品牌方 Brief 视图
const briefFiles: BriefFile[] = brief?.attachments?.map((att, i) => ({
id: att.id || `att-${i}`,
name: att.name,
type: 'brief' as const,
size: att.size || '未知',
uploadedAt: brief!.created_at.split('T')[0],
})) || []
if (brief?.file_name) {
briefFiles.unshift({
id: 'main-file',
name: brief.file_name,
type: 'brief' as const,
size: '未知',
uploadedAt: brief.created_at.split('T')[0],
})
}
setBrandBrief({
id: brief?.id || `no-brief-${projectId}`,
projectName: project.name,
brandName: project.brand_name || '未知品牌',
platform: 'douyin', // 后端暂无 platform 字段
files: briefFiles,
brandRules: {
restrictions: brief?.other_requirements || '暂无限制条件',
competitors: brief?.competitors || [],
},
})
// 映射到代理商配置视图
const hasBrief = !!(brief?.selling_points?.length || brief?.blacklist_words?.length || brief?.brand_tone)
setAgencyConfig({
status: hasBrief ? 'configured' : 'pending',
configuredAt: hasBrief ? (brief!.updated_at.split('T')[0]) : '',
agencyFiles: [], // 后端暂无代理商文档管理
aiParsedContent: {
productName: brief?.brand_tone || '待解析',
targetAudience: '待解析',
contentRequirements: brief?.min_duration && brief?.max_duration
? `视频时长 ${brief.min_duration}-${brief.max_duration}`
: (brief?.other_requirements || '待解析'),
},
sellingPoints: (brief?.selling_points || []).map((sp, i) => ({
id: `sp-${i}`,
content: sp.content,
required: sp.required,
})),
blacklistWords: (brief?.blacklist_words || []).map((bw, i) => ({
id: `bw-${i}`,
word: bw.word,
reason: bw.reason,
})),
})
} catch (err) {
console.error('加载 Brief 详情失败:', err)
toast.error('加载 Brief 详情失败')
} finally {
setLoading(false)
}
}, [projectId, toast])
useEffect(() => {
loadData()
}, [loadData])
const platform = getPlatformInfo(brandBrief.platform)
const rules = platformRules[brandBrief.platform as keyof typeof platformRules] || platformRules.douyin
@@ -180,6 +325,42 @@ export default function BriefConfigPage() {
// 保存配置
const handleSave = async () => {
setIsSaving(true)
if (!USE_MOCK) {
try {
const payload = {
selling_points: agencyConfig.sellingPoints.map(sp => ({
content: sp.content,
required: sp.required,
})),
blacklist_words: agencyConfig.blacklistWords.map(bw => ({
word: bw.word,
reason: bw.reason,
})),
competitors: brandBrief.brandRules.competitors,
brand_tone: agencyConfig.aiParsedContent.productName,
other_requirements: brandBrief.brandRules.restrictions,
}
// 尝试更新,如果 Brief 不存在则创建
try {
await api.updateBrief(projectId, payload)
} catch {
await api.createBrief(projectId, payload)
}
setIsSaving(false)
toast.success('配置已保存!')
return
} catch (err) {
console.error('保存 Brief 失败:', err)
setIsSaving(false)
toast.error('保存配置失败')
return
}
}
// Mock 模式
await new Promise(resolve => setTimeout(resolve, 1000))
setIsSaving(false)
toast.success('配置已保存!')
@@ -263,6 +444,10 @@ export default function BriefConfigPage() {
toast.info(`下载文件: ${file.name}`)
}
if (loading) {
return <BriefDetailSkeleton />
}
return (
<div className="space-y-6">
{/* 顶部导航 */}
@@ -290,7 +475,7 @@ export default function BriefConfigPage() {
{isExporting ? '导出中...' : '导出规则'}
</Button>
<Button onClick={handleSave} disabled={isSaving}>
<Save size={16} />
{isSaving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
{isSaving ? '保存中...' : '保存配置'}
</Button>
</div>
@@ -357,6 +542,12 @@ export default function BriefConfigPage() {
{brandBrief.files.length}
</button>
)}
{brandBrief.files.length === 0 && (
<div className="py-8 text-center">
<FileText size={32} className="mx-auto text-text-tertiary mb-2" />
<p className="text-sm text-text-secondary"> Brief </p>
</div>
)}
</CardContent>
</Card>
@@ -381,6 +572,9 @@ export default function BriefConfigPage() {
{c}
</span>
))}
{brandBrief.brandRules.competitors.length === 0 && (
<span className="text-sm text-text-tertiary"></span>
)}
</div>
</div>
</CardContent>
@@ -609,7 +803,7 @@ export default function BriefConfigPage() {
{agencyConfig.blacklistWords.map((bw) => (
<div key={bw.id} className="flex items-center justify-between p-3 bg-accent-coral/10 rounded-lg border border-accent-coral/30">
<div>
<span className="font-medium text-accent-coral">{bw.word}</span>
<span className="font-medium text-accent-coral">{'\u300C'}{bw.word}{'\u300D'}</span>
<span className="text-xs text-text-tertiary ml-2">{bw.reason}</span>
</div>
<button
@@ -652,7 +846,7 @@ export default function BriefConfigPage() {
</div>
<div className="flex justify-between">
<span className="text-text-secondary"></span>
<span className="text-text-primary">{agencyConfig.configuredAt}</span>
<span className="text-text-primary">{agencyConfig.configuredAt || '-'}</span>
</div>
</CardContent>
</Card>
@@ -705,6 +899,12 @@ export default function BriefConfigPage() {
</div>
</div>
))}
{brandBrief.files.length === 0 && (
<div className="py-12 text-center">
<FileText size={48} className="mx-auto text-text-tertiary mb-4" />
<p className="text-text-secondary"></p>
</div>
)}
</div>
</Modal>
+130 -8
View File
@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
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'
@@ -13,14 +13,35 @@ import {
CheckCircle,
AlertTriangle,
ChevronRight,
Settings
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'
// 模拟 Brief 列表
const mockBriefs = [
// ==================== 本地视图模型 ====================
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',
@@ -33,6 +54,7 @@ const mockBriefs = [
},
{
id: 'brief-002',
projectId: 'proj-002',
projectName: '新品口红系列',
brandName: 'XX美妆品牌',
platform: 'xiaohongshu',
@@ -45,6 +67,7 @@ const mockBriefs = [
},
{
id: 'brief-003',
projectId: 'proj-003',
projectName: '护肤品秋季活动',
brandName: 'XX护肤品牌',
platform: 'bilibili',
@@ -63,19 +86,118 @@ function StatusTag({ status }: { status: string }) {
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 filteredBriefs = mockBriefs.filter(brief => {
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 = mockBriefs.filter(b => b.status === 'pending').length
const configuredCount = mockBriefs.filter(b => b.status === 'configured').length
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">
@@ -143,7 +265,7 @@ export default function AgencyBriefsPage() {
{filteredBriefs.map((brief) => {
const platform = getPlatformInfo(brief.platform)
return (
<Link key={brief.id} href={`/agency/briefs/${brief.id}`}>
<Link key={brief.id} href={`/agency/briefs/${brief.projectId}`}>
<Card className="hover:border-accent-indigo/50 transition-colors cursor-pointer overflow-hidden">
{/* 平台顶部条 */}
{platform && (