Your Name 4c9b2f1263 feat: Brief附件/项目平台/规则AI解析/消息中心修复 + 项目创建通知
- Brief 支持代理商附件上传 (迁移 007)
- 项目新增 platform 字段 (迁移 008),前端创建/展示平台信息
- 修复 AI 规则解析:处理中文引号导致 JSON 解析失败的问题
- 修复消息中心崩溃:补全后端消息类型映射 + fallback 保护
- 项目创建时自动发送消息通知
- .gitignore 排除 backend/data/ 数据库文件

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 19:00:03 +08:00

511 lines
20 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 { useRouter, useParams } from 'next/navigation'
import { useToast } from '@/components/ui/Toast'
import {
ArrowLeft,
FileText,
Download,
Eye,
Target,
Ban,
File,
Building2,
Calendar,
Clock,
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 = {
id: string
name: string
size: string
uploadedAt: string
description?: string
url?: 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',
taskName: 'XX品牌618推广',
agencyName: '星辰传媒',
brandName: 'XX护肤品牌',
deadline: '2026-06-18',
createdAt: '2026-02-08',
}
// 模拟代理商Brief数据
const mockAgencyBrief = {
files: [
{ id: 'af1', name: '达人拍摄指南.pdf', size: '1.5MB', uploadedAt: '2026-02-02', description: '详细的拍摄流程和注意事项' },
{ id: 'af2', name: '产品卖点话术.docx', size: '800KB', uploadedAt: '2026-02-02', description: '推荐使用的话术和表达方式' },
{ id: 'af3', name: '品牌视觉参考.pdf', size: '3.2MB', uploadedAt: '2026-02-02', description: '视觉风格和拍摄参考示例' },
{ id: 'af4', name: '产品素材包.zip', size: '15.6MB', uploadedAt: '2026-02-02', description: '产品图片、视频素材等' },
] as AgencyBriefFile[],
sellingPoints: [
{ id: 'sp1', content: 'SPF50+ PA++++', required: true },
{ id: 'sp2', content: '轻薄质地,不油腻', required: true },
{ id: 'sp3', content: '延展性好,易推开', required: false },
{ id: 'sp4', content: '适合敏感肌', required: false },
{ id: 'sp5', content: '夏日必备防晒', required: true },
],
blacklistWords: [
{ id: 'bw1', word: '最好', reason: '绝对化用语' },
{ id: 'bw2', word: '第一', reason: '绝对化用语' },
{ id: 'bw3', word: '神器', reason: '夸大宣传' },
{ id: 'bw4', word: '完美', reason: '绝对化用语' },
{ id: 'bw5', word: '100%', reason: '虚假宣传' },
],
contentRequirements: [
'视频时长60-90秒',
'需展示产品质地和使用效果',
'需在户外或阳光下拍摄',
'需提及产品核心卖点',
],
}
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 {
// 优先显示代理商上传的文档,没有则降级到品牌方附件
const agencyAtts = brief.agency_attachments ?? []
const brandAtts = brief.attachments ?? []
const sourceAtts = agencyAtts.length > 0 ? agencyAtts : brandAtts
const files: AgencyBriefFile[] = sourceAtts.map((att, idx) => ({
id: att.id || `att-${idx}`,
name: att.name,
size: att.size || '',
uploadedAt: brief.updated_at?.split('T')[0] || '',
description: undefined,
url: att.url,
}))
// 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 = async (file: AgencyBriefFile) => {
if (USE_MOCK || !file.url) {
toast.info(`下载文件: ${file.name}`)
return
}
try {
const signedUrl = await api.getSignedUrl(file.url)
window.open(signedUrl, '_blank')
} catch {
toast.error('获取下载链接失败')
}
}
const handleDownloadAll = () => {
if (!viewModel) return
viewModel.files.forEach(f => handleDownload(f))
}
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">
<div className="flex flex-col gap-6 h-full">
{/* 顶部导航 */}
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-3 mb-1">
<button
type="button"
onClick={() => router.back()}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-bg-elevated text-text-secondary text-sm hover:bg-bg-card transition-colors"
>
<ArrowLeft className="w-4 h-4" />
</button>
</div>
<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}`)}>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
{/* 任务基本信息 */}
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
<h3 className="text-base font-semibold text-text-primary mb-4"></h3>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-purple-500/15 flex items-center justify-center">
<Building2 className="w-5 h-5 text-purple-400" />
</div>
<div>
<p className="text-xs text-text-tertiary"></p>
<p className="text-sm font-medium text-text-primary">{viewModel.agencyName}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-accent-indigo/15 flex items-center justify-center">
<Building2 className="w-5 h-5 text-accent-indigo" />
</div>
<div>
<p className="text-xs text-text-tertiary"></p>
<p className="text-sm font-medium text-text-primary">{viewModel.brandName}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-accent-green/15 flex items-center justify-center">
<Calendar className="w-5 h-5 text-accent-green" />
</div>
<div>
<p className="text-xs text-text-tertiary"></p>
<p className="text-sm font-medium text-text-primary">{viewModel.createdAt}</p>
</div>
</div>
{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>
</div>
{/* 主要内容区域 - 可滚动 */}
<div className="flex-1 overflow-y-auto space-y-6">
{/* Brief文档列表 */}
{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>
)}
{/* 内容要求 */}
{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>
)}
{/* 卖点要求 */}
{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>
)}
{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>
)}
{/* 违禁词 */}
{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 justify-center py-4">
<Button size="lg" onClick={() => router.push(`/creator/task/${params.id}`)}>
<ChevronRight className="w-5 h-5" />
</Button>
</div>
</div>
</div>
{/* 文件预览弹窗 */}
<Modal
isOpen={!!previewFile}
onClose={() => setPreviewFile(null)}
title={previewFile?.name || '文件预览'}
size="lg"
>
<div className="space-y-4">
<div className="aspect-[4/3] bg-bg-elevated rounded-lg flex items-center justify-center">
<div className="text-center">
<FileText className="w-12 h-12 mx-auto text-accent-indigo mb-4" />
<p className="text-text-secondary"></p>
<p className="text-xs text-text-tertiary mt-1"></p>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setPreviewFile(null)}>
</Button>
{previewFile && (
<Button onClick={() => handleDownload(previewFile)}>
<Download className="w-4 h-4" />
</Button>
)}
</div>
</div>
</Modal>
</ResponsiveLayout>
)
}