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

573 lines
24 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, useCallback } from 'react'
import { useRouter, useParams } from 'next/navigation'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Modal } from '@/components/ui/Modal'
import { SuccessTag, PendingTag, ErrorTag } from '@/components/ui/Tag'
import { useToast } from '@/components/ui/Toast'
import {
ArrowLeft,
Calendar,
Users,
FileText,
Video,
Clock,
CheckCircle,
XCircle,
ChevronRight,
Plus,
Settings,
Search,
Building2,
MoreHorizontal,
Trash2,
Check,
Pencil,
Loader2
} from 'lucide-react'
import { getPlatformInfo } from '@/lib/platforms'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import { useSSE } from '@/contexts/SSEContext'
import type { ProjectResponse } from '@/types/project'
import type { TaskResponse } from '@/types/task'
import type { AgencyDetail } from '@/types/organization'
// ==================== Mock 数据 ====================
const mockProject: ProjectResponse = {
id: 'proj-001', name: 'XX品牌618推广', brand_id: 'br-001', brand_name: 'XX护肤品牌',
description: '618大促活动营销内容审核项目', status: 'active', deadline: '2026-06-18',
agencies: [
{ id: 'AG789012', name: '星耀传媒' },
{ id: 'AG456789', name: '创意无限' },
],
task_count: 20,
created_at: '2026-02-01T00:00:00Z', updated_at: '2026-02-06T00:00:00Z',
}
const mockTasks: TaskResponse[] = [
{
id: 'task-001', name: '夏日护肤推广', sequence: 1,
stage: 'video_brand_review',
project: { id: 'proj-001', name: 'XX品牌618推广' },
agency: { id: 'AG789012', name: '星耀传媒' },
creator: { id: 'cr-001', name: '小美护肤' },
appeal_count: 0, is_appeal: false,
created_at: '2026-02-06T14:30:00Z', updated_at: '2026-02-06T14:30:00Z',
},
{
id: 'task-002', name: '新品口红试色', sequence: 2,
stage: 'completed',
project: { id: 'proj-001', name: 'XX品牌618推广' },
agency: { id: 'AG789012', name: '星耀传媒' },
creator: { id: 'cr-002', name: '美妆Lisa' },
appeal_count: 0, is_appeal: false,
created_at: '2026-02-06T12:15:00Z', updated_at: '2026-02-06T12:15:00Z',
},
{
id: 'task-003', name: '健身器材推荐', sequence: 3,
stage: 'rejected',
project: { id: 'proj-001', name: 'XX品牌618推广' },
agency: { id: 'AG456789', name: '创意无限' },
creator: { id: 'cr-003', name: '健身王' },
appeal_count: 0, is_appeal: false,
created_at: '2026-02-06T10:00:00Z', updated_at: '2026-02-06T10:00:00Z',
},
]
const mockManagedAgencies: AgencyDetail[] = [
{ id: 'AG789012', name: '星耀传媒', force_pass_enabled: true, contact_name: '张经理' },
{ id: 'AG456789', name: '创意无限', force_pass_enabled: false, contact_name: '李总' },
{ id: 'AG123456', name: '美妆达人MCN', force_pass_enabled: false },
{ id: 'AG111111', name: '蓝海科技', force_pass_enabled: true },
{ id: 'AG222222', name: '云创网络', force_pass_enabled: false },
]
// ==================== 组件 ====================
function StatCard({ title, value, icon: Icon, color }: { title: string; value: number | string; icon: React.ElementType; color: string }) {
return (
<Card>
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-text-secondary">{title}</p>
<p className={`text-2xl font-bold ${color}`}>{value}</p>
</div>
<div className={`w-10 h-10 rounded-lg ${color.replace('text-', 'bg-')}/20 flex items-center justify-center`}>
<Icon size={20} className={color} />
</div>
</div>
</CardContent>
</Card>
)
}
function TaskStatusTag({ stage }: { stage: string }) {
if (stage === 'completed') return <SuccessTag></SuccessTag>
if (stage === 'rejected') return <ErrorTag></ErrorTag>
if (stage.includes('review')) return <PendingTag></PendingTag>
return <PendingTag></PendingTag>
}
function DetailSkeleton() {
return (
<div className="space-y-6 animate-pulse">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-bg-elevated rounded-full" />
<div className="space-y-2">
<div className="h-7 w-48 bg-bg-elevated rounded" />
<div className="h-4 w-64 bg-bg-elevated rounded" />
</div>
</div>
<div className="h-20 bg-bg-elevated rounded-xl" />
<div className="grid grid-cols-4 gap-4">
{[1, 2, 3, 4].map(i => <div key={i} className="h-20 bg-bg-elevated rounded-xl" />)}
</div>
<div className="grid grid-cols-3 gap-6">
<div className="col-span-2 h-48 bg-bg-elevated rounded-xl" />
<div className="h-48 bg-bg-elevated rounded-xl" />
</div>
</div>
)
}
export default function ProjectDetailPage() {
const router = useRouter()
const params = useParams()
const toast = useToast()
const projectId = params.id as string
const { subscribe } = useSSE()
const [project, setProject] = useState<ProjectResponse | null>(null)
const [recentTasks, setRecentTasks] = useState<TaskResponse[]>([])
const [managedAgencies, setManagedAgencies] = useState<AgencyDetail[]>([])
const [loading, setLoading] = useState(true)
// UI states
const [showAddModal, setShowAddModal] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [selectedAgencies, setSelectedAgencies] = useState<string[]>([])
const [activeAgencyMenu, setActiveAgencyMenu] = useState<string | null>(null)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [agencyToDelete, setAgencyToDelete] = useState<{ id: string; name: string } | null>(null)
const [showDeadlineModal, setShowDeadlineModal] = useState(false)
const [newDeadline, setNewDeadline] = useState('')
const [submitting, setSubmitting] = useState(false)
const loadData = useCallback(async () => {
if (USE_MOCK) {
setProject(mockProject)
setRecentTasks(mockTasks)
setManagedAgencies(mockManagedAgencies)
setLoading(false)
return
}
try {
const [projectData, tasksData, agenciesData] = await Promise.all([
api.getProject(projectId),
api.listTasks(1, 10),
api.listBrandAgencies(),
])
setProject(projectData)
setRecentTasks(tasksData.items.filter(t => t.project.id === projectId).slice(0, 5))
setManagedAgencies(agenciesData.items)
} catch (err) {
console.error('Failed to load project:', err)
toast.error('加载项目详情失败')
} finally {
setLoading(false)
}
}, [projectId, toast])
useEffect(() => {
loadData()
}, [loadData])
useEffect(() => {
const unsub = subscribe('task_updated', () => loadData())
return unsub
}, [subscribe, loadData])
if (loading || !project) return <DetailSkeleton />
const availableAgencies = managedAgencies.filter(
agency => !project.agencies.some(a => a.id === agency.id)
)
const filteredAgencies = availableAgencies.filter(agency =>
searchQuery === '' ||
agency.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
agency.id.toLowerCase().includes(searchQuery.toLowerCase())
)
const toggleSelectAgency = (agencyId: string) => {
setSelectedAgencies(prev =>
prev.includes(agencyId) ? prev.filter(id => id !== agencyId) : [...prev, agencyId]
)
}
const handleAddAgencies = async () => {
setSubmitting(true)
try {
if (!USE_MOCK) {
await api.assignAgencies(projectId, selectedAgencies)
}
const newAgencies = managedAgencies
.filter(a => selectedAgencies.includes(a.id))
.map(a => ({ id: a.id, name: a.name }))
setProject({ ...project, agencies: [...project.agencies, ...newAgencies] })
toast.success('代理商已添加')
} catch (err) {
console.error('Failed to add agencies:', err)
toast.error('添加失败')
} finally {
setSubmitting(false)
setShowAddModal(false)
setSelectedAgencies([])
setSearchQuery('')
}
}
const handleRemoveAgency = async () => {
if (!agencyToDelete) return
setSubmitting(true)
try {
if (!USE_MOCK) {
await api.removeAgencyFromProject(projectId, agencyToDelete.id)
}
setProject({ ...project, agencies: project.agencies.filter(a => a.id !== agencyToDelete.id) })
toast.success('代理商已移除')
} catch (err) {
console.error('Failed to remove agency:', err)
toast.error('移除失败')
} finally {
setSubmitting(false)
setShowDeleteModal(false)
setAgencyToDelete(null)
}
}
const handleSaveDeadline = async () => {
if (!newDeadline) return
setSubmitting(true)
try {
if (!USE_MOCK) {
await api.updateProject(projectId, { deadline: newDeadline })
}
setProject({ ...project, deadline: newDeadline })
toast.success('截止日期已更新')
} catch (err) {
console.error('Failed to update deadline:', err)
toast.error('更新失败')
} finally {
setSubmitting(false)
setShowDeadlineModal(false)
}
}
return (
<div className="space-y-6">
{/* 顶部导航 */}
<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>
<div className="flex-1">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-text-primary">{project.name}</h1>
</div>
{project.description && (
<p className="text-sm text-text-secondary">{project.description}</p>
)}
</div>
<SuccessTag>{project.status === 'active' ? '进行中' : project.status === 'completed' ? '已完成' : '已归档'}</SuccessTag>
</div>
{/* 项目信息 */}
<div className="flex items-center gap-6 text-sm text-text-secondary">
<span className="flex items-center gap-2">
<Calendar size={16} />
: {project.deadline ? new Date(project.deadline).toLocaleDateString('zh-CN') : '未设置'}
<button
type="button"
onClick={() => { setNewDeadline(project.deadline || ''); setShowDeadlineModal(true) }}
className="p-1 rounded hover:bg-bg-elevated transition-colors"
>
<Pencil size={14} className="text-text-tertiary hover:text-accent-indigo" />
</button>
</span>
<span className="flex items-center gap-2">
<Clock size={16} />
: {new Date(project.created_at).toLocaleDateString('zh-CN')}
</span>
</div>
{/* Brief和规则配置 */}
<Link href={`/brand/projects/${projectId}/config`}>
<Card className="hover:border-accent-indigo transition-colors cursor-pointer">
<CardContent className="py-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-accent-indigo/15 flex items-center justify-center">
<Settings size={24} className="text-accent-indigo" />
</div>
<div>
<p className="font-semibold text-text-primary">Brief和规则配置</p>
<p className="text-sm text-text-secondary">BriefAI检测项等</p>
</div>
</div>
<ChevronRight size={20} className="text-text-tertiary" />
</div>
</CardContent>
</Card>
</Link>
{/* 统计卡片 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<StatCard title="总任务数" value={project.task_count} icon={FileText} color="text-accent-green" />
<StatCard title="参与代理商" value={project.agencies.length} icon={Users} color="text-purple-400" />
<StatCard title="状态" value={project.status === 'active' ? '进行中' : '已完成'} icon={CheckCircle} color="text-accent-indigo" />
<StatCard title="最近更新" value={new Date(project.updated_at).toLocaleDateString('zh-CN')} icon={Clock} color="text-orange-400" />
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 最近任务 */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span></span>
<Link href="/brand/review">
<Button variant="ghost" size="sm">
<ChevronRight size={16} />
</Button>
</Link>
</CardTitle>
</CardHeader>
<CardContent>
{recentTasks.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border-subtle text-left text-sm text-text-secondary">
<th className="pb-3 font-medium"></th>
<th className="pb-3 font-medium"></th>
<th className="pb-3 font-medium"></th>
<th className="pb-3 font-medium"></th>
<th className="pb-3 font-medium"></th>
</tr>
</thead>
<tbody>
{recentTasks.map((task) => (
<tr key={task.id} className="border-b border-border-subtle last:border-0 hover:bg-bg-elevated">
<td className="py-4 font-medium text-text-primary">{task.name}</td>
<td className="py-4 text-text-secondary">{task.creator.name}</td>
<td className="py-4">
<span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md bg-bg-elevated text-sm">
<Building2 size={14} className="text-accent-indigo" />
<span className="text-text-secondary">{task.agency.name}</span>
</span>
</td>
<td className="py-4"><TaskStatusTag stage={task.stage} /></td>
<td className="py-4">
<Link href={`/agency/review/${task.id}`}>
<Button size="sm" variant={task.stage.includes('review') ? 'primary' : 'secondary'}>
{task.stage.includes('review') ? '审核' : '查看'}
</Button>
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-8 text-text-tertiary text-sm"></div>
)}
</CardContent>
</Card>
{/* 代理商列表 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users size={16} />
<span className="text-sm font-normal text-text-tertiary">({project.agencies.length})</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{project.agencies.map((agency) => (
<div key={agency.id} className="flex items-center justify-between p-3 rounded-lg bg-bg-elevated">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-accent-indigo/15 flex items-center justify-center">
<Building2 size={18} className="text-accent-indigo" />
</div>
<div>
<p className="font-medium text-text-primary text-sm">{agency.name}</p>
<p className="text-xs text-text-tertiary">{agency.id}</p>
</div>
</div>
<div className="relative">
<button
type="button"
onClick={() => setActiveAgencyMenu(activeAgencyMenu === agency.id ? null : agency.id)}
className="p-1.5 rounded hover:bg-bg-page transition-colors"
>
<MoreHorizontal size={16} className="text-text-tertiary" />
</button>
{activeAgencyMenu === agency.id && (
<div className="absolute right-0 top-8 z-10 w-32 py-1 bg-bg-card rounded-lg shadow-lg border border-border-subtle">
<button
type="button"
onClick={() => {
setAgencyToDelete(agency)
setShowDeleteModal(true)
setActiveAgencyMenu(null)
}}
className="w-full px-3 py-2 text-left text-sm text-accent-coral hover:bg-bg-elevated flex items-center gap-2"
>
<Trash2 size={14} />
</button>
</div>
)}
</div>
</div>
))}
<button
type="button"
onClick={() => setShowAddModal(true)}
className="w-full p-3 rounded-lg border-2 border-dashed border-border-subtle hover:border-accent-indigo hover:bg-accent-indigo/5 transition-all flex items-center justify-center gap-2 text-text-tertiary hover:text-accent-indigo"
>
<Plus size={18} />
<span className="text-sm font-medium"></span>
</button>
</CardContent>
</Card>
</div>
{/* 添加代理商弹窗 */}
<Modal
isOpen={showAddModal}
onClose={() => { setShowAddModal(false); setSearchQuery(''); setSelectedAgencies([]) }}
title="添加代理商"
size="lg"
>
<div className="space-y-4">
<div className="relative">
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-tertiary" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索代理商名称或ID..."
className="pl-10"
/>
</div>
<div className="max-h-80 overflow-y-auto space-y-2">
{filteredAgencies.length > 0 ? (
filteredAgencies.map((agency) => {
const isSelected = selectedAgencies.includes(agency.id)
return (
<button
key={agency.id}
type="button"
onClick={() => toggleSelectAgency(agency.id)}
className={`w-full flex items-center gap-3 p-3 rounded-xl border-2 transition-all text-left ${
isSelected ? 'border-accent-indigo bg-accent-indigo/5' : 'border-transparent bg-bg-elevated hover:bg-bg-page'
}`}
>
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
isSelected ? 'bg-accent-indigo' : 'bg-accent-indigo/15'
}`}>
{isSelected ? <Check 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">
<p className="font-medium text-text-primary">{agency.name}</p>
<span className="text-xs text-text-tertiary font-mono">{agency.id}</span>
</div>
{agency.contact_name && (
<p className="text-sm text-text-secondary truncate">{agency.contact_name}</p>
)}
</div>
</button>
)
})
) : (
<div className="text-center py-8 text-text-tertiary">
{availableAgencies.length === 0 ? (
<><Users size={32} className="mx-auto mb-2 opacity-50" /><p></p></>
) : (
<><Search size={32} className="mx-auto mb-2 opacity-50" /><p></p></>
)}
</div>
)}
</div>
{selectedAgencies.length > 0 && (
<div className="flex items-center justify-between pt-3 border-t border-border-subtle">
<span className="text-sm text-text-secondary">
<span className="text-accent-indigo font-medium">{selectedAgencies.length}</span>
</span>
<Button variant="primary" onClick={handleAddAgencies} disabled={submitting}>
{submitting && <Loader2 size={16} className="animate-spin" />}
</Button>
</div>
)}
</div>
</Modal>
{/* 删除确认弹窗 */}
<Modal isOpen={showDeleteModal} onClose={() => { setShowDeleteModal(false); setAgencyToDelete(null) }} title="移除代理商">
<div className="space-y-4">
<p className="text-text-secondary">
<span className="text-text-primary font-medium">{agencyToDelete?.name}</span>
</p>
<p className="text-sm text-accent-coral"></p>
<div className="flex gap-3">
<Button variant="secondary" className="flex-1" onClick={() => { setShowDeleteModal(false); setAgencyToDelete(null) }}></Button>
<Button variant="primary" className="flex-1 bg-accent-coral hover:bg-accent-coral/80" onClick={handleRemoveAgency} disabled={submitting}>
{submitting && <Loader2 size={16} className="animate-spin" />}
</Button>
</div>
</div>
</Modal>
{/* 编辑截止日期弹窗 */}
<Modal isOpen={showDeadlineModal} onClose={() => setShowDeadlineModal(false)} title="修改截止日期">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-2"></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={newDeadline}
onChange={(e) => setNewDeadline(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>
<div className="flex gap-3 pt-2">
<Button variant="secondary" className="flex-1" onClick={() => setShowDeadlineModal(false)}></Button>
<Button variant="primary" className="flex-1" onClick={handleSaveDeadline} disabled={!newDeadline || submitting}>
{submitting && <Loader2 size={16} className="animate-spin" />}
</Button>
</div>
</div>
</Modal>
</div>
)
}