Your Name 8eb8100cf4 fix: P0 安全加固 + 前端错误边界 + ESLint 修复
后端:
- 实现登出 API(清除 refresh token)
- 清除 videos.py 中已被 Celery 任务取代的死代码
- 添加速率限制中间件(60次/分钟,登录10次/分钟)
- 添加 SECRET_KEY/ENCRYPTION_KEY 默认值警告
- OSS STS 方法回退到 Policy 签名(不再抛异常)

前端:
- 添加全局 404/error/loading 页面
- 添加三端 error.tsx + loading.tsx 错误边界
- 修复 useId 条件调用违反 Hooks 规则
- 修复未转义引号和 Image 命名冲突
- 添加 ESLint 配置

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 17:18:04 +08:00

328 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import {
ArrowLeft,
Upload,
Calendar,
FileText,
CheckCircle,
X,
Users,
Search,
Building2,
Check,
Loader2
} from 'lucide-react'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import { useOSSUpload } from '@/hooks/useOSSUpload'
import type { AgencyDetail } from '@/types/organization'
// ==================== Mock 数据 ====================
const mockAgencies: AgencyDetail[] = [
{ id: 'AG789012', name: '星耀传媒', force_pass_enabled: true },
{ id: 'AG456789', name: '创意无限', force_pass_enabled: false },
{ id: 'AG123456', name: '美妆达人MCN', force_pass_enabled: false },
{ id: 'AG111111', name: '蓝海科技', force_pass_enabled: true },
{ id: 'AG222222', name: '云创网络', force_pass_enabled: false },
{ id: 'AG333333', name: '天府传媒', force_pass_enabled: true },
]
export default function CreateProjectPage() {
const router = useRouter()
const toast = useToast()
const { upload, isUploading, progress: uploadProgress } = useOSSUpload('general')
const [projectName, setProjectName] = useState('')
const [description, setDescription] = useState('')
const [deadline, setDeadline] = useState('')
const [briefFile, setBriefFile] = useState<File | null>(null)
const [briefFileUrl, setBriefFileUrl] = useState<string | null>(null)
const [selectedAgencies, setSelectedAgencies] = useState<string[]>([])
const [isSubmitting, setIsSubmitting] = useState(false)
const [agencySearch, setAgencySearch] = useState('')
const [agencies, setAgencies] = useState<AgencyDetail[]>([])
const [loadingAgencies, setLoadingAgencies] = useState(true)
useEffect(() => {
const loadAgencies = async () => {
if (USE_MOCK) {
setAgencies(mockAgencies)
setLoadingAgencies(false)
return
}
try {
const data = await api.listBrandAgencies()
setAgencies(data.items)
} catch (err) {
console.error('Failed to load agencies:', err)
toast.error('加载代理商列表失败')
} finally {
setLoadingAgencies(false)
}
}
loadAgencies()
}, [toast])
const filteredAgencies = agencies.filter(agency =>
agencySearch === '' ||
agency.name.toLowerCase().includes(agencySearch.toLowerCase()) ||
agency.id.toLowerCase().includes(agencySearch.toLowerCase())
)
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setBriefFile(file)
if (!USE_MOCK) {
try {
const result = await upload(file)
setBriefFileUrl(result.url)
} catch (err) {
toast.error('文件上传失败')
setBriefFile(null)
}
} else {
setBriefFileUrl('mock://brief-file.pdf')
}
}
const toggleAgency = (agencyId: string) => {
setSelectedAgencies(prev =>
prev.includes(agencyId)
? prev.filter(id => id !== agencyId)
: [...prev, agencyId]
)
}
const handleSubmit = async () => {
if (!projectName.trim() || !deadline || selectedAgencies.length === 0) {
toast.error('请填写完整信息')
return
}
setIsSubmitting(true)
try {
if (USE_MOCK) {
await new Promise(resolve => setTimeout(resolve, 1000))
} else {
const project = await api.createProject({
name: projectName.trim(),
description: description.trim() || undefined,
deadline,
agency_ids: selectedAgencies,
})
// If brief file was uploaded, create brief
if (briefFileUrl && briefFile) {
await api.createBrief(project.id, {
file_url: briefFileUrl,
file_name: briefFile.name,
})
}
}
toast.success('项目创建成功!')
router.push('/brand')
} catch (err) {
console.error('Failed to create project:', err)
toast.error('创建失败,请重试')
} finally {
setIsSubmitting(false)
}
}
const isValid = projectName.trim() && deadline && selectedAgencies.length > 0
return (
<div className="space-y-6 max-w-4xl">
<div className="flex items-center gap-4">
<button type="button" onClick={() => router.back()} className="p-2 hover:bg-bg-elevated rounded-full">
<ArrowLeft size={20} className="text-text-primary" />
</button>
<h1 className="text-2xl font-bold text-text-primary"></h1>
</div>
<Card>
<CardContent className="p-6 space-y-6">
{/* 项目名称 */}
<div>
<label className="block text-sm font-medium text-text-primary mb-2">
<span className="text-accent-coral">*</span>
</label>
<input
type="text"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
placeholder="例如XX品牌618推广"
className="w-full px-4 py-3 border border-border-subtle rounded-lg bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
/>
</div>
{/* 项目描述 */}
<div>
<label className="block text-sm font-medium text-text-primary mb-2"></label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="简要描述项目目标和要求..."
className="w-full h-24 px-4 py-3 border border-border-subtle rounded-lg bg-bg-elevated text-text-primary resize-none focus:outline-none focus:ring-2 focus:ring-accent-indigo"
/>
</div>
{/* 截止日期 */}
<div>
<label className="block text-sm font-medium text-text-primary mb-2">
<span className="text-accent-coral">*</span>
</label>
<div className="relative">
<Calendar size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-text-tertiary" />
<input
type="date"
value={deadline}
onChange={(e) => setDeadline(e.target.value)}
className="w-full pl-12 pr-4 py-3 border border-border-subtle rounded-lg bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
/>
</div>
</div>
{/* Brief 上传 */}
<div>
<label className="block text-sm font-medium text-text-primary mb-2"> Brief</label>
<div className="border-2 border-dashed border-border-subtle rounded-lg p-8 text-center hover:border-accent-indigo/50 transition-colors">
{briefFile ? (
<div className="flex items-center justify-center gap-3">
<FileText size={24} className="text-accent-indigo" />
<span className="text-text-primary">{briefFile.name}</span>
{isUploading && (
<span className="text-xs text-text-tertiary">{uploadProgress}%</span>
)}
<button
type="button"
onClick={() => { setBriefFile(null); setBriefFileUrl(null) }}
className="p-1 hover:bg-bg-elevated rounded-full"
>
<X size={16} className="text-text-tertiary" />
</button>
</div>
) : (
<label className="cursor-pointer">
<Upload size={32} className="mx-auto text-text-tertiary mb-3" />
<p className="text-text-secondary mb-1"> Brief </p>
<p className="text-xs text-text-tertiary"> PDFWordExcel </p>
<input
type="file"
accept=".pdf,.doc,.docx,.xls,.xlsx"
onChange={handleFileChange}
className="hidden"
/>
</label>
)}
</div>
</div>
{/* 选择代理商 */}
<div>
<label className="block text-sm font-medium text-text-primary mb-2">
<span className="text-accent-coral">*</span>
<span className="text-text-tertiary font-normal ml-2">
{selectedAgencies.length}
</span>
</label>
<div className="relative mb-4">
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-text-tertiary" />
<input
type="text"
value={agencySearch}
onChange={(e) => setAgencySearch(e.target.value)}
placeholder="搜索代理商名称或ID..."
className="w-full pl-11 pr-4 py-3 border border-border-subtle rounded-lg bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
/>
</div>
{loadingAgencies ? (
<div className="flex items-center justify-center py-8 text-text-tertiary">
<Loader2 size={20} className="animate-spin mr-2" />
...
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 max-h-80 overflow-y-auto">
{filteredAgencies.length > 0 ? (
filteredAgencies.map((agency) => {
const isSelected = selectedAgencies.includes(agency.id)
return (
<button
key={agency.id}
type="button"
onClick={() => toggleAgency(agency.id)}
className={`p-4 rounded-xl border-2 text-left transition-all ${
isSelected
? 'border-accent-indigo bg-accent-indigo/10'
: 'border-border-subtle hover:border-accent-indigo/50'
}`}
>
<div className="flex items-start gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${
isSelected ? 'bg-accent-indigo' : 'bg-accent-indigo/15'
}`}>
{isSelected ? (
<CheckCircle size={20} className="text-white" />
) : (
<Building2 size={20} className="text-accent-indigo" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-text-primary">{agency.name}</span>
<span className="text-xs text-text-tertiary font-mono">{agency.id}</span>
</div>
{agency.contact_name && (
<p className="text-sm text-text-secondary mt-0.5">{agency.contact_name}</p>
)}
</div>
</div>
</button>
)
})
) : (
<div className="col-span-2 text-center py-8 text-text-tertiary">
<Search size={32} className="mx-auto mb-2 opacity-50" />
<p></p>
</div>
)}
</div>
)}
<p className="text-xs text-text-tertiary mt-3">
&ldquo;&rdquo;
</p>
</div>
{/* 操作按钮 */}
<div className="flex items-center justify-end gap-4 pt-4 border-t border-border-subtle">
<Button variant="secondary" onClick={() => router.back()}>
</Button>
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting || isUploading}>
{isSubmitting ? (
<>
<Loader2 size={16} className="animate-spin" />
...
</>
) : '创建项目'}
</Button>
</div>
</CardContent>
</Card>
</div>
)
}