feat: 完善品牌方和代理商前端功能

品牌方功能:
- 项目看板: 添加截止日期编辑功能
- 项目详情: 添加代理商管理、截止日期编辑、最近任务显示代理商
- 项目创建: 代理商选择支持搜索(名称/ID/公司名)
- 代理商管理: 通过ID邀请、添加备注/分配项目/移除操作
- Brief配置: 新增项目级Brief和规则配置页面
- 系统设置: 完善账户安全(密码/2FA/邮箱/手机/设备管理)、数据导出、退出登录

代理商功能:
- 个人中心: 新增代理商ID展示、公司信息(企业验证)、个人信息编辑
- 账户设置: 密码修改、手机/邮箱绑定、两步验证
- 通知设置: 分类型和渠道的通知开关
- 审核历史: 搜索筛选和统计展示
- 帮助反馈: FAQ分类搜索和客服联系

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-02-06 17:40:11 +08:00
co-authored by Claude Opus 4.5
parent ae74c515c7
commit 964797d2e9
14 changed files with 4349 additions and 154 deletions
@@ -0,0 +1,564 @@
'use client'
import { useState } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { Card, CardContent, CardHeader, CardTitle } 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
} from 'lucide-react'
// 模拟数据
const mockData = {
project: {
id: 'proj-001',
name: 'XX品牌618推广',
},
brief: {
title: 'XX品牌618推广Brief',
description: '本次618大促营销活动,需要达人围绕夏日护肤、美妆新品进行内容创作。',
requirements: [
'视频时长:60-90秒',
'必须展示产品使用过程',
'需要口播品牌slogan"XX品牌,夏日焕新"',
'背景音乐需使用品牌指定曲库',
],
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, // 申诉处理时限(小时)
},
},
}
// 严格程度选项
const strictnessOptions = [
{ value: 'low', label: '宽松', description: '仅检测明显违规内容' },
{ value: 'medium', label: '标准', description: '平衡检测,推荐使用' },
{ value: 'high', label: '严格', description: '严格检测,可能有较多误判' },
]
export default function ProjectConfigPage() {
const router = useRouter()
const params = useParams()
const projectId = params.id as string
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('')
const handleSave = async () => {
setIsSaving(true)
await new Promise(resolve => setTimeout(resolve, 1000))
setIsSaving(false)
alert('配置已保存')
}
const addRequirement = () => {
if (newRequirement.trim()) {
setBrief({ ...brief, requirements: [...brief.requirements, newRequirement.trim()] })
setNewRequirement('')
}
}
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('')
}
}
const removeKeyword = (keyword: string) => {
setBrief({ ...brief, keywords: brief.keywords.filter(k => k !== keyword) })
}
const addForbiddenWord = () => {
if (newForbiddenWord.trim() && !brief.forbiddenWords.includes(newForbiddenWord.trim())) {
setBrief({ ...brief, forbiddenWords: [...brief.forbiddenWords, newForbiddenWord.trim()] })
setNewForbiddenWord('')
}
}
const removeForbiddenWord = (word: string) => {
setBrief({ ...brief, forbiddenWords: brief.forbiddenWords.filter(w => w !== word) })
}
const toggleAiCheckItem = (itemId: string) => {
setRules({
...rules,
aiReview: {
...rules.aiReview,
checkItems: rules.aiReview.checkItems.map(item =>
item.id === itemId ? { ...item, enabled: !item.enabled } : item
),
},
})
}
const SectionHeader = ({ title, icon: Icon, section }: { title: string; icon: React.ElementType; section: string }) => (
<button
type="button"
onClick={() => setActiveSection(activeSection === section ? null : section)}
className="w-full flex items-center justify-between p-4 hover:bg-bg-elevated/50 rounded-xl transition-colors"
>
<span className="flex items-center gap-2 font-semibold text-text-primary">
<Icon size={18} className="text-accent-indigo" />
{title}
</span>
{activeSection === section ? (
<ChevronUp size={18} className="text-text-tertiary" />
) : (
<ChevronDown size={18} className="text-text-tertiary" />
)}
</button>
)
return (
<div className="space-y-6">
{/* 顶部导航 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<button
type="button"
onClick={() => router.back()}
className="p-2 rounded-lg hover:bg-bg-elevated transition-colors"
>
<ArrowLeft size={20} className="text-text-secondary" />
</button>
<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}
</p>
</div>
</div>
<Button variant="primary" onClick={handleSave} disabled={isSaving}>
{isSaving ? '保存中...' : (
<>
<Save size={16} />
</>
)}
</Button>
</div>
{/* Brief配置 */}
<Card>
<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>
<Input
value={brief.title}
onChange={(e) => setBrief({ ...brief, title: e.target.value })}
/>
</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 })}
/>
</div>
</div>
<div>
<label className="text-sm text-text-secondary mb-1.5 block"></label>
<textarea
value={brief.description}
onChange={(e) => setBrief({ ...brief, description: e.target.value })}
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>
<div className="space-y-2">
{brief.requirements.map((req, 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)}
className="p-1 rounded hover:bg-bg-page text-text-tertiary hover:text-accent-coral transition-colors"
>
<Trash2 size={14} />
</button>
</div>
))}
<div className="flex gap-2">
<Input
value={newRequirement}
onChange={(e) => setNewRequirement(e.target.value)}
placeholder="添加新的创作要求"
onKeyDown={(e) => e.key === 'Enter' && addRequirement()}
/>
<Button variant="secondary" onClick={addRequirement}>
<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}
<button
type="button"
onClick={() => removeKeyword(keyword)}
className="hover:text-accent-coral transition-colors"
>
×
</button>
</span>
))}
</div>
<div className="flex gap-2">
<Input
value={newKeyword}
onChange={(e) => setNewKeyword(e.target.value)}
placeholder="添加关键词"
onKeyDown={(e) => e.key === 'Enter' && addKeyword()}
/>
<Button variant="secondary" onClick={addKeyword}>
<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>
<div className="flex flex-wrap gap-2 mb-3">
{brief.forbiddenWords.map((word) => (
<span
key={word}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full bg-accent-coral/15 text-accent-coral text-sm"
>
{word}
<button
type="button"
onClick={() => removeForbiddenWord(word)}
className="hover:text-accent-coral/70 transition-colors"
>
×
</button>
</span>
))}
</div>
<div className="flex gap-2">
<Input
value={newForbiddenWord}
onChange={(e) => setNewForbiddenWord(e.target.value)}
placeholder="添加违禁词"
onKeyDown={(e) => e.key === 'Enter' && addForbiddenWord()}
/>
<Button variant="secondary" onClick={addForbiddenWord}>
<Plus size={16} />
</Button>
</div>
</div>
{/* 参考资料 */}
<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">
<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"
>
</a>
</div>
))}
<Button variant="secondary" className="w-full">
<Upload size={16} />
</Button>
</div>
</div>
</CardContent>
)}
</Card>
{/* AI审核规则 */}
<Card>
<SectionHeader title="AI审核规则" icon={Bot} section="ai" />
{activeSection === 'ai' && (
<CardContent className="space-y-6 pt-0">
{/* AI审核开关 */}
<div className="flex items-center justify-between p-4 rounded-xl bg-bg-elevated">
<div>
<p className="font-medium text-text-primary">AI自动审核</p>
<p className="text-sm text-text-secondary">AI预审</p>
</div>
<button
type="button"
onClick={() => setRules({ ...rules, aiReview: { ...rules.aiReview, enabled: !rules.aiReview.enabled } })}
className={`relative w-12 h-6 rounded-full transition-colors ${
rules.aiReview.enabled ? 'bg-accent-indigo' : 'bg-bg-page'
}`}
>
<span
className={`absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform ${
rules.aiReview.enabled ? 'left-7' : 'left-1'
}`}
/>
</button>
</div>
{rules.aiReview.enabled && (
<>
{/* 严格程度 */}
<div>
<label className="text-sm text-text-secondary mb-2 block"></label>
<div className="grid grid-cols-3 gap-3">
{strictnessOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setRules({ ...rules, aiReview: { ...rules.aiReview, strictness: option.value } })}
className={`p-4 rounded-xl border-2 text-left transition-all ${
rules.aiReview.strictness === option.value
? 'border-accent-indigo bg-accent-indigo/10'
: 'border-border-subtle hover:border-border-subtle/80'
}`}
>
<p className={`font-medium ${rules.aiReview.strictness === option.value ? 'text-accent-indigo' : 'text-text-primary'}`}>
{option.label}
</p>
<p className="text-xs text-text-tertiary mt-1">{option.description}</p>
</button>
))}
</div>
</div>
{/* 检测项目 */}
<div>
<label className="text-sm text-text-secondary mb-2 block"></label>
<div className="space-y-2">
{rules.aiReview.checkItems.map((item) => (
<div
key={item.id}
className="flex items-center justify-between p-3 rounded-lg bg-bg-elevated"
>
<span className="text-text-primary">{item.name}</span>
<button
type="button"
onClick={() => toggleAiCheckItem(item.id)}
className={`relative w-10 h-5 rounded-full transition-colors ${
item.enabled ? 'bg-accent-green' : 'bg-bg-page'
}`}
>
<span
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${
item.enabled ? 'left-5' : 'left-0.5'
}`}
/>
</button>
</div>
))}
</div>
</div>
</>
)}
</CardContent>
)}
</Card>
{/* 人工审核规则 */}
<Card>
<SectionHeader title="人工审核规则" icon={Users} section="manual" />
{activeSection === 'manual' && (
<CardContent className="space-y-4 pt-0">
<div className="flex items-center justify-between p-4 rounded-xl bg-bg-elevated">
<div>
<p className="font-medium text-text-primary"></p>
<p className="text-sm text-text-secondary">/</p>
</div>
<button
type="button"
onClick={() => setRules({ ...rules, manualReview: { ...rules.manualReview, scriptRequired: !rules.manualReview.scriptRequired } })}
className={`relative w-12 h-6 rounded-full transition-colors ${
rules.manualReview.scriptRequired ? 'bg-accent-indigo' : 'bg-bg-page'
}`}
>
<span className={`absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform ${
rules.manualReview.scriptRequired ? 'left-7' : 'left-1'
}`} />
</button>
</div>
<div className="flex items-center justify-between p-4 rounded-xl bg-bg-elevated">
<div>
<p className="font-medium text-text-primary"></p>
<p className="text-sm text-text-secondary">/</p>
</div>
<button
type="button"
onClick={() => setRules({ ...rules, manualReview: { ...rules.manualReview, videoRequired: !rules.manualReview.videoRequired } })}
className={`relative w-12 h-6 rounded-full transition-colors ${
rules.manualReview.videoRequired ? 'bg-accent-indigo' : 'bg-bg-page'
}`}
>
<span className={`absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform ${
rules.manualReview.videoRequired ? 'left-7' : 'left-1'
}`} />
</button>
</div>
<div className="flex items-center justify-between p-4 rounded-xl bg-bg-elevated">
<div>
<p className="font-medium text-text-primary"></p>
<p className="text-sm text-text-secondary">/</p>
</div>
<button
type="button"
onClick={() => setRules({ ...rules, manualReview: { ...rules.manualReview, agencyCanApprove: !rules.manualReview.agencyCanApprove } })}
className={`relative w-12 h-6 rounded-full transition-colors ${
rules.manualReview.agencyCanApprove ? 'bg-accent-indigo' : 'bg-bg-page'
}`}
>
<span className={`absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform ${
rules.manualReview.agencyCanApprove ? 'left-7' : 'left-1'
}`} />
</button>
</div>
<div className="flex items-center justify-between p-4 rounded-xl bg-bg-elevated">
<div>
<p className="font-medium text-text-primary"></p>
<p className="text-sm text-text-secondary"></p>
</div>
<button
type="button"
onClick={() => setRules({ ...rules, manualReview: { ...rules.manualReview, brandFinalReview: !rules.manualReview.brandFinalReview } })}
className={`relative w-12 h-6 rounded-full transition-colors ${
rules.manualReview.brandFinalReview ? 'bg-accent-indigo' : 'bg-bg-page'
}`}
>
<span className={`absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform ${
rules.manualReview.brandFinalReview ? 'left-7' : 'left-1'
}`} />
</button>
</div>
</CardContent>
)}
</Card>
{/* 申诉规则 */}
<Card>
<SectionHeader title="申诉规则" icon={Shield} section="appeal" />
{activeSection === 'appeal' && (
<CardContent className="space-y-4 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"></label>
<Input
type="number"
min={1}
max={10}
value={rules.appealRules.maxAppeals}
onChange={(e) => setRules({
...rules,
appealRules: { ...rules.appealRules, maxAppeals: parseInt(e.target.value) || 1 }
})}
/>
<p className="text-xs text-text-tertiary mt-1"></p>
</div>
<div>
<label className="text-sm text-text-secondary mb-1.5 block"></label>
<Input
type="number"
min={1}
max={168}
value={rules.appealRules.appealDeadline}
onChange={(e) => setRules({
...rules,
appealRules: { ...rules.appealRules, appealDeadline: parseInt(e.target.value) || 24 }
})}
/>
<p className="text-xs text-text-tertiary mt-1"></p>
</div>
</div>
</CardContent>
)}
</Card>
</div>
)
}
+357 -24
View File
@@ -5,18 +5,27 @@ 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 { SuccessTag, PendingTag, WarningTag, ErrorTag } from '@/components/ui/Tag'
import { Input } from '@/components/ui/Input'
import { Modal } from '@/components/ui/Modal'
import { SuccessTag, PendingTag, ErrorTag } from '@/components/ui/Tag'
import {
ArrowLeft,
Calendar,
Users,
FileText,
Video,
TrendingUp,
Clock,
CheckCircle,
XCircle,
ChevronRight
ChevronRight,
Plus,
Settings,
Search,
Building2,
MoreHorizontal,
Trash2,
Check,
Pencil
} from 'lucide-react'
// 模拟项目详情数据
@@ -27,7 +36,6 @@ const mockProject = {
deadline: '2026-06-18',
createdAt: '2026-02-01',
description: '618大促活动营销内容审核项目',
briefUrl: '/briefs/xx-brand-618.pdf',
stats: {
scriptTotal: 20,
scriptPassed: 15,
@@ -39,18 +47,27 @@ const mockProject = {
videoRejected: 3,
},
agencies: [
{ id: 'agency-001', name: '星耀传媒', creatorCount: 8, passRate: 92 },
{ id: 'agency-002', name: '创意无限', creatorCount: 5, passRate: 88 },
{ id: 'agency-003', name: '美妆达人MCN', creatorCount: 2, passRate: 75 },
{ id: 'AG789012', name: '星耀传媒', creatorCount: 8, passRate: 92 },
{ id: 'AG456789', name: '创意无限', creatorCount: 5, passRate: 88 },
],
recentTasks: [
{ id: 'task-001', type: 'video', creatorName: '小美护肤', status: 'pending', submittedAt: '2026-02-06 14:30' },
{ id: 'task-002', type: 'script', creatorName: '美妆Lisa', status: 'approved', submittedAt: '2026-02-06 12:15' },
{ id: 'task-003', type: 'video', creatorName: '健身王', status: 'rejected', submittedAt: '2026-02-06 10:00' },
{ id: 'task-004', type: 'script', creatorName: '时尚达人', status: 'pending', submittedAt: '2026-02-05 16:45' },
{ 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' },
],
}
// 模拟品牌方已添加的代理商(来自代理商管理)
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: '成都天府传媒集团有限公司' },
]
function StatCard({ title, value, icon: Icon, color }: { title: string; value: number | string; icon: React.ElementType; color: string }) {
return (
<Card>
@@ -81,11 +98,85 @@ function TaskStatusTag({ status }: { status: string }) {
export default function ProjectDetailPage() {
const router = useRouter()
const params = useParams()
const project = mockProject
const projectId = params.id as string
const [project, setProject] = useState(mockProject)
// 添加代理商相关状态
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 [showDeadlineModal, setShowDeadlineModal] = useState(false)
const [newDeadline, setNewDeadline] = useState(project.deadline)
// 保存截止日期
const handleSaveDeadline = () => {
if (!newDeadline) return
setProject({ ...project, deadline: newDeadline })
setShowDeadlineModal(false)
}
const scriptPassRate = Math.round((project.stats.scriptPassed / project.stats.scriptTotal) * 100)
const videoPassRate = Math.round((project.stats.videoPassed / project.stats.videoTotal) * 100)
// 过滤可添加的代理商(排除已在项目中的)
const availableAgencies = mockManagedAgencies.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())
)
// 切换选择
const toggleSelectAgency = (agencyId: string) => {
setSelectedAgencies(prev =>
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 handleRemoveAgency = async () => {
if (!agencyToDelete) return
setProject({
...project,
agencies: project.agencies.filter(a => a.id !== agencyToDelete.id)
})
setShowDeleteModal(false)
setAgencyToDelete(null)
}
return (
<div className="space-y-6">
{/* 顶部导航 */}
@@ -105,17 +196,44 @@ export default function ProjectDetailPage() {
<span className="flex items-center gap-2">
<Calendar size={16} />
: {project.deadline}
<button
type="button"
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}
</span>
<Link href={project.briefUrl} className="flex items-center gap-2 text-accent-indigo hover:underline">
<FileText size={16} />
Brief
</Link>
</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={`${scriptPassRate}%`} icon={FileText} color="text-accent-green" />
@@ -197,20 +315,58 @@ export default function ProjectDetailPage() {
<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-3">
<CardContent className="space-y-2">
{project.agencies.map((agency) => (
<div key={agency.id} className="p-3 rounded-lg bg-bg-elevated">
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-text-primary">{agency.name}</span>
<span className={`text-sm font-medium ${agency.passRate >= 90 ? 'text-accent-green' : agency.passRate >= 80 ? 'text-accent-indigo' : 'text-orange-400'}`}>
{agency.passRate}%
</span>
<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.creatorCount} · {agency.passRate}%</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 className="text-xs text-text-secondary">{agency.creatorCount} </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>
@@ -234,6 +390,7 @@ export default function ProjectDetailPage() {
<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>
@@ -249,6 +406,12 @@ export default function ProjectDetailPage() {
</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">
@@ -265,6 +428,176 @@ export default function ProjectDetailPage() {
</div>
</CardContent>
</Card>
{/* 添加代理商弹窗 */}
<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>
<p className="text-sm text-text-secondary truncate">{agency.companyName}</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}>
<Plus size={16} />
</Button>
</div>
)}
{/* 底部提示 */}
<p className="text-xs text-text-tertiary pt-2">
"代理商管理"
</p>
</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}
>
</Button>
</div>
</div>
</Modal>
{/* 编辑截止日期弹窗 */}
<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>
<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}
>
</Button>
</div>
</div>
</Modal>
</div>
)
}
+86 -38
View File
@@ -12,15 +12,19 @@ import {
FileText,
CheckCircle,
X,
Users
Users,
Search,
Building2
} from 'lucide-react'
// 模拟代理商列表
// 模拟品牌方已添加的代理商(来自代理商管理)
const mockAgencies = [
{ id: 'agency-001', name: '星耀传媒', creatorCount: 50, passRate: 92 },
{ id: 'agency-002', name: '创意无限', creatorCount: 35, passRate: 88 },
{ id: 'agency-003', name: '美妆达人MCN', creatorCount: 28, passRate: 82 },
{ id: 'agency-004', name: '时尚风向标', creatorCount: 42, passRate: 85 },
{ id: 'AG789012', name: '星耀传媒', companyName: '上海星耀文化传媒有限公司', creatorCount: 50, passRate: 92 },
{ id: 'AG456789', name: '创意无限', companyName: '深圳创意无限广告有限公司', creatorCount: 35, passRate: 88 },
{ id: 'AG123456', name: '美妆达人MCN', companyName: '杭州美妆达人网络科技有限公司', creatorCount: 28, passRate: 82 },
{ id: 'AG111111', name: '蓝海科技', companyName: '北京蓝海数字科技有限公司', creatorCount: 42, passRate: 85 },
{ id: 'AG222222', name: '云创网络', companyName: '杭州云创网络技术有限公司', creatorCount: 30, passRate: 90 },
{ id: 'AG333333', name: '天府传媒', companyName: '成都天府传媒集团有限公司', creatorCount: 25, passRate: 87 },
]
export default function CreateProjectPage() {
@@ -30,6 +34,15 @@ export default function CreateProjectPage() {
const [briefFile, setBriefFile] = useState<File | null>(null)
const [selectedAgencies, setSelectedAgencies] = useState<string[]>([])
const [isSubmitting, setIsSubmitting] = useState(false)
const [agencySearch, setAgencySearch] = useState('')
// 搜索过滤代理商
const filteredAgencies = mockAgencies.filter(agency =>
agencySearch === '' ||
agency.name.toLowerCase().includes(agencySearch.toLowerCase()) ||
agency.id.toLowerCase().includes(agencySearch.toLowerCase()) ||
agency.companyName.toLowerCase().includes(agencySearch.toLowerCase())
)
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
@@ -145,41 +158,76 @@ export default function CreateProjectPage() {
{selectedAgencies.length}
</span>
</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{mockAgencies.map((agency) => {
const isSelected = selectedAgencies.includes(agency.id)
return (
<button
key={agency.id}
type="button"
onClick={() => toggleAgency(agency.id)}
className={`p-4 rounded-lg 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 justify-between">
<div>
<div className="font-medium text-text-primary">{agency.name}</div>
<div className="flex items-center gap-4 mt-1 text-sm text-text-secondary">
<span className="flex items-center gap-1">
<Users size={14} />
{agency.creatorCount}
</span>
<span className={agency.passRate >= 90 ? 'text-accent-green' : agency.passRate >= 80 ? 'text-accent-indigo' : 'text-orange-400'}>
{agency.passRate}%
</span>
{/* 搜索框 */}
<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>
{/* 代理商列表 */}
<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>
<p className="text-sm text-text-secondary truncate mt-0.5">{agency.companyName}</p>
<div className="flex items-center gap-4 mt-1.5 text-xs text-text-tertiary">
<span className="flex items-center gap-1">
<Users size={12} />
{agency.creatorCount}
</span>
<span className={agency.passRate >= 90 ? 'text-accent-green' : agency.passRate >= 80 ? 'text-accent-indigo' : 'text-orange-400'}>
{agency.passRate}%
</span>
</div>
</div>
</div>
{isSelected && (
<CheckCircle size={20} className="text-accent-indigo" />
)}
</div>
</button>
)
})}
</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>
{/* 操作按钮 */}