Your Name 54eaa54966 feat: 前端全面对接后端 API(Phase 1 完成)
- 新增基础设施:useOSSUpload Hook、SSEContext Provider、taskStageMapper 工具
- 达人端4页面:任务列表/详情/脚本上传/视频上传对接真实 API
- 代理商端3页面:工作台/审核队列/审核详情对接真实 API
- 品牌方端4页面:项目列表/创建项目/项目详情/Brief配置对接真实 API
- 保留 USE_MOCK 开关,mock 模式下使用类型安全的 mock 数据
- 所有页面添加 loading 骨架屏、SSE 实时更新、错误处理

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 15:58:47 +08:00

328 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useState, useEffect } 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,
X,
Users,
Search,
Building2,
Check,
Loader2
} from 'lucide-react'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import { useOSSUpload } from '@/hooks/useOSSUpload'
import type { AgencyDetail } from '@/types/organization'
// ==================== 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 { upload, isUploading, progress: uploadProgress } = useOSSUpload('general')
const [projectName, setProjectName] = useState('')
const [description, setDescription] = useState('')
const [deadline, setDeadline] = useState('')
const [briefFile, setBriefFile] = useState<File | null>(null)
const [briefFileUrl, setBriefFileUrl] = useState<string | null>(null)
const [selectedAgencies, setSelectedAgencies] = useState<string[]>([])
const [isSubmitting, setIsSubmitting] = useState(false)
const [agencySearch, setAgencySearch] = useState('')
const [agencies, setAgencies] = useState<AgencyDetail[]>([])
const [loadingAgencies, setLoadingAgencies] = useState(true)
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 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)
}
} else {
setBriefFileUrl('mock://brief-file.pdf')
}
}
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,
deadline,
agency_ids: selectedAgencies,
})
// If brief file was uploaded, create brief
if (briefFileUrl && briefFile) {
await api.createBrief(project.id, {
file_url: briefFileUrl,
file_name: briefFile.name,
})
}
}
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="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>
<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>
</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>
</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">
"代理商管理"
</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 || isUploading}>
{isSubmitting ? (
<>
<Loader2 size={16} className="animate-spin" />
...
</>
) : '创建项目'}
</Button>
</div>
</CardContent>
</Card>
</div>
)
}