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>
This commit is contained in:
Your Name
2026-02-09 15:58:47 +08:00
co-authored by Claude Opus 4.6
parent 4a3c7e7923
commit 54eaa54966
16 changed files with 3002 additions and 2970 deletions
+351 -155
View File
@@ -1,75 +1,83 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Card, CardContent } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import {
ArrowLeft,
FileText,
Shield,
Settings,
Plus,
Trash2,
AlertTriangle,
CheckCircle,
Video,
Bot,
Users,
Save,
Upload,
Download,
ChevronDown,
ChevronUp
ChevronUp,
Loader2
} from 'lucide-react'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import { useOSSUpload } from '@/hooks/useOSSUpload'
import type { BriefResponse, BriefCreateRequest, SellingPoint, BlacklistWord, BriefAttachment } from '@/types/brief'
// 模拟数据
const mockData = {
project: {
id: 'proj-001',
name: 'XX品牌618推广',
},
brief: {
title: 'XX品牌618推广Brief',
description: '本次618大促营销活动,需要达人围绕夏日护肤、美妆新品进行内容创作。',
requirements: [
'视频时长:60-90秒',
'必须展示产品使用过程',
'需要口播品牌slogan"XX品牌,夏日焕新"',
'背景音乐需使用品牌指定曲库',
// ==================== Mock 数据 ====================
const mockBrief: BriefResponse = {
id: 'bf-001',
project_id: 'proj-001',
project_name: 'XX品牌618推广',
selling_points: [
{ content: '视频时长:60-90秒', required: true },
{ content: '必须展示产品使用过程', required: true },
{ content: '需要口播品牌slogan"XX品牌,夏日焕新"', required: true },
{ content: '背景音乐需使用品牌指定曲库', required: false },
],
blacklist_words: [
{ word: '最好', reason: '违反广告法' },
{ word: '第一', reason: '违反广告法' },
{ word: '绝对', reason: '夸大宣传' },
{ word: '100%', reason: '夸大宣传' },
],
competitors: ['竞品A', '竞品B', '竞品C'],
brand_tone: '年轻、活力、清新',
min_duration: 60,
max_duration: 90,
other_requirements: '本次618大促营销活动,需要达人围绕夏日护肤、美妆新品进行内容创作。',
attachments: [
{ id: 'att-001', name: '品牌视觉指南.pdf', url: 'https://example.com/brand-guide.pdf' },
{ id: 'att-002', name: '产品资料包.zip', url: 'https://example.com/product-pack.zip' },
],
created_at: '2026-02-01T00:00:00Z',
updated_at: '2026-02-05T00:00:00Z',
}
const mockRules = {
aiReview: {
enabled: true,
strictness: 'medium',
checkItems: [
{ id: 'forbidden_words', name: '违禁词检测', enabled: true },
{ id: 'competitor', name: '竞品提及检测', enabled: true },
{ id: 'brand_tone', name: '品牌调性检测', enabled: true },
{ id: 'duration', name: '视频时长检测', enabled: true },
{ id: 'music', name: '背景音乐检测', enabled: false },
],
keywords: ['夏日护肤', '清爽', '补水', '防晒', '焕新'],
forbiddenWords: ['最好', '第一', '绝对', '100%'],
referenceLinks: [
{ title: '品牌视觉指南', url: 'https://example.com/brand-guide.pdf' },
{ title: '产品资料包', url: 'https://example.com/product-pack.zip' },
],
deadline: '2026-06-10',
},
rules: {
aiReview: {
enabled: true,
strictness: 'medium', // low, medium, high
checkItems: [
{ id: 'forbidden_words', name: '违禁词检测', enabled: true },
{ id: 'competitor', name: '竞品提及检测', enabled: true },
{ id: 'brand_tone', name: '品牌调性检测', enabled: true },
{ id: 'duration', name: '视频时长检测', enabled: true },
{ id: 'music', name: '背景音乐检测', enabled: false },
],
},
manualReview: {
scriptRequired: true,
videoRequired: true,
agencyCanApprove: true, // 代理商是否有终审权限
brandFinalReview: true, // 品牌方是否需要终审
},
appealRules: {
maxAppeals: 3, // 最大申诉次数
appealDeadline: 48, // 申诉处理时限(小时)
},
manualReview: {
scriptRequired: true,
videoRequired: true,
agencyCanApprove: true,
brandFinalReview: true,
},
appealRules: {
maxAppeals: 3,
appealDeadline: 48,
},
}
@@ -80,64 +88,203 @@ const strictnessOptions = [
{ value: 'high', label: '严格', description: '严格检测,可能有较多误判' },
]
function ConfigSkeleton() {
return (
<div className="space-y-6 animate-pulse">
<div className="flex items-center gap-4">
<div className="h-10 w-10 bg-bg-elevated rounded-lg" />
<div className="space-y-2">
<div className="h-7 w-48 bg-bg-elevated rounded" />
<div className="h-4 w-32 bg-bg-elevated rounded" />
</div>
</div>
{[1, 2, 3, 4].map(i => (
<div key={i} className="h-16 bg-bg-elevated rounded-xl" />
))}
</div>
)
}
export default function ProjectConfigPage() {
const router = useRouter()
const params = useParams()
const toast = useToast()
const projectId = params.id as string
const { upload, isUploading, progress: uploadProgress } = useOSSUpload('general')
// Brief state
const [briefExists, setBriefExists] = useState(false)
const [loading, setLoading] = useState(true)
const [projectName, setProjectName] = useState('')
// Brief form fields
const [brandTone, setBrandTone] = useState('')
const [otherRequirements, setOtherRequirements] = useState('')
const [minDuration, setMinDuration] = useState<number | undefined>()
const [maxDuration, setMaxDuration] = useState<number | undefined>()
const [sellingPoints, setSellingPoints] = useState<SellingPoint[]>([])
const [blacklistWords, setBlacklistWords] = useState<BlacklistWord[]>([])
const [competitors, setCompetitors] = useState<string[]>([])
const [attachments, setAttachments] = useState<BriefAttachment[]>([])
// Rules state (local only — no per-project backend API yet)
const [rules, setRules] = useState(mockRules)
const [brief, setBrief] = useState(mockData.brief)
const [rules, setRules] = useState(mockData.rules)
const [isSaving, setIsSaving] = useState(false)
const [activeSection, setActiveSection] = useState<string | null>('brief')
// 新增需求
const [newRequirement, setNewRequirement] = useState('')
// 新增关键词
const [newKeyword, setNewKeyword] = useState('')
// 新增违禁词
const [newForbiddenWord, setNewForbiddenWord] = useState('')
// Input fields
const [newSellingPoint, setNewSellingPoint] = useState('')
const [newBlacklistWord, setNewBlacklistWord] = useState('')
const [newBlacklistReason, setNewBlacklistReason] = useState('')
const [newCompetitor, setNewCompetitor] = useState('')
const handleSave = async () => {
const populateBrief = (data: BriefResponse) => {
setProjectName(data.project_name || '')
setBrandTone(data.brand_tone || '')
setOtherRequirements(data.other_requirements || '')
setMinDuration(data.min_duration ?? undefined)
setMaxDuration(data.max_duration ?? undefined)
setSellingPoints(data.selling_points || [])
setBlacklistWords(data.blacklist_words || [])
setCompetitors(data.competitors || [])
setAttachments(data.attachments || [])
}
const loadBrief = useCallback(async () => {
if (USE_MOCK) {
populateBrief(mockBrief)
setBriefExists(true)
setLoading(false)
return
}
try {
const data = await api.getBrief(projectId)
populateBrief(data)
setBriefExists(true)
} catch (err: any) {
if (err?.response?.status === 404) {
setBriefExists(false)
} else {
console.error('Failed to load brief:', err)
toast.error('加载Brief失败')
}
} finally {
setLoading(false)
}
}, [projectId, toast])
useEffect(() => {
loadBrief()
}, [loadBrief])
const handleSaveBrief = async () => {
setIsSaving(true)
await new Promise(resolve => setTimeout(resolve, 1000))
setIsSaving(false)
toast.success('配置已保存')
}
try {
const briefData: BriefCreateRequest = {
selling_points: sellingPoints,
blacklist_words: blacklistWords,
competitors,
brand_tone: brandTone || undefined,
min_duration: minDuration,
max_duration: maxDuration,
other_requirements: otherRequirements || undefined,
attachments,
}
const addRequirement = () => {
if (newRequirement.trim()) {
setBrief({ ...brief, requirements: [...brief.requirements, newRequirement.trim()] })
setNewRequirement('')
if (USE_MOCK) {
await new Promise(resolve => setTimeout(resolve, 1000))
} else if (briefExists) {
await api.updateBrief(projectId, briefData)
} else {
await api.createBrief(projectId, briefData)
setBriefExists(true)
}
toast.success('Brief配置已保存')
} catch (err) {
console.error('Failed to save brief:', err)
toast.error('保存失败,请重试')
} finally {
setIsSaving(false)
}
}
const removeRequirement = (index: number) => {
setBrief({ ...brief, requirements: brief.requirements.filter((_, i) => i !== index) })
}
const addKeyword = () => {
if (newKeyword.trim() && !brief.keywords.includes(newKeyword.trim())) {
setBrief({ ...brief, keywords: [...brief.keywords, newKeyword.trim()] })
setNewKeyword('')
// Selling points
const addSellingPoint = () => {
if (newSellingPoint.trim()) {
setSellingPoints([...sellingPoints, { content: newSellingPoint.trim(), required: false }])
setNewSellingPoint('')
}
}
const removeKeyword = (keyword: string) => {
setBrief({ ...brief, keywords: brief.keywords.filter(k => k !== keyword) })
const removeSellingPoint = (index: number) => {
setSellingPoints(sellingPoints.filter((_, i) => i !== index))
}
const addForbiddenWord = () => {
if (newForbiddenWord.trim() && !brief.forbiddenWords.includes(newForbiddenWord.trim())) {
setBrief({ ...brief, forbiddenWords: [...brief.forbiddenWords, newForbiddenWord.trim()] })
setNewForbiddenWord('')
const toggleSellingPointRequired = (index: number) => {
setSellingPoints(sellingPoints.map((sp, i) =>
i === index ? { ...sp, required: !sp.required } : sp
))
}
// Blacklist words
const addBlacklistWord = () => {
if (newBlacklistWord.trim()) {
setBlacklistWords([...blacklistWords, { word: newBlacklistWord.trim(), reason: newBlacklistReason.trim() || '品牌规范' }])
setNewBlacklistWord('')
setNewBlacklistReason('')
}
}
const removeForbiddenWord = (word: string) => {
setBrief({ ...brief, forbiddenWords: brief.forbiddenWords.filter(w => w !== word) })
const removeBlacklistWord = (index: number) => {
setBlacklistWords(blacklistWords.filter((_, i) => i !== index))
}
// Competitors
const addCompetitorItem = () => {
if (newCompetitor.trim() && !competitors.includes(newCompetitor.trim())) {
setCompetitors([...competitors, newCompetitor.trim()])
setNewCompetitor('')
}
}
const removeCompetitor = (name: string) => {
setCompetitors(competitors.filter(c => c !== name))
}
// Attachment upload
const handleAttachmentUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
if (USE_MOCK) {
setAttachments([...attachments, {
id: `att-${Date.now()}`,
name: file.name,
url: `mock://${file.name}`,
}])
return
}
try {
const result = await upload(file)
setAttachments([...attachments, {
id: `att-${Date.now()}`,
name: file.name,
url: result.url,
}])
} catch {
toast.error('文件上传失败')
}
}
const removeAttachment = (id: string) => {
setAttachments(attachments.filter(a => a.id !== id))
}
// AI check item toggles (local state only)
const toggleAiCheckItem = (itemId: string) => {
setRules({
...rules,
@@ -168,6 +315,8 @@ export default function ProjectConfigPage() {
</button>
)
if (loading) return <ConfigSkeleton />
return (
<div className="space-y-6">
{/* 顶部导航 */}
@@ -183,12 +332,17 @@ export default function ProjectConfigPage() {
<div>
<h1 className="text-2xl font-bold text-text-primary">Brief和规则配置</h1>
<p className="text-sm text-text-secondary mt-0.5">
{mockData.project.name}
{projectName || `项目 ${projectId}`}
</p>
</div>
</div>
<Button variant="primary" onClick={handleSave} disabled={isSaving}>
{isSaving ? '保存中...' : (
<Button variant="primary" onClick={handleSaveBrief} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 size={16} className="animate-spin" />
...
</>
) : (
<>
<Save size={16} />
@@ -202,45 +356,67 @@ export default function ProjectConfigPage() {
<SectionHeader title="Brief配置" icon={FileText} section="brief" />
{activeSection === 'brief' && (
<CardContent className="space-y-6 pt-0">
{/* 基本信息 */}
{/* 品牌调性 + 视频时长 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm text-text-secondary mb-1.5 block">Brief标题</label>
<label className="text-sm text-text-secondary mb-1.5 block"></label>
<Input
value={brief.title}
onChange={(e) => setBrief({ ...brief, title: e.target.value })}
value={brandTone}
onChange={(e) => setBrandTone(e.target.value)}
placeholder="例如:年轻、活力、清新"
/>
</div>
<div>
<label className="text-sm text-text-secondary mb-1.5 block"></label>
<Input
type="date"
value={brief.deadline}
onChange={(e) => setBrief({ ...brief, deadline: e.target.value })}
/>
<label className="text-sm text-text-secondary mb-1.5 block"></label>
<div className="flex items-center gap-2">
<Input
type="number"
min={0}
value={minDuration ?? ''}
onChange={(e) => setMinDuration(e.target.value ? parseInt(e.target.value) : undefined)}
placeholder="最短"
/>
<span className="text-text-tertiary">~</span>
<Input
type="number"
min={0}
value={maxDuration ?? ''}
onChange={(e) => setMaxDuration(e.target.value ? parseInt(e.target.value) : undefined)}
placeholder="最长"
/>
</div>
</div>
</div>
{/* 其他要求 */}
<div>
<label className="text-sm text-text-secondary mb-1.5 block"></label>
<label className="text-sm text-text-secondary mb-1.5 block"></label>
<textarea
value={brief.description}
onChange={(e) => setBrief({ ...brief, description: e.target.value })}
value={otherRequirements}
onChange={(e) => setOtherRequirements(e.target.value)}
placeholder="简要描述项目要求..."
className="w-full h-24 p-3 rounded-xl bg-bg-elevated border border-border-subtle text-text-primary resize-none focus:outline-none focus:ring-2 focus:ring-accent-indigo"
/>
</div>
{/* 创作要求 */}
{/* 卖点 / 创作要求 */}
<div>
<label className="text-sm text-text-secondary mb-2 block"></label>
<label className="text-sm text-text-secondary mb-2 block"> / </label>
<div className="space-y-2">
{brief.requirements.map((req, index) => (
{sellingPoints.map((sp, index) => (
<div key={index} className="flex items-center gap-2 p-3 rounded-lg bg-bg-elevated">
<CheckCircle size={16} className="text-accent-green flex-shrink-0" />
<span className="flex-1 text-text-primary">{req}</span>
<button
type="button"
onClick={() => removeRequirement(index)}
onClick={() => toggleSellingPointRequired(index)}
title={sp.required ? '必选卖点(点击切换)' : '可选卖点(点击切换)'}
>
<CheckCircle size={16} className={sp.required ? 'text-accent-green' : 'text-text-tertiary'} />
</button>
<span className="flex-1 text-text-primary">{sp.content}</span>
{sp.required && <span className="text-xs text-accent-green"></span>}
<button
type="button"
onClick={() => removeSellingPoint(index)}
className="p-1 rounded hover:bg-bg-page text-text-tertiary hover:text-accent-coral transition-colors"
>
<Trash2 size={14} />
@@ -249,67 +425,72 @@ export default function ProjectConfigPage() {
))}
<div className="flex gap-2">
<Input
value={newRequirement}
onChange={(e) => setNewRequirement(e.target.value)}
placeholder="添加新的创作要求"
onKeyDown={(e) => e.key === 'Enter' && addRequirement()}
value={newSellingPoint}
onChange={(e) => setNewSellingPoint(e.target.value)}
placeholder="添加卖点或创作要求"
onKeyDown={(e) => e.key === 'Enter' && addSellingPoint()}
/>
<Button variant="secondary" onClick={addRequirement}>
<Button variant="secondary" onClick={addSellingPoint}>
<Plus size={16} />
</Button>
</div>
</div>
</div>
{/* 关键词 */}
{/* 禁止词 */}
<div>
<label className="text-sm text-text-secondary mb-2 block"></label>
<div className="flex flex-wrap gap-2 mb-3">
{brief.keywords.map((keyword) => (
<span
key={keyword}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full bg-accent-indigo/15 text-accent-indigo text-sm"
>
{keyword}
<label className="text-sm text-text-secondary mb-2 block flex items-center gap-2">
<AlertTriangle size={14} className="text-accent-coral" />
</label>
<div className="space-y-2 mb-3">
{blacklistWords.map((bw, index) => (
<div key={index} className="flex items-center gap-2 p-3 rounded-lg bg-bg-elevated">
<span className="text-accent-coral font-medium">{bw.word}</span>
{bw.reason && <span className="text-xs text-text-tertiary"> {bw.reason}</span>}
<div className="flex-1" />
<button
type="button"
onClick={() => removeKeyword(keyword)}
className="hover:text-accent-coral transition-colors"
onClick={() => removeBlacklistWord(index)}
className="p-1 rounded hover:bg-bg-page text-text-tertiary hover:text-accent-coral transition-colors"
>
×
<Trash2 size={14} />
</button>
</span>
</div>
))}
</div>
<div className="flex gap-2">
<Input
value={newKeyword}
onChange={(e) => setNewKeyword(e.target.value)}
placeholder="添加关键词"
onKeyDown={(e) => e.key === 'Enter' && addKeyword()}
value={newBlacklistWord}
onChange={(e) => setNewBlacklistWord(e.target.value)}
placeholder="禁止词"
onKeyDown={(e) => e.key === 'Enter' && addBlacklistWord()}
/>
<Button variant="secondary" onClick={addKeyword}>
<Input
value={newBlacklistReason}
onChange={(e) => setNewBlacklistReason(e.target.value)}
placeholder="原因(可选)"
onKeyDown={(e) => e.key === 'Enter' && addBlacklistWord()}
/>
<Button variant="secondary" onClick={addBlacklistWord}>
<Plus size={16} />
</Button>
</div>
</div>
{/* 违禁词 */}
{/* 竞品品牌 */}
<div>
<label className="text-sm text-text-secondary mb-2 block flex items-center gap-2">
<AlertTriangle size={14} className="text-accent-coral" />
</label>
<label className="text-sm text-text-secondary mb-2 block"></label>
<div className="flex flex-wrap gap-2 mb-3">
{brief.forbiddenWords.map((word) => (
{competitors.map((name) => (
<span
key={word}
key={name}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full bg-accent-coral/15 text-accent-coral text-sm"
>
{word}
{name}
<button
type="button"
onClick={() => removeForbiddenWord(word)}
onClick={() => removeCompetitor(name)}
className="hover:text-accent-coral/70 transition-colors"
>
×
@@ -319,12 +500,12 @@ export default function ProjectConfigPage() {
</div>
<div className="flex gap-2">
<Input
value={newForbiddenWord}
onChange={(e) => setNewForbiddenWord(e.target.value)}
placeholder="添加违禁词"
onKeyDown={(e) => e.key === 'Enter' && addForbiddenWord()}
value={newCompetitor}
onChange={(e) => setNewCompetitor(e.target.value)}
placeholder="添加竞品品牌名称"
onKeyDown={(e) => e.key === 'Enter' && addCompetitorItem()}
/>
<Button variant="secondary" onClick={addForbiddenWord}>
<Button variant="secondary" onClick={addCompetitorItem}>
<Plus size={16} />
</Button>
</div>
@@ -334,24 +515,39 @@ export default function ProjectConfigPage() {
<div>
<label className="text-sm text-text-secondary mb-2 block"></label>
<div className="space-y-2">
{brief.referenceLinks.map((link, index) => (
<div key={index} className="flex items-center gap-3 p-3 rounded-lg bg-bg-elevated">
{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">{link.title}</span>
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-accent-indigo hover:underline text-sm"
<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"
>
</a>
<Trash2 size={14} />
</button>
</div>
))}
<Button variant="secondary" className="w-full">
<Upload size={16} />
</Button>
<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>
</div>
</CardContent>
+264 -307
View File
@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
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'
@@ -8,6 +8,7 @@ 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,
@@ -25,51 +26,69 @@ import {
MoreHorizontal,
Trash2,
Check,
Pencil
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'
// 模拟项目详情数据
const mockProject = {
id: 'proj-001',
name: 'XX品牌618推广',
platform: 'douyin',
status: 'active',
deadline: '2026-06-18',
createdAt: '2026-02-01',
description: '618大促活动营销内容审核项目',
stats: {
scriptTotal: 20,
scriptPassed: 15,
scriptPending: 3,
scriptRejected: 2,
videoTotal: 20,
videoPassed: 12,
videoPending: 5,
videoRejected: 3,
},
// ==================== 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: '星耀传媒', creatorCount: 8, passRate: 92 },
{ id: 'AG456789', name: '创意无限', creatorCount: 5, passRate: 88 },
],
recentTasks: [
{ id: 'task-001', type: 'video', creatorName: '小美护肤', agencyId: 'AG789012', agencyName: '星耀传媒', status: 'pending', submittedAt: '2026-02-06 14:30' },
{ id: 'task-002', type: 'script', creatorName: '美妆Lisa', agencyId: 'AG789012', agencyName: '星耀传媒', status: 'approved', submittedAt: '2026-02-06 12:15' },
{ id: 'task-003', type: 'video', creatorName: '健身王', agencyId: 'AG456789', agencyName: '创意无限', status: 'rejected', submittedAt: '2026-02-06 10:00' },
{ id: 'task-004', type: 'script', creatorName: '时尚达人', agencyId: 'AG456789', agencyName: '创意无限', status: 'pending', submittedAt: '2026-02-05 16:45' },
{ id: 'AG789012', name: '星耀传媒' },
{ id: 'AG456789', name: '创意无限' },
],
task_count: 20,
created_at: '2026-02-01T00:00:00Z', updated_at: '2026-02-06T00:00:00Z',
}
// 模拟品牌方已添加的代理商(来自代理商管理)
const mockManagedAgencies = [
{ id: 'AG789012', name: '星耀传媒', companyName: '上海星耀文化传媒有限公司' },
{ id: 'AG456789', name: '创意无限', companyName: '深圳创意无限广告有限公司' },
{ id: 'AG123456', name: '美妆达人MCN', companyName: '杭州美妆达人网络科技有限公司' },
{ id: 'AG111111', name: '蓝海科技', companyName: '北京蓝海数字科技有限公司' },
{ id: 'AG222222', name: '云创网络', companyName: '杭州云创网络技术有限公司' },
{ id: 'AG333333', name: '天府传媒', companyName: '成都天府传媒集团有限公司' },
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>
@@ -88,98 +107,169 @@ function StatCard({ title, value, icon: Icon, color }: { title: string; value: n
)
}
function TaskStatusTag({ status }: { status: string }) {
switch (status) {
case 'approved': return <SuccessTag></SuccessTag>
case 'pending': return <PendingTag></PendingTag>
case 'rejected': return <ErrorTag></ErrorTag>
default: return <PendingTag></PendingTag>
}
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 [project, setProject] = useState(mockProject)
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<typeof project.agencies[0] | null>(null)
// 编辑截止日期
const [agencyToDelete, setAgencyToDelete] = useState<{ id: string; name: string } | null>(null)
const [showDeadlineModal, setShowDeadlineModal] = useState(false)
const [newDeadline, setNewDeadline] = useState(project.deadline)
const [newDeadline, setNewDeadline] = useState('')
const [submitting, setSubmitting] = useState(false)
// 保存截止日期
const handleSaveDeadline = () => {
if (!newDeadline) return
setProject({ ...project, deadline: newDeadline })
setShowDeadlineModal(false)
}
const loadData = useCallback(async () => {
if (USE_MOCK) {
setProject(mockProject)
setRecentTasks(mockTasks)
setManagedAgencies(mockManagedAgencies)
setLoading(false)
return
}
const scriptPassRate = Math.round((project.stats.scriptPassed / project.stats.scriptTotal) * 100)
const videoPassRate = Math.round((project.stats.videoPassed / project.stats.videoTotal) * 100)
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])
// 过滤可添加的代理商(排除已在项目中的)
const availableAgencies = mockManagedAgencies.filter(
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()) ||
agency.companyName.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]
prev.includes(agencyId) ? prev.filter(id => id !== agencyId) : [...prev, agencyId]
)
}
// 确认添加
const handleAddAgencies = () => {
const newAgencies = mockManagedAgencies
.filter(a => selectedAgencies.includes(a.id))
.map(a => ({ id: a.id, name: a.name, creatorCount: 0, passRate: 0 }))
setProject({
...project,
agencies: [...project.agencies, ...newAgencies]
})
setShowAddModal(false)
setSelectedAgencies([])
setSearchQuery('')
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
setProject({
...project,
agencies: project.agencies.filter(a => a.id !== agencyToDelete.id)
})
setShowDeleteModal(false)
setAgencyToDelete(null)
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 platform = getPlatformInfo(project.platform)
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">
@@ -191,42 +281,34 @@ export default function ProjectDetailPage() {
<div className="flex-1">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-text-primary">{project.name}</h1>
{platform && (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium ${platform.bgColor} ${platform.textColor} border ${platform.borderColor}`}>
<span>{platform.icon}</span>
{platform.name}
</span>
)}
</div>
<p className="text-sm text-text-secondary">{project.description}</p>
{project.description && (
<p className="text-sm text-text-secondary">{project.description}</p>
)}
</div>
<SuccessTag></SuccessTag>
<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}
: {project.deadline ? new Date(project.deadline).toLocaleDateString('zh-CN') : '未设置'}
<button
type="button"
onClick={() => {
setNewDeadline(project.deadline)
setShowDeadlineModal(true)
}}
onClick={() => { setNewDeadline(project.deadline || ''); setShowDeadlineModal(true) }}
className="p-1 rounded hover:bg-bg-elevated transition-colors"
title="修改截止日期"
>
<Pencil size={14} className="text-text-tertiary hover:text-accent-indigo" />
</button>
</span>
<span className="flex items-center gap-2">
<Clock size={16} />
: {project.createdAt}
: {new Date(project.created_at).toLocaleDateString('zh-CN')}
</span>
</div>
{/* Brief和规则配置 - 大按钮 */}
{/* Brief和规则配置 */}
<Link href={`/brand/projects/${projectId}/config`}>
<Card className="hover:border-accent-indigo transition-colors cursor-pointer">
<CardContent className="py-5">
@@ -248,76 +330,65 @@ export default function ProjectDetailPage() {
{/* 统计卡片 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<StatCard title="脚本通过率" value={`${scriptPassRate}%`} icon={FileText} color="text-accent-green" />
<StatCard title="视频通过率" value={`${videoPassRate}%`} icon={Video} color="text-accent-indigo" />
<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.stats.scriptPending + project.stats.videoPending} icon={Clock} color="text-orange-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></CardTitle>
<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 className="space-y-6">
{/* 脚本审核 */}
<div>
<div className="flex items-center justify-between mb-3">
<span className="flex items-center gap-2 text-text-primary font-medium">
<FileText size={16} />
</span>
<span className="text-sm text-text-secondary">
{project.stats.scriptPassed}/{project.stats.scriptTotal}
</span>
<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="flex h-4 rounded-full overflow-hidden bg-bg-elevated">
<div className="bg-accent-green" style={{ width: `${(project.stats.scriptPassed / project.stats.scriptTotal) * 100}%` }} />
<div className="bg-yellow-500" style={{ width: `${(project.stats.scriptPending / project.stats.scriptTotal) * 100}%` }} />
<div className="bg-accent-coral" style={{ width: `${(project.stats.scriptRejected / project.stats.scriptTotal) * 100}%` }} />
</div>
<div className="flex gap-6 mt-2 text-xs">
<span className="flex items-center gap-1 text-accent-green">
<CheckCircle size={12} /> {project.stats.scriptPassed}
</span>
<span className="flex items-center gap-1 text-yellow-500">
<Clock size={12} /> {project.stats.scriptPending}
</span>
<span className="flex items-center gap-1 text-accent-coral">
<XCircle size={12} /> {project.stats.scriptRejected}
</span>
</div>
</div>
{/* 视频审核 */}
<div>
<div className="flex items-center justify-between mb-3">
<span className="flex items-center gap-2 text-text-primary font-medium">
<Video size={16} />
</span>
<span className="text-sm text-text-secondary">
{project.stats.videoPassed}/{project.stats.videoTotal}
</span>
</div>
<div className="flex h-4 rounded-full overflow-hidden bg-bg-elevated">
<div className="bg-accent-green" style={{ width: `${(project.stats.videoPassed / project.stats.videoTotal) * 100}%` }} />
<div className="bg-yellow-500" style={{ width: `${(project.stats.videoPending / project.stats.videoTotal) * 100}%` }} />
<div className="bg-accent-coral" style={{ width: `${(project.stats.videoRejected / project.stats.videoTotal) * 100}%` }} />
</div>
<div className="flex gap-6 mt-2 text-xs">
<span className="flex items-center gap-1 text-accent-green">
<CheckCircle size={12} /> {project.stats.videoPassed}
</span>
<span className="flex items-center gap-1 text-yellow-500">
<Clock size={12} /> {project.stats.videoPending}
</span>
<span className="flex items-center gap-1 text-accent-coral">
<XCircle size={12} /> {project.stats.videoRejected}
</span>
</div>
</div>
) : (
<div className="text-center py-8 text-text-tertiary text-sm"></div>
)}
</CardContent>
</Card>
@@ -339,7 +410,7 @@ export default function ProjectDetailPage() {
</div>
<div>
<p className="font-medium text-text-primary text-sm">{agency.name}</p>
<p className="text-xs text-text-tertiary">{agency.creatorCount} · {agency.passRate}%</p>
<p className="text-xs text-text-tertiary">{agency.id}</p>
</div>
</div>
<div className="relative">
@@ -370,7 +441,6 @@ export default function ProjectDetailPage() {
</div>
))}
{/* 添加代理商按钮 */}
<button
type="button"
onClick={() => setShowAddModal(true)}
@@ -383,77 +453,14 @@ export default function ProjectDetailPage() {
</Card>
</div>
{/* 最近任务 */}
<Card>
<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>
<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>
<th className="pb-3 font-medium"></th>
</tr>
</thead>
<tbody>
{project.recentTasks.map((task) => (
<tr key={task.id} className="border-b border-border-subtle last:border-0 hover:bg-bg-elevated">
<td className="py-4">
<span className="flex items-center gap-2">
{task.type === 'script' ? <FileText size={16} className="text-accent-indigo" /> : <Video size={16} className="text-purple-400" />}
{task.type === 'script' ? '脚本' : '视频'}
</span>
</td>
<td className="py-4 text-text-primary">{task.creatorName}</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.agencyName}</span>
</span>
</td>
<td className="py-4"><TaskStatusTag status={task.status} /></td>
<td className="py-4 text-sm text-text-tertiary">{task.submittedAt}</td>
<td className="py-4">
<Link href={`/brand/review/${task.type}/${task.id}`}>
<Button size="sm" variant={task.status === 'pending' ? 'primary' : 'secondary'}>
{task.status === 'pending' ? '审核' : '查看'}
</Button>
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* 添加代理商弹窗 */}
<Modal
isOpen={showAddModal}
onClose={() => {
setShowAddModal(false)
setSearchQuery('')
setSelectedAgencies([])
}}
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
@@ -464,7 +471,6 @@ export default function ProjectDetailPage() {
/>
</div>
{/* 代理商列表 */}
<div className="max-h-80 overflow-y-auto space-y-2">
{filteredAgencies.length > 0 ? (
filteredAgencies.map((agency) => {
@@ -475,26 +481,22 @@ export default function ProjectDetailPage() {
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'
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" />
)}
{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>
<p className="text-sm text-text-secondary truncate">{agency.companyName}</p>
{agency.contact_name && (
<p className="text-sm text-text-secondary truncate">{agency.contact_name}</p>
)}
</div>
</button>
)
@@ -502,62 +504,39 @@ export default function ProjectDetailPage() {
) : (
<div className="text-center py-8 text-text-tertiary">
{availableAgencies.length === 0 ? (
<>
<Users size={32} className="mx-auto mb-2 opacity-50" />
<p></p>
</>
<><Users size={32} className="mx-auto mb-2 opacity-50" /><p></p></>
) : (
<>
<Search 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}>
<Plus size={16} />
<Button variant="primary" onClick={handleAddAgencies} disabled={submitting}>
{submitting && <Loader2 size={16} className="animate-spin" />}
</Button>
</div>
)}
{/* 底部提示 */}
<p className="text-xs text-text-tertiary pt-2">
"代理商管理"
</p>
</div>
</Modal>
{/* 删除确认弹窗 */}
<Modal
isOpen={showDeleteModal}
onClose={() => { setShowDeleteModal(false); setAgencyToDelete(null) }}
title="移除代理商"
>
<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>
<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}
>
<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>
@@ -565,21 +544,10 @@ export default function ProjectDetailPage() {
</Modal>
{/* 编辑截止日期弹窗 */}
<Modal
isOpen={showDeadlineModal}
onClose={() => setShowDeadlineModal(false)}
title="修改截止日期"
>
<Modal isOpen={showDeadlineModal} onClose={() => setShowDeadlineModal(false)} title="修改截止日期">
<div className="space-y-4">
<div className="p-3 rounded-lg bg-bg-elevated">
<p className="text-sm text-text-secondary"></p>
<p className="font-medium text-text-primary">{project.name}</p>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-2">
</label>
<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
@@ -590,21 +558,10 @@ export default function ProjectDetailPage() {
/>
</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}
>
<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>