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

537 lines
21 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 } from 'react'
import { useRouter } from 'next/navigation'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import {
ArrowLeft,
Upload,
Calendar,
FileText,
CheckCircle,
AlertCircle,
Search,
Building2,
Loader2,
Trash2,
RotateCcw
} from 'lucide-react'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import { platformOptions } from '@/lib/platforms'
import type { AgencyDetail } from '@/types/organization'
import type { BriefAttachment } from '@/types/brief'
// 单个文件的上传状态
interface UploadFileItem {
id: string
name: string
size: string
rawSize: number
status: 'uploading' | 'success' | 'error'
progress: number
url?: string
error?: string
file?: File // 保留引用用于重试
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return bytes + 'B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'KB'
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + 'MB'
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + 'GB'
}
// ==================== Mock 数据 ====================
const mockAgencies: AgencyDetail[] = [
{ id: 'AG789012', name: '星耀传媒', force_pass_enabled: true },
{ id: 'AG456789', name: '创意无限', force_pass_enabled: false },
{ id: 'AG123456', name: '美妆达人MCN', force_pass_enabled: false },
{ id: 'AG111111', name: '蓝海科技', force_pass_enabled: true },
{ id: 'AG222222', name: '云创网络', force_pass_enabled: false },
{ id: 'AG333333', name: '天府传媒', force_pass_enabled: true },
]
export default function CreateProjectPage() {
const router = useRouter()
const toast = useToast()
const [projectName, setProjectName] = useState('')
const [description, setDescription] = useState('')
const [platform, setPlatform] = useState('douyin')
const [deadline, setDeadline] = useState('')
const [uploadFiles, setUploadFiles] = useState<UploadFileItem[]>([])
const [selectedAgencies, setSelectedAgencies] = useState<string[]>([])
const [isSubmitting, setIsSubmitting] = useState(false)
const [agencySearch, setAgencySearch] = useState('')
const [agencies, setAgencies] = useState<AgencyDetail[]>([])
const [loadingAgencies, setLoadingAgencies] = useState(true)
// 从成功上传的文件中提取 BriefAttachment
const briefFiles: BriefAttachment[] = uploadFiles
.filter(f => f.status === 'success' && f.url)
.map(f => ({ id: f.id, name: f.name, url: f.url!, size: f.size }))
const hasUploading = uploadFiles.some(f => f.status === 'uploading')
useEffect(() => {
const loadAgencies = async () => {
if (USE_MOCK) {
setAgencies(mockAgencies)
setLoadingAgencies(false)
return
}
try {
const data = await api.listBrandAgencies()
setAgencies(data.items)
} catch (err) {
console.error('Failed to load agencies:', err)
toast.error('加载代理商列表失败')
} finally {
setLoadingAgencies(false)
}
}
loadAgencies()
}, [toast])
const filteredAgencies = agencies.filter(agency =>
agencySearch === '' ||
agency.name.toLowerCase().includes(agencySearch.toLowerCase()) ||
agency.id.toLowerCase().includes(agencySearch.toLowerCase())
)
// 上传单个文件(独立跟踪进度)
const uploadSingleFile = async (file: File, fileId: string) => {
if (USE_MOCK) {
// Mock模拟进度
for (let p = 20; p <= 80; p += 20) {
await new Promise(r => setTimeout(r, 300))
setUploadFiles(prev => prev.map(f => f.id === fileId ? { ...f, progress: p } : f))
}
await new Promise(r => setTimeout(r, 300))
setUploadFiles(prev => prev.map(f => f.id === fileId
? { ...f, status: 'success', progress: 100, url: `mock://${file.name}` }
: f
))
toast.success(`${file.name} 上传完成`)
return
}
try {
const result = await api.proxyUpload(file, 'general', (pct) => {
setUploadFiles(prev => prev.map(f => f.id === fileId
? { ...f, progress: Math.min(95, Math.round(pct * 0.95)) }
: f
))
})
setUploadFiles(prev => prev.map(f => f.id === fileId
? { ...f, status: 'success', progress: 100, url: result.url }
: f
))
toast.success(`${file.name} 上传完成`)
} catch (err) {
const msg = err instanceof Error ? err.message : '上传失败'
setUploadFiles(prev => prev.map(f => f.id === fileId
? { ...f, status: 'error', error: msg }
: f
))
toast.error(`${file.name} 上传失败: ${msg}`)
}
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files || files.length === 0) return
const fileList = Array.from(files)
e.target.value = ''
toast.info(`已选择 ${fileList.length} 个文件,开始上传...`)
// 立即添加所有文件到列表uploading 状态)
const newItems: UploadFileItem[] = fileList.map(file => ({
id: `att-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: file.name,
size: formatFileSize(file.size),
rawSize: file.size,
status: 'uploading' as const,
progress: 0,
file,
}))
setUploadFiles(prev => [...prev, ...newItems])
// 并发上传所有文件
newItems.forEach(item => {
uploadSingleFile(item.file!, item.id)
})
}
// 重试失败的上传
const retryUpload = (fileId: string) => {
const item = uploadFiles.find(f => f.id === fileId)
if (!item?.file) return
setUploadFiles(prev => prev.map(f => f.id === fileId
? { ...f, status: 'uploading', progress: 0, error: undefined }
: f
))
uploadSingleFile(item.file, fileId)
}
const removeFile = (id: string) => {
setUploadFiles(prev => prev.filter(f => f.id !== id))
}
const toggleAgency = (agencyId: string) => {
setSelectedAgencies(prev =>
prev.includes(agencyId)
? prev.filter(id => id !== agencyId)
: [...prev, agencyId]
)
}
const handleSubmit = async () => {
if (!projectName.trim() || !deadline || selectedAgencies.length === 0) {
toast.error('请填写完整信息')
return
}
setIsSubmitting(true)
try {
if (USE_MOCK) {
await new Promise(resolve => setTimeout(resolve, 1000))
} else {
const project = await api.createProject({
name: projectName.trim(),
description: description.trim() || undefined,
platform,
deadline,
agency_ids: selectedAgencies,
})
// If brief files were uploaded, create brief with attachments
if (briefFiles.length > 0) {
await api.createBrief(project.id, {
attachments: briefFiles,
})
}
}
toast.success('项目创建成功!')
router.push('/brand')
} catch (err) {
console.error('Failed to create project:', err)
toast.error('创建失败,请重试')
} finally {
setIsSubmitting(false)
}
}
const isValid = projectName.trim() && deadline && selectedAgencies.length > 0
return (
<div className="space-y-6 max-w-4xl">
<div className="flex items-center gap-4">
<button type="button" onClick={() => router.back()} className="p-2 hover:bg-bg-elevated rounded-full">
<ArrowLeft size={20} className="text-text-primary" />
</button>
<h1 className="text-2xl font-bold text-text-primary"></h1>
</div>
<Card>
<CardContent className="p-6 space-y-6">
{/* 项目名称 */}
<div>
<label className="block text-sm font-medium text-text-primary mb-2">
<span className="text-accent-coral">*</span>
</label>
<input
type="text"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
placeholder="例如XX品牌618推广"
className="w-full px-4 py-3 border border-border-subtle rounded-lg bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
/>
</div>
{/* 项目描述 */}
<div>
<label className="block text-sm font-medium text-text-primary mb-2"></label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="简要描述项目目标和要求..."
className="w-full h-24 px-4 py-3 border border-border-subtle rounded-lg bg-bg-elevated text-text-primary resize-none focus:outline-none focus:ring-2 focus:ring-accent-indigo"
/>
</div>
{/* 发布平台 */}
<div>
<label className="block text-sm font-medium text-text-primary mb-2">
<span className="text-accent-coral">*</span>
</label>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{platformOptions.map((p) => {
const isSelected = platform === p.id
return (
<button
key={p.id}
type="button"
onClick={() => setPlatform(p.id)}
className={`flex items-center gap-3 px-4 py-3 rounded-xl border-2 transition-all ${
isSelected
? `${p.borderColor} ${p.bgColor} border-opacity-100`
: 'border-border-subtle hover:border-accent-indigo/30'
}`}
>
<span className="text-xl">{p.icon}</span>
<span className={`font-medium ${isSelected ? p.textColor : 'text-text-secondary'}`}>
{p.name}
</span>
</button>
)
})}
</div>
</div>
{/* 截止日期 */}
<div>
<label className="block text-sm font-medium text-text-primary mb-2">
<span className="text-accent-coral">*</span>
</label>
<div className="relative">
<Calendar size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-text-tertiary" />
<input
type="date"
value={deadline}
onChange={(e) => setDeadline(e.target.value)}
className="w-full pl-12 pr-4 py-3 border border-border-subtle rounded-lg bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
/>
</div>
</div>
{/* Brief 上传 */}
<div>
<label className="block text-sm font-medium text-text-primary mb-2">
Brief
</label>
{/* 上传区域 */}
<label className="border-2 border-dashed border-border-subtle rounded-lg p-6 text-center hover:border-accent-indigo/50 transition-colors cursor-pointer block mb-3">
<Upload size={28} className="mx-auto text-text-tertiary mb-2" />
<p className="text-text-secondary text-sm mb-1">
{uploadFiles.length > 0 ? '继续添加文件' : '点击上传 Brief 文件(可多选)'}
</p>
<p className="text-xs text-text-tertiary"> PDFWordExcel</p>
<input
type="file"
multiple
onChange={handleFileChange}
className="hidden"
/>
</label>
{/* 文件列表(含进度)— 始终显示,空状态也有提示 */}
<div className={`border rounded-lg overflow-hidden ${uploadFiles.length > 0 ? 'border-accent-indigo/40 bg-accent-indigo/5' : 'border-border-subtle'}`}>
<div className={`flex items-center justify-between px-4 py-2.5 border-b ${uploadFiles.length > 0 ? 'bg-accent-indigo/10 border-accent-indigo/20' : 'bg-bg-elevated border-border-subtle'}`}>
<span className="text-sm font-medium text-text-primary flex items-center gap-2">
<FileText size={14} className="text-accent-indigo" />
</span>
{uploadFiles.length > 0 && (
<span className="text-xs text-text-tertiary">
{briefFiles.length}/{uploadFiles.length}
{uploadFiles.some(f => f.status === 'error') && (
<span className="text-accent-coral ml-1">
· {uploadFiles.filter(f => f.status === 'error').length}
</span>
)}
{hasUploading && (
<span className="text-accent-indigo ml-1">
· ...
</span>
)}
</span>
)}
</div>
{uploadFiles.length === 0 ? (
<div className="px-4 py-6 text-center">
<p className="text-sm text-text-tertiary"></p>
</div>
) : (
<div className="divide-y divide-border-subtle">
{uploadFiles.map((file) => (
<div key={file.id} className="px-4 py-3">
<div className="flex items-center gap-3">
{/* 状态图标 */}
{file.status === 'uploading' && (
<Loader2 size={16} className="animate-spin text-accent-indigo flex-shrink-0" />
)}
{file.status === 'success' && (
<CheckCircle size={16} className="text-accent-green flex-shrink-0" />
)}
{file.status === 'error' && (
<AlertCircle size={16} className="text-accent-coral flex-shrink-0" />
)}
{/* 文件图标+文件名 */}
<FileText size={14} className="text-text-tertiary flex-shrink-0" />
<span className={`flex-1 text-sm truncate ${
file.status === 'error' ? 'text-accent-coral' : 'text-text-primary'
}`}>
{file.name}
</span>
{/* 大小/进度文字 */}
<span className="text-xs text-text-tertiary whitespace-nowrap min-w-[48px] text-right">
{file.status === 'uploading'
? `${file.progress}%`
: file.size
}
</span>
{/* 操作按钮 */}
{file.status === 'error' && (
<button
type="button"
onClick={() => retryUpload(file.id)}
className="p-1 rounded hover:bg-bg-elevated text-accent-indigo transition-colors"
title="重试"
>
<RotateCcw size={14} />
</button>
)}
{file.status !== 'uploading' && (
<button
type="button"
onClick={() => removeFile(file.id)}
className="p-1 rounded hover:bg-bg-elevated text-text-tertiary hover:text-accent-coral transition-colors"
title="删除"
>
<Trash2 size={14} />
</button>
)}
</div>
{/* 进度条 */}
{file.status === 'uploading' && (
<div className="mt-2 ml-[30px] h-2 bg-bg-page rounded-full overflow-hidden">
<div
className="h-full bg-accent-indigo rounded-full transition-all duration-300"
style={{ width: `${file.progress}%` }}
/>
</div>
)}
{/* 错误提示 */}
{file.status === 'error' && file.error && (
<p className="mt-1 ml-[30px] text-xs text-accent-coral">{file.error}</p>
)}
</div>
))}
</div>
)}
</div>
</div>
{/* 选择代理商 */}
<div>
<label className="block text-sm font-medium text-text-primary mb-2">
<span className="text-accent-coral">*</span>
<span className="text-text-tertiary font-normal ml-2">
{selectedAgencies.length}
</span>
</label>
<div className="relative mb-4">
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-text-tertiary" />
<input
type="text"
value={agencySearch}
onChange={(e) => setAgencySearch(e.target.value)}
placeholder="搜索代理商名称或ID..."
className="w-full pl-11 pr-4 py-3 border border-border-subtle rounded-lg bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
/>
</div>
{loadingAgencies ? (
<div className="flex items-center justify-center py-8 text-text-tertiary">
<Loader2 size={20} className="animate-spin mr-2" />
...
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 max-h-80 overflow-y-auto">
{filteredAgencies.length > 0 ? (
filteredAgencies.map((agency) => {
const isSelected = selectedAgencies.includes(agency.id)
return (
<button
key={agency.id}
type="button"
onClick={() => toggleAgency(agency.id)}
className={`p-4 rounded-xl border-2 text-left transition-all ${
isSelected
? 'border-accent-indigo bg-accent-indigo/10'
: 'border-border-subtle hover:border-accent-indigo/50'
}`}
>
<div className="flex items-start gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${
isSelected ? 'bg-accent-indigo' : 'bg-accent-indigo/15'
}`}>
{isSelected ? (
<CheckCircle size={20} className="text-white" />
) : (
<Building2 size={20} className="text-accent-indigo" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-text-primary">{agency.name}</span>
<span className="text-xs text-text-tertiary font-mono">{agency.id}</span>
</div>
{agency.contact_name && (
<p className="text-sm text-text-secondary mt-0.5">{agency.contact_name}</p>
)}
</div>
</div>
</button>
)
})
) : (
<div className="col-span-2 text-center py-8 text-text-tertiary">
<Search size={32} className="mx-auto mb-2 opacity-50" />
<p></p>
</div>
)}
</div>
)}
<p className="text-xs text-text-tertiary mt-3">
&ldquo;&rdquo;
</p>
</div>
{/* 操作按钮 */}
<div className="flex items-center justify-end gap-4 pt-4 border-t border-border-subtle">
<Button variant="secondary" onClick={() => router.back()}>
</Button>
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting || hasUploading}>
{isSubmitting ? (
<>
<Loader2 size={16} className="animate-spin" />
...
</>
) : '创建项目'}
</Button>
</div>
</CardContent>
</Card>
</div>
)
}