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:
co-authored by
Claude Opus 4.6
parent
58aed5f201
commit
4c9b2f1263
@@ -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}
|
||||
|
||||
@@ -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 数据
|
||||
|
||||
Reference in New Issue
Block a user