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:
co-authored by
Claude Opus 4.6
parent
54eaa54966
commit
a8be7bbca9
@@ -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>
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user