feat: Brief附件/项目平台/规则AI解析/消息中心修复 + 项目创建通知

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-02-10 19:00:03 +08:00
co-authored by Claude Opus 4.6
parent 58aed5f201
commit 4c9b2f1263
40 changed files with 1479 additions and 360 deletions
+193 -27
View File
@@ -28,12 +28,25 @@ import {
Trash2,
File,
Loader2,
Search
Search,
AlertCircle,
RotateCcw
} from 'lucide-react'
import { getPlatformInfo } from '@/lib/platforms'
import { api } from '@/lib/api'
import { USE_MOCK, useAuth } from '@/contexts/AuthContext'
import type { RuleConflict } from '@/types/rules'
// 单个文件上传状态
interface UploadingFileItem {
id: string
name: string
size: string
status: 'uploading' | 'error'
progress: number
error?: string
file?: File
}
import type { BriefResponse, SellingPoint, BlacklistWord, BriefAttachment } from '@/types/brief'
import type { ProjectResponse } from '@/types/project'
@@ -44,6 +57,7 @@ type BriefFile = {
type: 'brief' | 'rule' | 'reference'
size: string
uploadedAt: string
url?: string
}
// 代理商上传的Brief文档(可编辑)
@@ -53,6 +67,7 @@ type AgencyFile = {
size: string
uploadedAt: string
description?: string
url?: string
}
// ==================== 视图类型 ====================
@@ -147,6 +162,15 @@ const platformRules = {
},
}
// ==================== 工具函数 ====================
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'
}
// ==================== 组件 ====================
function BriefDetailSkeleton() {
@@ -185,6 +209,10 @@ export default function BriefConfigPage() {
const toast = useToast()
const { user } = useAuth()
const projectId = params.id as string
const agencyFileInputRef = useRef<HTMLInputElement>(null)
// 上传中的文件跟踪
const [uploadingFiles, setUploadingFiles] = useState<UploadingFileItem[]>([])
// 加载状态
const [loading, setLoading] = useState(true)
@@ -206,7 +234,7 @@ export default function BriefConfigPage() {
const [isExporting, setIsExporting] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [isAIParsing, setIsAIParsing] = useState(false)
const [isUploading, setIsUploading] = useState(false)
const isUploading = uploadingFiles.some(f => f.status === 'uploading')
// 规则冲突检测
const [isCheckingConflicts, setIsCheckingConflicts] = useState(false)
@@ -310,6 +338,7 @@ export default function BriefConfigPage() {
type: 'brief' as const,
size: att.size || '未知',
uploadedAt: brief!.created_at.split('T')[0],
url: att.url,
})) || []
if (brief?.file_name) {
@@ -319,6 +348,7 @@ export default function BriefConfigPage() {
type: 'brief' as const,
size: '未知',
uploadedAt: brief.created_at.split('T')[0],
url: brief.file_url || undefined,
})
}
@@ -340,7 +370,13 @@ export default function BriefConfigPage() {
setAgencyConfig({
status: hasBrief ? 'configured' : 'pending',
configuredAt: hasBrief ? (brief!.updated_at.split('T')[0]) : '',
agencyFiles: [], // 后端暂无代理商文档管理
agencyFiles: (brief?.agency_attachments || []).map((att: any) => ({
id: att.id || `af-${Math.random().toString(36).slice(2, 6)}`,
name: att.name,
size: att.size || '未知',
uploadedAt: brief!.updated_at?.split('T')[0] || '',
url: att.url,
})),
aiParsedContent: {
productName: brief?.brand_tone || '待解析',
targetAudience: '待解析',
@@ -375,8 +411,17 @@ export default function BriefConfigPage() {
const rules = platformRules[brandBrief.platform as keyof typeof platformRules] || platformRules.douyin
// 下载文件
const handleDownload = (file: BriefFile) => {
toast.info(`下载文件: ${file.name}`)
const handleDownload = async (file: BriefFile) => {
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('获取下载链接失败')
}
}
// 预览文件
@@ -418,6 +463,12 @@ export default function BriefConfigPage() {
competitors: brandBrief.brandRules.competitors,
brand_tone: agencyConfig.aiParsedContent.productName,
other_requirements: brandBrief.brandRules.restrictions,
agency_attachments: agencyConfig.agencyFiles.map(f => ({
id: f.id,
name: f.name,
url: f.url || '',
size: f.size,
})),
}
// 尝试更新,如果 Brief 不存在则创建
@@ -487,24 +538,81 @@ export default function BriefConfigPage() {
}))
}
// 代理商文档操作
const handleUploadAgencyFile = async () => {
setIsUploading(true)
// 模拟上传
await new Promise(resolve => setTimeout(resolve, 1500))
const newFile: AgencyFile = {
id: `af${Date.now()}`,
name: '新上传文档.pdf',
size: '1.2MB',
uploadedAt: new Date().toISOString().split('T')[0],
description: '新上传的文档'
// 上传单个代理商文
const uploadSingleAgencyFile = async (file: File, fileId: string) => {
if (USE_MOCK) {
for (let p = 20; p <= 80; p += 20) {
await new Promise(r => setTimeout(r, 300))
setUploadingFiles(prev => prev.map(f => f.id === fileId ? { ...f, progress: p } : f))
}
await new Promise(r => setTimeout(r, 300))
const newFile: AgencyFile = {
id: fileId, name: file.name, size: formatFileSize(file.size),
uploadedAt: new Date().toISOString().split('T')[0],
}
setAgencyConfig(prev => ({ ...prev, agencyFiles: [...prev.agencyFiles, newFile] }))
setUploadingFiles(prev => prev.filter(f => f.id !== fileId))
return
}
setAgencyConfig(prev => ({
...prev,
agencyFiles: [...prev.agencyFiles, newFile]
try {
const result = await api.proxyUpload(file, 'general', (pct) => {
setUploadingFiles(prev => prev.map(f => f.id === fileId
? { ...f, progress: Math.min(95, Math.round(pct * 0.95)) }
: f
))
})
const newFile: AgencyFile = {
id: fileId, name: file.name, size: formatFileSize(file.size),
uploadedAt: new Date().toISOString().split('T')[0], url: result.url,
}
setAgencyConfig(prev => ({ ...prev, agencyFiles: [...prev.agencyFiles, newFile] }))
setUploadingFiles(prev => prev.filter(f => f.id !== fileId))
} catch (err) {
const msg = err instanceof Error ? err.message : '上传失败'
setUploadingFiles(prev => prev.map(f => f.id === fileId
? { ...f, status: 'error', error: msg }
: f
))
}
}
const retryAgencyFileUpload = (fileId: string) => {
const item = uploadingFiles.find(f => f.id === fileId)
if (!item?.file) return
setUploadingFiles(prev => prev.map(f => f.id === fileId
? { ...f, status: 'uploading', progress: 0, error: undefined }
: f
))
uploadSingleAgencyFile(item.file, fileId)
}
const removeUploadingFile = (id: string) => {
setUploadingFiles(prev => prev.filter(f => f.id !== id))
}
// 代理商文档操作
const handleUploadAgencyFile = (e?: React.ChangeEvent<HTMLInputElement>) => {
if (!e) {
agencyFileInputRef.current?.click()
return
}
const files = e.target.files
if (!files || files.length === 0) return
const fileList = Array.from(files)
e.target.value = ''
const newItems: UploadingFileItem[] = fileList.map(file => ({
id: `af-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: file.name,
size: formatFileSize(file.size),
status: 'uploading' as const,
progress: 0,
file,
}))
setIsUploading(false)
toast.success('文档上传成功!')
setUploadingFiles(prev => [...prev, ...newItems])
newItems.forEach(item => uploadSingleAgencyFile(item.file!, item.id))
}
const removeAgencyFile = (id: string) => {
@@ -518,8 +626,17 @@ export default function BriefConfigPage() {
setPreviewAgencyFile(file)
}
const handleDownloadAgencyFile = (file: AgencyFile) => {
toast.info(`下载文件: ${file.name}`)
const handleDownloadAgencyFile = async (file: AgencyFile) => {
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('获取下载链接失败')
}
}
if (loading) {
@@ -721,7 +838,7 @@ export default function BriefConfigPage() {
<Eye size={14} />
</Button>
<Button size="sm" onClick={handleUploadAgencyFile} disabled={isUploading}>
<Button size="sm" onClick={() => handleUploadAgencyFile()} disabled={isUploading}>
<Upload size={14} />
{isUploading ? '上传中...' : '上传文档'}
</Button>
@@ -759,11 +876,51 @@ export default function BriefConfigPage() {
</div>
</div>
))}
{/* 上传中/失败的文件 */}
{uploadingFiles.map((file) => (
<div key={file.id} className="p-4 rounded-lg border border-accent-indigo/20 bg-accent-indigo/5">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-accent-indigo/15 flex items-center justify-center flex-shrink-0">
{file.status === 'uploading'
? <Loader2 size={20} className="animate-spin text-accent-indigo" />
: <AlertCircle size={20} className="text-accent-coral" />
}
</div>
<div className="flex-1 min-w-0">
<p className={`font-medium text-sm truncate ${file.status === 'error' ? 'text-accent-coral' : 'text-text-primary'}`}>
{file.name}
</p>
<p className="text-xs text-text-tertiary mt-0.5">
{file.status === 'uploading' ? `${file.progress}% · ${file.size}` : file.size}
</p>
{file.status === 'uploading' && (
<div className="mt-2 h-1.5 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 text-xs text-accent-coral">{file.error}</p>
)}
</div>
</div>
{file.status === 'error' && (
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border-subtle">
<Button variant="ghost" size="sm" onClick={() => retryAgencyFileUpload(file.id)} className="flex-1">
<RotateCcw size={14} />
</Button>
<Button variant="ghost" size="sm" onClick={() => removeUploadingFile(file.id)} className="text-accent-coral hover:text-accent-coral">
<Trash2 size={14} />
</Button>
</div>
)}
</div>
))}
{/* 上传占位卡片 */}
<button
type="button"
onClick={handleUploadAgencyFile}
disabled={isUploading}
onClick={() => handleUploadAgencyFile()}
className="p-4 rounded-lg border-2 border-dashed border-border-subtle hover:border-accent-indigo/50 transition-colors flex flex-col items-center justify-center gap-2 min-h-[140px]"
>
<div className="w-10 h-10 rounded-full bg-bg-elevated flex items-center justify-center">
@@ -1060,7 +1217,7 @@ export default function BriefConfigPage() {
<p className="text-sm text-text-secondary">
</p>
<Button size="sm" onClick={handleUploadAgencyFile} disabled={isUploading}>
<Button size="sm" onClick={() => handleUploadAgencyFile()} disabled={isUploading}>
<Upload size={14} />
{isUploading ? '上传中...' : '上传文档'}
</Button>
@@ -1136,6 +1293,15 @@ export default function BriefConfigPage() {
</div>
</Modal>
{/* 隐藏的文件上传 input */}
<input
ref={agencyFileInputRef}
type="file"
multiple
onChange={handleUploadAgencyFile}
className="hidden"
/>
{/* 规则冲突检测结果弹窗 */}
<Modal
isOpen={showConflictModal}
+30 -13
View File
@@ -45,6 +45,11 @@ type MessageType =
| 'task_deadline' // 任务截止提醒
| 'brand_brief_updated' // 品牌方更新了Brief
| 'system_notice' // 系统通知
| 'new_task' // 新任务
| 'pass' // 审核通过
| 'reject' // 审核驳回
| 'force_pass' // 强制通过
| 'approve' // 审核批准
interface Message {
id: string
@@ -299,19 +304,31 @@ export default function AgencyMessagesPage() {
}
try {
const res = await api.getMessages({ page: 1, page_size: 50 })
const mapped: Message[] = res.items.map(item => ({
id: item.id,
type: (item.type || 'system_notice') as MessageType,
title: item.title,
content: item.content,
time: item.created_at ? new Date(item.created_at).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '',
read: item.is_read,
icon: Bell,
iconColor: 'text-text-secondary',
bgColor: 'bg-bg-elevated',
taskId: item.related_task_id || undefined,
projectId: item.related_project_id || undefined,
}))
const typeIconMap: Record<string, { icon: typeof Bell; iconColor: string; bgColor: string }> = {
new_task: { icon: FileText, iconColor: 'text-accent-indigo', bgColor: 'bg-accent-indigo/20' },
pass: { icon: CheckCircle, iconColor: 'text-accent-green', bgColor: 'bg-accent-green/20' },
approve: { icon: CheckCircle, iconColor: 'text-accent-green', bgColor: 'bg-accent-green/20' },
reject: { icon: XCircle, iconColor: 'text-accent-coral', bgColor: 'bg-accent-coral/20' },
force_pass: { icon: CheckCircle, iconColor: 'text-accent-amber', bgColor: 'bg-accent-amber/20' },
system_notice: { icon: Bell, iconColor: 'text-text-secondary', bgColor: 'bg-bg-elevated' },
}
const defaultIcon = { icon: Bell, iconColor: 'text-text-secondary', bgColor: 'bg-bg-elevated' }
const mapped: Message[] = res.items.map(item => {
const iconCfg = typeIconMap[item.type] || defaultIcon
return {
id: item.id,
type: (item.type || 'system_notice') as MessageType,
title: item.title,
content: item.content,
time: item.created_at ? new Date(item.created_at).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '',
read: item.is_read,
icon: iconCfg.icon,
iconColor: iconCfg.iconColor,
bgColor: iconCfg.bgColor,
taskId: item.related_task_id || undefined,
projectId: item.related_project_id || undefined,
}
})
setMessages(mapped)
} catch {
// 加载失败保持 mock 数据