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 数据
+9 -1
View File
@@ -45,6 +45,10 @@ type MessageType =
| 'brief_config_updated' // 代理商更新了Brief配置
| 'batch_review_done' // 批量审核完成
| 'system_notice' // 系统通知
| 'new_task' // 新任务分配
| 'pass' // 审核通过
| 'reject' // 审核驳回
| 'approve' // 审核批准
type Message = {
id: string
@@ -80,6 +84,10 @@ const messageConfig: Record<MessageType, {
brief_config_updated: { icon: FileText, iconColor: 'text-accent-indigo', bgColor: 'bg-accent-indigo/20' },
batch_review_done: { icon: CheckCircle, iconColor: 'text-accent-green', bgColor: 'bg-accent-green/20' },
system_notice: { icon: Bell, iconColor: 'text-text-secondary', bgColor: 'bg-bg-elevated' },
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' },
reject: { icon: XCircle, iconColor: 'text-accent-coral', bgColor: 'bg-accent-coral/20' },
approve: { icon: CheckCircle, iconColor: 'text-accent-green', bgColor: 'bg-accent-green/20' },
}
// 模拟消息数据
@@ -412,7 +420,7 @@ export default function BrandMessagesPage() {
{/* 消息列表 */}
<div className="space-y-3">
{filteredMessages.map((message) => {
const config = messageConfig[message.type]
const config = messageConfig[message.type] || messageConfig.system_notice
const Icon = config.icon
const platform = message.platform ? getPlatformInfo(message.platform) : null
+21 -6
View File
@@ -22,28 +22,29 @@ import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import { useSSE } from '@/contexts/SSEContext'
import { useToast } from '@/components/ui/Toast'
import { getPlatformInfo } from '@/lib/platforms'
import type { ProjectResponse } from '@/types/project'
// ==================== Mock 数据 ====================
const mockProjects: ProjectResponse[] = [
{
id: 'proj-001', name: 'XX品牌618推广', brand_id: 'br-001', brand_name: 'XX品牌',
status: 'active', deadline: '2026-06-18', agencies: [],
platform: 'douyin', status: 'active', deadline: '2026-06-18', agencies: [],
task_count: 20, created_at: '2026-02-01T00:00:00Z', updated_at: '2026-02-05T00:00:00Z',
},
{
id: 'proj-002', name: '新品口红系列', brand_id: 'br-001', brand_name: 'XX品牌',
status: 'active', deadline: '2026-03-15', agencies: [],
platform: 'xiaohongshu', status: 'active', deadline: '2026-03-15', agencies: [],
task_count: 12, created_at: '2026-01-15T00:00:00Z', updated_at: '2026-02-01T00:00:00Z',
},
{
id: 'proj-003', name: '护肤品秋季活动', brand_id: 'br-001', brand_name: 'XX品牌',
status: 'completed', deadline: '2025-11-30', agencies: [],
platform: 'bilibili', status: 'completed', deadline: '2025-11-30', agencies: [],
task_count: 15, created_at: '2025-08-01T00:00:00Z', updated_at: '2025-11-30T00:00:00Z',
},
{
id: 'proj-004', name: '双11预热活动', brand_id: 'br-001', brand_name: 'XX品牌',
status: 'active', deadline: '2026-11-11', agencies: [],
platform: 'kuaishou', status: 'active', deadline: '2026-11-11', agencies: [],
task_count: 18, created_at: '2026-01-10T00:00:00Z', updated_at: '2026-02-04T00:00:00Z',
},
]
@@ -58,11 +59,25 @@ function StatusTag({ status }: { status: string }) {
}
function ProjectCard({ project, onEditDeadline }: { project: ProjectResponse; onEditDeadline: (project: ProjectResponse) => void }) {
const platformInfo = project.platform ? getPlatformInfo(project.platform) : null
return (
<Link href={`/brand/projects/${project.id}`}>
<Card className="hover:border-accent-indigo/50 transition-colors cursor-pointer h-full overflow-hidden">
<div className="px-6 py-2 bg-accent-indigo/10 border-b border-accent-indigo/20 flex items-center justify-between">
<span className="text-sm font-medium text-accent-indigo">{project.brand_name || '品牌项目'}</span>
<div className={`px-6 py-2 border-b flex items-center justify-between ${
platformInfo
? `${platformInfo.bgColor} ${platformInfo.borderColor}`
: 'bg-accent-indigo/10 border-accent-indigo/20'
}`}>
<span className={`text-sm font-medium flex items-center gap-1.5 ${
platformInfo ? platformInfo.textColor : 'text-accent-indigo'
}`}>
{platformInfo ? (
<><span>{platformInfo.icon}</span>{platformInfo.name}</>
) : (
project.brand_name || '品牌项目'
)}
</span>
<StatusTag status={project.status} />
</div>
<CardContent className="p-6 space-y-4">
+174 -54
View File
@@ -13,6 +13,7 @@ import {
Plus,
Trash2,
AlertTriangle,
AlertCircle,
CheckCircle,
Bot,
Users,
@@ -21,15 +22,27 @@ import {
ChevronDown,
ChevronUp,
Loader2,
Search
Search,
RotateCcw
} from 'lucide-react'
import { Modal } from '@/components/ui/Modal'
import { api } from '@/lib/api'
import { USE_MOCK, useAuth } from '@/contexts/AuthContext'
import type { RuleConflict } from '@/types/rules'
import { useOSSUpload } from '@/hooks/useOSSUpload'
import type { BriefResponse, BriefCreateRequest, SellingPoint, BlacklistWord, BriefAttachment } from '@/types/brief'
// 单个文件的上传状态
interface UploadFileItem {
id: string
name: string
size: string
status: 'uploading' | 'success' | 'error'
progress: number
url?: string
error?: string
file?: File
}
// ==================== Mock 数据 ====================
const mockBrief: BriefResponse = {
id: 'bf-001',
@@ -84,6 +97,13 @@ const mockRules = {
},
}
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'
}
// 严格程度选项
const strictnessOptions = [
{ value: 'low', label: '宽松', description: '仅检测明显违规内容' },
@@ -114,7 +134,9 @@ export default function ProjectConfigPage() {
const toast = useToast()
const { user } = useAuth()
const projectId = params.id as string
const { upload, isUploading, progress: uploadProgress } = useOSSUpload('general')
// 附件上传跟踪
const [uploadingFiles, setUploadingFiles] = useState<UploadFileItem[]>([])
// Brief state
const [briefExists, setBriefExists] = useState(false)
@@ -334,32 +356,71 @@ export default function ProjectConfigPage() {
setCompetitors(competitors.filter(c => c !== name))
}
// Attachment upload
const handleAttachmentUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// 上传单个附件(独立跟踪进度)
const uploadSingleAttachment = async (file: File, fileId: string) => {
if (USE_MOCK) {
setAttachments([...attachments, {
id: `att-${Date.now()}`,
name: file.name,
url: `mock://${file.name}`,
}])
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 att: BriefAttachment = { id: fileId, name: file.name, url: `mock://${file.name}`, size: formatFileSize(file.size) }
setAttachments(prev => [...prev, att])
setUploadingFiles(prev => prev.filter(f => f.id !== fileId))
return
}
try {
const result = await upload(file)
setAttachments([...attachments, {
id: `att-${Date.now()}`,
name: file.name,
url: result.url,
}])
} catch {
toast.error('文件上传失败')
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 att: BriefAttachment = { id: fileId, name: file.name, url: result.url, size: formatFileSize(file.size) }
setAttachments(prev => [...prev, att])
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 handleAttachmentUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files || files.length === 0) return
const fileList = Array.from(files)
e.target.value = ''
const newItems: UploadFileItem[] = fileList.map(file => ({
id: `att-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: file.name,
size: formatFileSize(file.size),
status: 'uploading' as const,
progress: 0,
file,
}))
setUploadingFiles(prev => [...prev, ...newItems])
newItems.forEach(item => uploadSingleAttachment(item.file!, item.id))
}
const retryAttachmentUpload = (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
))
uploadSingleAttachment(item.file, fileId)
}
const removeUploadingFile = (id: string) => {
setUploadingFiles(prev => prev.filter(f => f.id !== id))
}
const removeAttachment = (id: string) => {
setAttachments(attachments.filter(a => a.id !== id))
}
@@ -629,40 +690,99 @@ export default function ProjectConfigPage() {
{/* 参考资料 */}
<div>
<label className="text-sm text-text-secondary mb-2 block"></label>
<div className="space-y-2">
{attachments.map((att) => (
<div key={att.id} className="flex items-center gap-3 p-3 rounded-lg bg-bg-elevated">
<FileText size={16} className="text-accent-indigo" />
<span className="flex-1 text-text-primary">{att.name}</span>
{att.size && <span className="text-xs text-text-tertiary">{att.size}</span>}
<button
type="button"
onClick={() => removeAttachment(att.id)}
className="p-1 rounded hover:bg-bg-page text-text-tertiary hover:text-accent-coral transition-colors"
>
<Trash2 size={14} />
</button>
<label className="flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg border border-dashed border-border-subtle bg-bg-elevated text-text-primary hover:border-accent-indigo/50 hover:bg-bg-page transition-colors cursor-pointer w-full text-sm mb-3">
<Upload size={16} className="text-accent-indigo" />
<input
type="file"
multiple
onChange={handleAttachmentUpload}
className="hidden"
/>
</label>
{/* 文件列表 */}
<div className="border border-border-subtle rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 bg-bg-elevated border-b border-border-subtle">
<span className="text-xs font-medium text-text-secondary flex items-center gap-1.5">
<FileText size={12} className="text-accent-indigo" />
</span>
<span className="text-xs text-text-tertiary">
{attachments.length + uploadingFiles.filter(f => f.status === 'uploading').length}
{uploadingFiles.some(f => f.status === 'uploading') && (
<span className="text-accent-indigo ml-1">· </span>
)}
</span>
</div>
{attachments.length === 0 && uploadingFiles.length === 0 ? (
<div className="px-4 py-5 text-center">
<p className="text-xs text-text-tertiary"></p>
</div>
))}
<label className="flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg border border-border-subtle bg-bg-elevated text-text-primary hover:bg-bg-page transition-colors cursor-pointer w-full text-sm">
{isUploading ? (
<>
<Loader2 size={16} className="animate-spin" />
{uploadProgress}%
</>
) : (
<>
<Upload size={16} />
</>
)}
<input
type="file"
onChange={handleAttachmentUpload}
className="hidden"
disabled={isUploading}
/>
</label>
) : (
<div className="divide-y divide-border-subtle">
{/* 已完成的文件 */}
{attachments.map((att) => (
<div key={att.id} className="flex items-center gap-3 px-4 py-2.5">
<CheckCircle size={14} className="text-accent-green flex-shrink-0" />
<FileText size={14} className="text-text-tertiary flex-shrink-0" />
<span className="flex-1 text-sm text-text-primary truncate">{att.name}</span>
{att.size && <span className="text-xs text-text-tertiary">{att.size}</span>}
<button
type="button"
onClick={() => removeAttachment(att.id)}
className="p-1 rounded hover:bg-bg-elevated text-text-tertiary hover:text-accent-coral transition-colors"
>
<Trash2 size={14} />
</button>
</div>
))}
{/* 上传中/失败的文件 */}
{uploadingFiles.map((file) => (
<div key={file.id} className="px-4 py-2.5">
<div className="flex items-center gap-3">
{file.status === 'uploading' && (
<Loader2 size={14} className="animate-spin text-accent-indigo flex-shrink-0" />
)}
{file.status === 'error' && (
<AlertCircle size={14} 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-[40px] text-right">
{file.status === 'uploading' ? `${file.progress}%` : file.size}
</span>
{file.status === 'error' && (
<button type="button" onClick={() => retryAttachmentUpload(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={() => removeUploadingFile(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-1.5 ml-[28px] 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-[28px] text-xs text-accent-coral">{file.error}</p>
)}
</div>
))}
</div>
)}
</div>
</div>
</CardContent>
+263 -54
View File
@@ -12,17 +12,38 @@ import {
Calendar,
FileText,
CheckCircle,
X,
Users,
AlertCircle,
Search,
Building2,
Check,
Loader2
Loader2,
Trash2,
RotateCcw
} from 'lucide-react'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import { useOSSUpload } from '@/hooks/useOSSUpload'
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[] = [
@@ -37,19 +58,25 @@ const mockAgencies: AgencyDetail[] = [
export default function CreateProjectPage() {
const router = useRouter()
const toast = useToast()
const { upload, isUploading, progress: uploadProgress } = useOSSUpload('general')
const [projectName, setProjectName] = useState('')
const [description, setDescription] = useState('')
const [platform, setPlatform] = useState('douyin')
const [deadline, setDeadline] = useState('')
const [briefFile, setBriefFile] = useState<File | null>(null)
const [briefFileUrl, setBriefFileUrl] = useState<string | null>(null)
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) {
@@ -76,22 +103,85 @@ export default function CreateProjectPage() {
agency.id.toLowerCase().includes(agencySearch.toLowerCase())
)
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setBriefFile(file)
if (!USE_MOCK) {
try {
const result = await upload(file)
setBriefFileUrl(result.url)
} catch (err) {
toast.error('文件上传失败')
setBriefFile(null)
// 上传单个文件(独立跟踪进度)
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))
}
} else {
setBriefFileUrl('mock://brief-file.pdf')
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) => {
@@ -116,15 +206,15 @@ export default function CreateProjectPage() {
const project = await api.createProject({
name: projectName.trim(),
description: description.trim() || undefined,
platform,
deadline,
agency_ids: selectedAgencies,
})
// If brief file was uploaded, create brief
if (briefFileUrl && briefFile) {
// If brief files were uploaded, create brief with attachments
if (briefFiles.length > 0) {
await api.createBrief(project.id, {
file_url: briefFileUrl,
file_name: briefFile.name,
attachments: briefFiles,
})
}
}
@@ -177,6 +267,35 @@ export default function CreateProjectPage() {
/>
</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">
@@ -195,35 +314,125 @@ export default function CreateProjectPage() {
{/* Brief 上传 */}
<div>
<label className="block text-sm font-medium text-text-primary mb-2"> Brief</label>
<div className="border-2 border-dashed border-border-subtle rounded-lg p-8 text-center hover:border-accent-indigo/50 transition-colors">
{briefFile ? (
<div className="flex items-center justify-center gap-3">
<FileText size={24} className="text-accent-indigo" />
<span className="text-text-primary">{briefFile.name}</span>
{isUploading && (
<span className="text-xs text-text-tertiary">{uploadProgress}%</span>
)}
<button
type="button"
onClick={() => { setBriefFile(null); setBriefFileUrl(null) }}
className="p-1 hover:bg-bg-elevated rounded-full"
>
<X size={16} className="text-text-tertiary" />
</button>
<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>
) : (
<label className="cursor-pointer">
<Upload size={32} className="mx-auto text-text-tertiary mb-3" />
<p className="text-text-secondary mb-1"> Brief </p>
<p className="text-xs text-text-tertiary"> PDFWordExcel </p>
<input
type="file"
accept=".pdf,.doc,.docx,.xls,.xlsx"
onChange={handleFileChange}
className="hidden"
/>
</label>
<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>
@@ -311,7 +520,7 @@ export default function CreateProjectPage() {
<Button variant="secondary" onClick={() => router.back()}>
</Button>
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting || isUploading}>
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting || hasUploading}>
{isSubmitting ? (
<>
<Loader2 size={16} className="animate-spin" />
+12 -4
View File
@@ -8,7 +8,7 @@ import { Modal } from '@/components/ui/Modal'
import { useToast } from '@/components/ui/Toast'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import { useOSSUpload } from '@/hooks/useOSSUpload'
// upload via api.proxyUpload directly
import type {
ForbiddenWordResponse,
CompetitorResponse,
@@ -192,7 +192,8 @@ function ListSkeleton({ count = 3 }: { count?: number }) {
export default function RulesPage() {
const toast = useToast()
const fileInputRef = useRef<HTMLInputElement>(null)
const { upload: ossUpload, isUploading: isOssUploading, progress: ossProgress } = useOSSUpload('rules')
const [isOssUploading, setIsOssUploading] = useState(false)
const [ossProgress, setOssProgress] = useState(0)
// Tab 选择
const [activeTab, setActiveTab] = useState<'platforms' | 'forbidden' | 'competitors' | 'whitelist'>('platforms')
@@ -337,8 +338,14 @@ export default function RulesPage() {
return
}
// 真实模式: 上传到 TOS
const uploadResult = await ossUpload(uploadFile)
// 真实模式: 上传到 TOS (通过后端代理)
setIsOssUploading(true)
setOssProgress(0)
const uploadResult = await api.proxyUpload(uploadFile, 'rules', (pct) => {
setOssProgress(Math.min(95, Math.round(pct * 0.95)))
})
setOssProgress(100)
setIsOssUploading(false)
documentUrl = uploadResult.url
// 调用 AI 解析
@@ -374,6 +381,7 @@ export default function RulesPage() {
toast.error('文档解析失败:' + (err instanceof Error ? err.message : '未知错误'))
} finally {
setParsing(false)
setIsOssUploading(false)
}
}
+7 -1
View File
@@ -47,6 +47,9 @@ type MessageType =
| 'task_deadline' // 任务截止提醒
| 'brief_updated' // Brief更新通知
| 'system_notice' // 系统通知
| 'reject' // 审核驳回
| 'force_pass' // 强制通过
| 'approve' // 审核批准
type Message = {
id: string
@@ -87,6 +90,9 @@ const messageConfig: Record<MessageType, {
task_deadline: { icon: CalendarClock, iconColor: 'text-orange-400', bgColor: 'bg-orange-500/20' },
brief_updated: { icon: FileText, iconColor: 'text-accent-indigo', bgColor: 'bg-accent-indigo/20' },
system_notice: { icon: Bell, iconColor: 'text-text-secondary', bgColor: 'bg-bg-elevated' },
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' },
approve: { icon: CheckCircle, iconColor: 'text-accent-green', bgColor: 'bg-accent-green/20' },
}
// 12条消息数据
@@ -281,7 +287,7 @@ function MessageCard({
onAcceptInvite?: () => void
onIgnoreInvite?: () => void
}) {
const config = messageConfig[message.type]
const config = messageConfig[message.type] || messageConfig.system_notice
const Icon = config.icon
return (
+20 -5
View File
@@ -32,6 +32,7 @@ type AgencyBriefFile = {
size: string
uploadedAt: string
description?: string
url?: string
}
// 页面视图模型
@@ -102,13 +103,17 @@ function buildMockViewModel(): BriefViewModel {
}
function buildViewModelFromAPI(task: TaskResponse, brief: BriefResponse): BriefViewModel {
// Map attachments to file list
const files: AgencyBriefFile[] = (brief.attachments ?? []).map((att, idx) => ({
// 优先显示代理商上传的文档,没有则降级到品牌方附件
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
@@ -233,12 +238,22 @@ export default function TaskBriefPage() {
loadBriefData()
}, [loadBriefData])
const handleDownload = (file: AgencyBriefFile) => {
toast.info(`下载文件: ${file.name}`)
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 = () => {
toast.info('下载全部文件')
if (!viewModel) return
viewModel.files.forEach(f => handleDownload(f))
}
if (loading || !viewModel) {
+75 -31
View File
@@ -16,7 +16,6 @@ import { Modal } from '@/components/ui/Modal'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import { useSSE } from '@/contexts/SSEContext'
import { useOSSUpload } from '@/hooks/useOSSUpload'
import type { TaskResponse, AIReviewResult } from '@/types/task'
import type { BriefResponse } from '@/types/brief'
@@ -217,64 +216,109 @@ function AgencyBriefSection({ toast, briefData }: { toast: ReturnType<typeof use
function UploadSection({ taskId, onUploaded }: { taskId: string; onUploaded: () => void }) {
const [file, setFile] = useState<File | null>(null)
const { upload, isUploading, progress } = useOSSUpload('script')
const [isUploading, setIsUploading] = useState(false)
const [progress, setProgress] = useState(0)
const [uploadError, setUploadError] = useState<string | null>(null)
const toast = useToast()
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0]
if (selectedFile) setFile(selectedFile)
if (selectedFile) {
setFile(selectedFile)
setUploadError(null)
}
}
const handleSubmit = async () => {
if (!file) return
setIsUploading(true)
setProgress(0)
setUploadError(null)
try {
const result = await upload(file)
if (!USE_MOCK) {
if (USE_MOCK) {
for (let i = 0; i <= 100; i += 20) {
await new Promise(r => setTimeout(r, 400))
setProgress(i)
}
toast.success('脚本已提交,等待 AI 审核')
onUploaded()
} else {
const result = await api.proxyUpload(file, 'script', (pct) => {
setProgress(Math.min(90, Math.round(pct * 0.9)))
})
setProgress(95)
await api.uploadTaskScript(taskId, { file_url: result.url, file_name: result.file_name })
setProgress(100)
toast.success('脚本已提交,等待 AI 审核')
onUploaded()
}
toast.success('脚本已提交,等待 AI 审核')
onUploaded()
} catch (err) {
toast.error(err instanceof Error ? err.message : '上传失败')
const msg = err instanceof Error ? err.message : '上传失败'
setUploadError(msg)
toast.error(msg)
} finally {
setIsUploading(false)
}
}
const formatSize = (bytes: number) => {
if (bytes < 1024) return bytes + 'B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'KB'
return (bytes / (1024 * 1024)).toFixed(1) + 'MB'
}
return (
<Card>
<CardHeader><CardTitle className="flex items-center gap-2"><Upload size={18} className="text-accent-indigo" /></CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="border-2 border-dashed border-border-subtle rounded-lg p-8 text-center hover:border-accent-indigo/50 transition-colors">
{file ? (
<div className="space-y-4">
<div className="flex items-center justify-center gap-3">
<FileText size={24} className="text-accent-indigo" />
<span className="text-text-primary">{file.name}</span>
{!file ? (
<label className="border-2 border-dashed border-border-subtle rounded-lg p-8 text-center hover:border-accent-indigo/50 transition-colors cursor-pointer block">
<Upload size={32} className="mx-auto text-text-tertiary mb-3" />
<p className="text-text-secondary mb-1"></p>
<p className="text-xs text-text-tertiary"> WordPDFTXT </p>
<input type="file" accept=".doc,.docx,.pdf,.txt" onChange={handleFileChange} className="hidden" />
</label>
) : (
<div className="border border-border-subtle rounded-lg overflow-hidden">
<div className="px-4 py-2.5 bg-bg-elevated border-b border-border-subtle">
<span className="text-xs font-medium text-text-secondary"></span>
</div>
<div className="px-4 py-3">
<div className="flex items-center gap-3">
{isUploading ? (
<Loader2 size={16} className="animate-spin text-accent-indigo flex-shrink-0" />
) : uploadError ? (
<AlertTriangle size={16} className="text-accent-coral flex-shrink-0" />
) : (
<CheckCircle size={16} className="text-accent-green flex-shrink-0" />
)}
<FileText size={14} className="text-accent-indigo flex-shrink-0" />
<span className="flex-1 text-sm text-text-primary truncate">{file.name}</span>
<span className="text-xs text-text-tertiary">{formatSize(file.size)}</span>
{!isUploading && (
<button type="button" onClick={() => setFile(null)} className="p-1 hover:bg-bg-elevated rounded-full">
<XCircle size={16} className="text-text-tertiary" />
<button type="button" onClick={() => { setFile(null); setUploadError(null) }} className="p-1 hover:bg-bg-elevated rounded">
<XCircle size={14} className="text-text-tertiary" />
</button>
)}
</div>
{isUploading && (
<div className="w-full max-w-xs mx-auto">
<div className="h-2 bg-bg-elevated rounded-full overflow-hidden mb-2">
<div className="h-full bg-accent-indigo transition-all" style={{ width: `${progress}%` }} />
</div>
<p className="text-sm text-text-tertiary"> {progress}%</p>
<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: `${progress}%` }} />
</div>
)}
{isUploading && (
<p className="mt-1 ml-[30px] text-xs text-text-tertiary"> {progress}%</p>
)}
{uploadError && (
<p className="mt-1 ml-[30px] text-xs text-accent-coral">{uploadError}</p>
)}
</div>
) : (
<label className="cursor-pointer">
<Upload size={32} className="mx-auto text-text-tertiary mb-3" />
<p className="text-text-secondary mb-1"></p>
<p className="text-xs text-text-tertiary"> WordPDFTXT </p>
<input type="file" accept=".doc,.docx,.pdf,.txt" onChange={handleFileChange} className="hidden" />
</label>
)}
</div>
</div>
)}
<Button onClick={handleSubmit} disabled={!file || isUploading} fullWidth>
{isUploading ? '上传中...' : '提交脚本'}
{isUploading ? (
<><Loader2 size={16} className="animate-spin" /> {progress}%</>
) : '提交脚本'}
</Button>
</CardContent>
</Card>
+75 -31
View File
@@ -14,7 +14,6 @@ import {
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import { useSSE } from '@/contexts/SSEContext'
import { useOSSUpload } from '@/hooks/useOSSUpload'
import type { TaskResponse } from '@/types/task'
// ========== 类型 ==========
@@ -102,64 +101,109 @@ function formatTimestamp(seconds: number): string {
function UploadSection({ taskId, onUploaded }: { taskId: string; onUploaded: () => void }) {
const [file, setFile] = useState<File | null>(null)
const { upload, isUploading, progress } = useOSSUpload('video')
const [isUploading, setIsUploading] = useState(false)
const [progress, setProgress] = useState(0)
const [uploadError, setUploadError] = useState<string | null>(null)
const toast = useToast()
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0]
if (selectedFile) setFile(selectedFile)
if (selectedFile) {
setFile(selectedFile)
setUploadError(null)
}
}
const handleUpload = async () => {
if (!file) return
setIsUploading(true)
setProgress(0)
setUploadError(null)
try {
const result = await upload(file)
if (!USE_MOCK) {
if (USE_MOCK) {
for (let i = 0; i <= 100; i += 10) {
await new Promise(r => setTimeout(r, 300))
setProgress(i)
}
toast.success('视频已提交,等待 AI 审核')
onUploaded()
} else {
const result = await api.proxyUpload(file, 'video', (pct) => {
setProgress(Math.min(90, Math.round(pct * 0.9)))
})
setProgress(95)
await api.uploadTaskVideo(taskId, { file_url: result.url, file_name: result.file_name })
setProgress(100)
toast.success('视频已提交,等待 AI 审核')
onUploaded()
}
toast.success('视频已提交,等待 AI 审核')
onUploaded()
} catch (err) {
toast.error(err instanceof Error ? err.message : '上传失败')
const msg = err instanceof Error ? err.message : '上传失败'
setUploadError(msg)
toast.error(msg)
} finally {
setIsUploading(false)
}
}
const formatSize = (bytes: number) => {
if (bytes < 1024) return bytes + 'B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'KB'
return (bytes / (1024 * 1024)).toFixed(1) + 'MB'
}
return (
<Card>
<CardHeader><CardTitle className="flex items-center gap-2"><Upload size={18} className="text-purple-400" /></CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="border-2 border-dashed border-border-subtle rounded-lg p-8 text-center hover:border-accent-indigo/50 transition-colors">
{file ? (
<div className="space-y-4">
<div className="flex items-center justify-center gap-3">
<Video size={24} className="text-purple-400" />
<span className="text-text-primary">{file.name}</span>
{!file ? (
<label className="border-2 border-dashed border-border-subtle rounded-lg p-8 text-center hover:border-accent-indigo/50 transition-colors cursor-pointer block">
<Upload size={32} className="mx-auto text-text-tertiary mb-3" />
<p className="text-text-secondary mb-1"></p>
<p className="text-xs text-text-tertiary"> MP4MOVAVI 500MB</p>
<input type="file" accept="video/*" onChange={handleFileChange} className="hidden" />
</label>
) : (
<div className="border border-border-subtle rounded-lg overflow-hidden">
<div className="px-4 py-2.5 bg-bg-elevated border-b border-border-subtle">
<span className="text-xs font-medium text-text-secondary"></span>
</div>
<div className="px-4 py-3">
<div className="flex items-center gap-3">
{isUploading ? (
<Loader2 size={16} className="animate-spin text-purple-400 flex-shrink-0" />
) : uploadError ? (
<AlertTriangle size={16} className="text-accent-coral flex-shrink-0" />
) : (
<CheckCircle size={16} className="text-accent-green flex-shrink-0" />
)}
<Video size={14} className="text-purple-400 flex-shrink-0" />
<span className="flex-1 text-sm text-text-primary truncate">{file.name}</span>
<span className="text-xs text-text-tertiary">{formatSize(file.size)}</span>
{!isUploading && (
<button type="button" onClick={() => setFile(null)} className="p-1 hover:bg-bg-elevated rounded-full">
<XCircle size={16} className="text-text-tertiary" />
<button type="button" onClick={() => { setFile(null); setUploadError(null) }} className="p-1 hover:bg-bg-elevated rounded">
<XCircle size={14} className="text-text-tertiary" />
</button>
)}
</div>
{isUploading && (
<div className="w-full max-w-xs mx-auto">
<div className="h-2 bg-bg-elevated rounded-full overflow-hidden mb-2">
<div className="h-full bg-accent-indigo transition-all" style={{ width: `${progress}%` }} />
</div>
<p className="text-sm text-text-tertiary"> {progress}%</p>
<div className="mt-2 ml-[30px] h-2 bg-bg-page rounded-full overflow-hidden">
<div className="h-full bg-purple-400 rounded-full transition-all duration-300" style={{ width: `${progress}%` }} />
</div>
)}
{isUploading && (
<p className="mt-1 ml-[30px] text-xs text-text-tertiary"> {progress}%</p>
)}
{uploadError && (
<p className="mt-1 ml-[30px] text-xs text-accent-coral">{uploadError}</p>
)}
</div>
) : (
<label className="cursor-pointer">
<Upload size={32} className="mx-auto text-text-tertiary mb-3" />
<p className="text-text-secondary mb-1"></p>
<p className="text-xs text-text-tertiary"> MP4MOVAVI 500MB</p>
<input type="file" accept="video/*" onChange={handleFileChange} className="hidden" />
</label>
)}
</div>
</div>
)}
<Button onClick={handleUpload} disabled={!file || isUploading} fullWidth>
{isUploading ? '上传中...' : '提交视频'}
{isUploading ? (
<><Loader2 size={16} className="animate-spin" /> {progress}%</>
) : '提交视频'}
</Button>
</CardContent>
</Card>
+2 -1
View File
@@ -21,7 +21,8 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined)
const USER_STORAGE_KEY = 'miaosi_user'
// 开发模式:使用 mock 数据
export const USE_MOCK = process.env.NEXT_PUBLIC_USE_MOCK === 'true' || process.env.NODE_ENV === 'development'
export const USE_MOCK = process.env.NEXT_PUBLIC_USE_MOCK === 'true' ||
(process.env.NEXT_PUBLIC_USE_MOCK !== 'false' && process.env.NODE_ENV === 'development')
// Mock 用户数据
const MOCK_USERS: Record<string, User & { password: string }> = {
+4 -39
View File
@@ -53,47 +53,12 @@ export function useOSSUpload(fileType: string = 'general'): UseOSSUploadReturn {
return result
}
// 1. 获取上传凭证
setProgress(10)
const policy = await api.getUploadPolicy(fileType)
// 2. 构建 TOS 直传 FormData
const fileKey = `${policy.dir}${Date.now()}_${file.name}`
const formData = new FormData()
formData.append('key', fileKey)
formData.append('x-tos-algorithm', policy.x_tos_algorithm)
formData.append('x-tos-credential', policy.x_tos_credential)
formData.append('x-tos-date', policy.x_tos_date)
formData.append('x-tos-signature', policy.x_tos_signature)
formData.append('policy', policy.policy)
formData.append('success_action_status', '200')
formData.append('file', file)
// 3. 上传到 TOS
setProgress(30)
const xhr = new XMLHttpRequest()
await new Promise<void>((resolve, reject) => {
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
setProgress(30 + Math.round((e.loaded / e.total) * 50))
}
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve()
} else {
reject(new Error(`上传失败: ${xhr.status}`))
}
}
xhr.onerror = () => reject(new Error('网络错误'))
xhr.open('POST', policy.host)
xhr.send(formData)
// 后端代理上传:文件 → 后端 → TOS,避免浏览器 CORS/代理问题
setProgress(5)
const result = await api.proxyUpload(file, fileType, (pct) => {
setProgress(5 + Math.round(pct * 0.9))
})
// 4. 回调通知后端
setProgress(90)
const result = await api.fileUploaded(fileKey, file.name, file.size, fileType)
setProgress(100)
setIsUploading(false)
return {
+20 -1
View File
@@ -453,6 +453,23 @@ class ApiClient {
return response.data
}
/**
* TOS CORS/
*/
async proxyUpload(file: File, fileType: string = 'general', onProgress?: (pct: number) => void): Promise<FileUploadedResponse> {
const formData = new FormData()
formData.append('file', file)
formData.append('file_type', fileType)
const response = await this.client.post<FileUploadedResponse>('/upload/proxy', formData, {
timeout: 300000,
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => {
if (e.total && onProgress) onProgress(Math.round((e.loaded / e.total) * 100))
},
})
return response.data
}
/**
* 访 URL
*/
@@ -877,7 +894,9 @@ class ApiClient {
* AI
*/
async parsePlatformRule(data: PlatformRuleParseRequest): Promise<PlatformRuleParseResponse> {
const response = await this.client.post<PlatformRuleParseResponse>('/rules/platform-rules/parse', data)
const response = await this.client.post<PlatformRuleParseResponse>('/rules/platform-rules/parse', data, {
timeout: 180000, // 3 分钟,视觉模型解析图片 PDF 较慢
})
return response.data
}
+2
View File
@@ -34,6 +34,7 @@ export interface BriefResponse {
max_duration?: number | null
other_requirements?: string | null
attachments?: BriefAttachment[] | null
agency_attachments?: BriefAttachment[] | null
created_at: string
updated_at: string
}
@@ -49,4 +50,5 @@ export interface BriefCreateRequest {
max_duration?: number
other_requirements?: string
attachments?: BriefAttachment[]
agency_attachments?: BriefAttachment[]
}
+3
View File
@@ -13,6 +13,7 @@ export interface ProjectResponse {
id: string
name: string
description?: string | null
platform?: string | null
brand_id: string
brand_name?: string | null
status: string
@@ -34,6 +35,7 @@ export interface ProjectListResponse {
export interface ProjectCreateRequest {
name: string
description?: string
platform?: string
start_date?: string
deadline?: string
agency_ids?: string[]
@@ -42,6 +44,7 @@ export interface ProjectCreateRequest {
export interface ProjectUpdateRequest {
name?: string
description?: string
platform?: string
start_date?: string
deadline?: string
status?: 'active' | 'completed' | 'archived'