fix: 修复前端代码质量问题

- 创建 Toast 通知组件,替换所有 alert() 调用
- 修复 useReview hook 内存泄漏(setInterval 清理)
- 移除所有 console.error 和 console.log 语句
- 为复制操作失败添加用户友好的 toast 提示

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-02-09 12:48:22 +08:00
parent a5a005db0c
commit 37ac749071
33 changed files with 519 additions and 91 deletions

View File

@ -2,6 +2,7 @@
import { useState } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import {
@ -67,6 +68,7 @@ const statusConfig: Record<AppealStatus, { label: string; color: string; bgColor
export default function AgencyAppealDetailPage() {
const router = useRouter()
const toast = useToast()
const params = useParams()
const [appeal] = useState(mockAppealDetail)
const [replyContent, setReplyContent] = useState('')
@ -77,25 +79,25 @@ export default function AgencyAppealDetailPage() {
const handleApprove = async () => {
if (!replyContent.trim()) {
alert('请填写处理意见')
toast.error('请填写处理意见')
return
}
setIsSubmitting(true)
// 模拟提交
await new Promise(resolve => setTimeout(resolve, 1000))
alert('申诉已通过')
toast.success('申诉已通过')
router.push('/agency/appeals')
}
const handleReject = async () => {
if (!replyContent.trim()) {
alert('请填写驳回原因')
toast.error('请填写驳回原因')
return
}
setIsSubmitting(true)
// 模拟提交
await new Promise(resolve => setTimeout(resolve, 1000))
alert('申诉已驳回')
toast.success('申诉已驳回')
router.push('/agency/appeals')
}

View File

@ -2,6 +2,7 @@
import { useState } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Modal } from '@/components/ui/Modal'
@ -127,6 +128,7 @@ const platformRules = {
export default function BriefConfigPage() {
const router = useRouter()
const params = useParams()
const toast = useToast()
// 品牌方 Brief只读
const [brandBrief] = useState(mockBrandBrief)
@ -151,7 +153,7 @@ export default function BriefConfigPage() {
// 下载文件
const handleDownload = (file: BriefFile) => {
alert(`下载文件: ${file.name}`)
toast.info(`下载文件: ${file.name}`)
}
// 预览文件
@ -164,7 +166,7 @@ export default function BriefConfigPage() {
setIsExporting(true)
await new Promise(resolve => setTimeout(resolve, 1500))
setIsExporting(false)
alert('平台规则文档已导出!')
toast.success('平台规则文档已导出!')
}
// AI 解析
@ -172,7 +174,7 @@ export default function BriefConfigPage() {
setIsAIParsing(true)
await new Promise(resolve => setTimeout(resolve, 2000))
setIsAIParsing(false)
alert('AI 解析完成!')
toast.success('AI 解析完成!')
}
// 保存配置
@ -180,7 +182,7 @@ export default function BriefConfigPage() {
setIsSaving(true)
await new Promise(resolve => setTimeout(resolve, 1000))
setIsSaving(false)
alert('配置已保存!')
toast.success('配置已保存!')
}
// 卖点操作
@ -243,7 +245,7 @@ export default function BriefConfigPage() {
agencyFiles: [...prev.agencyFiles, newFile]
}))
setIsUploading(false)
alert('文档上传成功!')
toast.success('文档上传成功!')
}
const removeAgencyFile = (id: string) => {
@ -258,7 +260,7 @@ export default function BriefConfigPage() {
}
const handleDownloadAgencyFile = (file: AgencyFile) => {
alert(`下载文件: ${file.name}`)
toast.info(`下载文件: ${file.name}`)
}
return (

View File

@ -1,6 +1,7 @@
'use client'
import { useState } from 'react'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Modal } from '@/components/ui/Modal'
@ -165,6 +166,7 @@ function StageTag({ stage }: { stage: TaskStage }) {
}
export default function AgencyCreatorsPage() {
const toast = useToast()
const [searchQuery, setSearchQuery] = useState('')
const [showInviteModal, setShowInviteModal] = useState(false)
const [inviteCreatorId, setInviteCreatorId] = useState('')
@ -299,7 +301,7 @@ export default function AgencyCreatorsPage() {
const handleConfirmAssign = () => {
if (assignModal.creator && selectedProject) {
const project = mockProjects.find(p => p.id === selectedProject)
alert(`已将达人「${assignModal.creator.name}」分配到项目「${project?.name}`)
toast.success(`已将达人「${assignModal.creator.name}」分配到项目「${project?.name}`)
}
setAssignModal({ open: false, creator: null })
setSelectedProject('')

View File

@ -2,6 +2,7 @@
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
@ -120,6 +121,7 @@ const mockCurrentCompany = {
export default function AgencyCompanyPage() {
const router = useRouter()
const toast = useToast()
const [isEditing, setIsEditing] = useState(false)
const [formData, setFormData] = useState(mockCurrentCompany)
const [isSaving, setIsSaving] = useState(false)
@ -182,7 +184,7 @@ export default function AgencyCompanyPage() {
// 提交验证
const handleSubmitVerify = async () => {
if (!verifyCode.trim()) {
alert('请输入验证信息')
toast.error('请输入验证信息')
return
}
@ -196,7 +198,7 @@ export default function AgencyCompanyPage() {
setVerifyMethod(null)
setVerifyStep(1)
setVerifyCode('')
alert('企业认证成功!')
toast.success('企业认证成功')
}
const handleSave = async () => {
@ -204,7 +206,7 @@ export default function AgencyCompanyPage() {
await new Promise(resolve => setTimeout(resolve, 1000))
setIsSaving(false)
setIsEditing(false)
alert('公司信息已保存')
toast.success('公司信息已保存')
}
// 认证状态显示

View File

@ -2,6 +2,7 @@
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
@ -26,6 +27,7 @@ const mockUserData = {
export default function AgencyProfileEditPage() {
const router = useRouter()
const toast = useToast()
const [formData, setFormData] = useState(mockUserData)
const [isSaving, setIsSaving] = useState(false)
const [copied, setCopied] = useState(false)
@ -35,8 +37,8 @@ export default function AgencyProfileEditPage() {
await navigator.clipboard.writeText(formData.agencyId)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('复制失败:', err)
} catch {
toast.error('复制失败,请重试')
}
}
@ -44,7 +46,7 @@ export default function AgencyProfileEditPage() {
setIsSaving(true)
await new Promise(resolve => setTimeout(resolve, 1000))
setIsSaving(false)
alert('个人信息已保存')
toast.success('个人信息已保存')
router.back()
}

View File

@ -17,6 +17,7 @@ import {
FileCheck
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useToast } from '@/components/ui/Toast'
// 代理商数据
const mockAgency = {
@ -87,6 +88,7 @@ const menuItems = [
// 代理商卡片组件
function AgencyCard() {
const toast = useToast()
const [copied, setCopied] = useState(false)
// 复制代理商ID
@ -95,8 +97,8 @@ function AgencyCard() {
await navigator.clipboard.writeText(mockAgency.agencyId)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('复制失败:', err)
} catch {
toast.error('复制失败,请重试')
}
}

View File

@ -1,6 +1,7 @@
'use client'
import { useState } from 'react'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Modal } from '@/components/ui/Modal'
@ -179,6 +180,7 @@ export default function AgencyReportsPage() {
const [exportFormat, setExportFormat] = useState<'csv' | 'excel' | 'pdf'>('excel')
const [isExporting, setIsExporting] = useState(false)
const [exportSuccess, setExportSuccess] = useState(false)
const toast = useToast()
const currentData = mockDataByRange[dateRange]
@ -199,9 +201,9 @@ export default function AgencyReportsPage() {
downloadFile(csvContent, `${fileName}.csv`, 'text/csv')
} else if (exportFormat === 'excel') {
// 实际项目中会使用 xlsx 库
alert(`Excel 文件「${fileName}.xlsx」已开始下载`)
toast.info(`Excel 文件「${fileName}.xlsx」已开始下载`)
} else {
alert(`PDF 文件「${fileName}.pdf」已开始下载`)
toast.info(`PDF 文件「${fileName}.pdf」已开始下载`)
}
setIsExporting(false)

View File

@ -2,6 +2,7 @@
import { useState } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { useToast } from '@/components/ui/Toast'
import { ArrowLeft, Play, Pause, AlertTriangle, Shield, Radio } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
@ -85,6 +86,7 @@ function formatTimestamp(seconds: number): string {
export default function ReviewPage() {
const router = useRouter()
const params = useParams()
const toast = useToast()
const [isPlaying, setIsPlaying] = useState(false)
const [showApproveModal, setShowApproveModal] = useState(false)
const [showRejectModal, setShowRejectModal] = useState(false)
@ -103,7 +105,7 @@ export default function ReviewPage() {
const handleReject = () => {
if (!rejectReason.trim()) {
alert('请填写驳回原因')
toast.error('请填写驳回原因')
return
}
setShowRejectModal(false)
@ -112,7 +114,7 @@ export default function ReviewPage() {
const handleForcePass = () => {
if (!forcePassReason.trim()) {
alert('请填写强制通过原因')
toast.error('请填写强制通过原因')
return
}
setShowForcePassModal(false)

View File

@ -2,6 +2,7 @@
import { useState } from 'react'
import Link from 'next/link'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { SuccessTag, PendingTag, WarningTag, ErrorTag } from '@/components/ui/Tag'
@ -165,13 +166,13 @@ function ScoreTag({ score }: { score: number }) {
type ScriptTask = typeof mockScriptTasks[0]
type VideoTask = typeof mockVideoTasks[0]
function ScriptTaskCard({ task, onPreview }: { task: ScriptTask; onPreview: (task: ScriptTask) => void }) {
function ScriptTaskCard({ task, onPreview, toast }: { task: ScriptTask; onPreview: (task: ScriptTask) => void; toast: ReturnType<typeof useToast> }) {
const riskConfig = riskLevelConfig[task.riskLevel]
const platform = getPlatformInfo(task.platform)
const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation()
alert(`下载文件: ${task.fileName}`)
toast.info(`下载文件: ${task.fileName}`)
}
const handlePreview = (e: React.MouseEvent) => {
@ -261,13 +262,13 @@ function ScriptTaskCard({ task, onPreview }: { task: ScriptTask; onPreview: (tas
)
}
function VideoTaskCard({ task, onPreview }: { task: VideoTask; onPreview: (task: VideoTask) => void }) {
function VideoTaskCard({ task, onPreview, toast }: { task: VideoTask; onPreview: (task: VideoTask) => void; toast: ReturnType<typeof useToast> }) {
const riskConfig = riskLevelConfig[task.riskLevel]
const platform = getPlatformInfo(task.platform)
const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation()
alert(`下载文件: ${task.fileName}`)
toast.info(`下载文件: ${task.fileName}`)
}
const handlePreview = (e: React.MouseEvent) => {
@ -362,6 +363,7 @@ export default function AgencyReviewListPage() {
const [activeTab, setActiveTab] = useState<'all' | 'script' | 'video'>('all')
const [previewScript, setPreviewScript] = useState<ScriptTask | null>(null)
const [previewVideo, setPreviewVideo] = useState<VideoTask | null>(null)
const toast = useToast()
const filteredScripts = mockScriptTasks.filter(task =>
task.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
@ -462,7 +464,7 @@ export default function AgencyReviewListPage() {
<CardContent className="space-y-3">
{filteredScripts.length > 0 ? (
filteredScripts.map((task) => (
<ScriptTaskCard key={task.id} task={task} onPreview={setPreviewScript} />
<ScriptTaskCard key={task.id} task={task} onPreview={setPreviewScript} toast={toast} />
))
) : (
<div className="text-center py-8 text-text-tertiary">
@ -489,7 +491,7 @@ export default function AgencyReviewListPage() {
<CardContent className="space-y-3">
{filteredVideos.length > 0 ? (
filteredVideos.map((task) => (
<VideoTaskCard key={task.id} task={task} onPreview={setPreviewVideo} />
<VideoTaskCard key={task.id} task={task} onPreview={setPreviewVideo} toast={toast} />
))
) : (
<div className="text-center py-8 text-text-tertiary">
@ -536,7 +538,7 @@ export default function AgencyReviewListPage() {
<Button variant="secondary" onClick={() => setPreviewScript(null)}>
</Button>
<Button onClick={() => alert(`下载文件: ${previewScript?.fileName}`)}>
<Button onClick={() => toast.info(`下载文件: ${previewScript?.fileName}`)}>
<Download size={16} />
</Button>
@ -581,7 +583,7 @@ export default function AgencyReviewListPage() {
<Button variant="secondary" onClick={() => setPreviewVideo(null)}>
</Button>
<Button onClick={() => alert(`下载文件: ${previewVideo?.fileName}`)}>
<Button onClick={() => toast.info(`下载文件: ${previewVideo?.fileName}`)}>
<Download size={16} />
</Button>

View File

@ -2,6 +2,7 @@
import { useState } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Modal, ConfirmModal } from '@/components/ui/Modal'
@ -90,6 +91,7 @@ function ReviewProgressBar({ taskStatus }: { taskStatus: string }) {
export default function AgencyScriptReviewPage() {
const router = useRouter()
const toast = useToast()
const params = useParams()
const [showApproveModal, setShowApproveModal] = useState(false)
const [showRejectModal, setShowRejectModal] = useState(false)
@ -103,27 +105,27 @@ export default function AgencyScriptReviewPage() {
const handleApprove = () => {
setShowApproveModal(false)
alert('已提交品牌方终审!')
toast.success('已提交品牌方终审')
router.push('/agency/review')
}
const handleReject = () => {
if (!rejectReason.trim()) {
alert('请填写驳回原因')
toast.error('请填写驳回原因')
return
}
setShowRejectModal(false)
alert('已驳回')
toast.success('已驳回')
router.push('/agency/review')
}
const handleForcePass = () => {
if (!forcePassReason.trim()) {
alert('请填写强制通过原因')
toast.error('请填写强制通过原因')
return
}
setShowForcePassModal(false)
alert('已强制通过并提交品牌方终审')
toast.success('已强制通过并提交品牌方终审')
router.push('/agency/review')
}

View File

@ -2,6 +2,7 @@
import { useState } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Modal, ConfirmModal } from '@/components/ui/Modal'
@ -114,6 +115,7 @@ function RiskLevelTag({ level }: { level: string }) {
export default function AgencyVideoReviewPage() {
const router = useRouter()
const toast = useToast()
const params = useParams()
const [isPlaying, setIsPlaying] = useState(false)
const [showApproveModal, setShowApproveModal] = useState(false)
@ -130,27 +132,27 @@ export default function AgencyVideoReviewPage() {
const handleApprove = () => {
setShowApproveModal(false)
alert('已提交品牌方终审!')
toast.success('已提交品牌方终审')
router.push('/agency/review')
}
const handleReject = () => {
if (!rejectReason.trim()) {
alert('请填写驳回原因')
toast.error('请填写驳回原因')
return
}
setShowRejectModal(false)
alert('已驳回')
toast.success('已驳回')
router.push('/agency/review')
}
const handleForcePass = () => {
if (!forcePassReason.trim()) {
alert('请填写强制通过原因')
toast.error('请填写强制通过原因')
return
}
setShowForcePassModal(false)
alert('已强制通过并提交品牌方终审')
toast.success('已强制通过并提交品牌方终审')
router.push('/agency/review')
}

View File

@ -2,6 +2,7 @@
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
@ -20,6 +21,7 @@ import {
export default function AgencyAccountSettingsPage() {
const router = useRouter()
const toast = useToast()
const [showOldPassword, setShowOldPassword] = useState(false)
const [showNewPassword, setShowNewPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
@ -39,21 +41,21 @@ export default function AgencyAccountSettingsPage() {
const handleChangePassword = async () => {
if (!passwordForm.oldPassword || !passwordForm.newPassword || !passwordForm.confirmPassword) {
alert('请填写完整密码信息')
toast.error('请填写完整密码信息')
return
}
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
alert('两次输入的新密码不一致')
toast.error('两次输入的新密码不一致')
return
}
if (passwordForm.newPassword.length < 8) {
alert('新密码长度不能少于8位')
toast.error('新密码长度不能少于8位')
return
}
setIsSaving(true)
await new Promise(resolve => setTimeout(resolve, 1000))
setIsSaving(false)
alert('密码修改成功')
toast.success('密码修改成功')
setPasswordForm({ oldPassword: '', newPassword: '', confirmPassword: '' })
}

View File

@ -2,6 +2,7 @@
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import {
@ -102,6 +103,7 @@ function Toggle({ checked, onChange }: { checked: boolean; onChange: (checked: b
export default function AgencyNotificationSettingsPage() {
const router = useRouter()
const toast = useToast()
const [settings, setSettings] = useState(initialSettings)
const [isSaving, setIsSaving] = useState(false)
@ -117,7 +119,7 @@ export default function AgencyNotificationSettingsPage() {
setIsSaving(true)
await new Promise(resolve => setTimeout(resolve, 1000))
setIsSaving(false)
alert('通知设置已保存')
toast.success('通知设置已保存')
}
return (

View File

@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Modal } from '@/components/ui/Modal'
import { SuccessTag, PendingTag, WarningTag } from '@/components/ui/Tag'
import { useToast } from '@/components/ui/Toast'
import {
Search,
Plus,
@ -110,6 +111,7 @@ function StatusTag({ status }: { status: string }) {
}
export default function AgenciesManagePage() {
const toast = useToast()
const [searchQuery, setSearchQuery] = useState('')
const [agencies, setAgencies] = useState<Agency[]>(initialAgencies)
const [copiedId, setCopiedId] = useState<string | null>(null)
@ -231,7 +233,7 @@ export default function AgenciesManagePage() {
.filter(p => selectedProjects.includes(p.id))
.map(p => p.name)
.join('、')
alert(`已将代理商「${assignModal.agency.name}」分配到项目「${projectNames}`)
toast.success(`已将代理商「${assignModal.agency.name}」分配到项目「${projectNames}`)
}
setAssignModal({ open: false, agency: null })
setSelectedProjects([])

View File

@ -6,6 +6,7 @@ import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select'
import { SuccessTag, ErrorTag, PendingTag } from '@/components/ui/Tag'
import { useToast } from '@/components/ui/Toast'
import {
Bot,
Eye,
@ -68,6 +69,7 @@ type TestResult = {
}
export default function AIConfigPage() {
const toast = useToast()
const [provider, setProvider] = useState('oneapi')
const [baseUrl, setBaseUrl] = useState('https://oneapi.intelligrow.cn')
const [apiKey, setApiKey] = useState('')
@ -133,7 +135,7 @@ export default function AIConfigPage() {
}
const handleSave = () => {
alert('配置已保存')
toast.success('配置已保存')
}
const getTestStatusIcon = (status: string) => {

View File

@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
import { ArrowLeft, Check, X, CheckSquare, Video, Clock } from 'lucide-react'
import { cn } from '@/lib/utils'
import { getPlatformInfo } from '@/lib/platforms'
import { useToast } from '@/components/ui/Toast'
// 模拟待审核内容列表
const mockReviewItems = [
@ -99,6 +100,7 @@ function ReviewProgressBar({ currentStep }: { currentStep: number }) {
export default function FinalReviewPage() {
const router = useRouter()
const toast = useToast()
const [selectedItem, setSelectedItem] = useState(mockReviewItems[0])
const [feedback, setFeedback] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
@ -108,19 +110,19 @@ export default function FinalReviewPage() {
setIsSubmitting(true)
// 模拟提交
await new Promise(resolve => setTimeout(resolve, 1000))
alert('已通过审核')
toast.success('已通过审核')
setIsSubmitting(false)
}
const handleReject = async () => {
if (!feedback.trim()) {
alert('请填写驳回原因')
toast.error('请填写驳回原因')
return
}
setIsSubmitting(true)
// 模拟提交
await new Promise(resolve => setTimeout(resolve, 1000))
alert('已驳回')
toast.success('已驳回')
setIsSubmitting(false)
setFeedback('')
}

View File

@ -2,6 +2,7 @@
import { useState } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
@ -82,6 +83,7 @@ const strictnessOptions = [
export default function ProjectConfigPage() {
const router = useRouter()
const params = useParams()
const toast = useToast()
const projectId = params.id as string
const [brief, setBrief] = useState(mockData.brief)
@ -100,7 +102,7 @@ export default function ProjectConfigPage() {
setIsSaving(true)
await new Promise(resolve => setTimeout(resolve, 1000))
setIsSaving(false)
alert('配置已保存')
toast.success('配置已保存')
}
const addRequirement = () => {

View File

@ -2,6 +2,7 @@
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
@ -40,6 +41,7 @@ const mockAgencies = [
export default function CreateProjectPage() {
const router = useRouter()
const toast = useToast()
const [projectName, setProjectName] = useState('')
const [deadline, setDeadline] = useState('')
const [briefFile, setBriefFile] = useState<File | null>(null)
@ -73,14 +75,14 @@ export default function CreateProjectPage() {
const handleSubmit = async () => {
if (!projectName.trim() || !deadline || !briefFile || selectedAgencies.length === 0 || !selectedPlatform) {
alert('请填写完整信息')
toast.error('请填写完整信息')
return
}
setIsSubmitting(true)
// 模拟提交
await new Promise(resolve => setTimeout(resolve, 1500))
alert('项目创建成功!')
toast.success('项目创建成功!')
router.push('/brand')
}

View File

@ -5,6 +5,7 @@ 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 { useToast } from '@/components/ui/Toast'
import {
FileText,
Video,
@ -129,6 +130,7 @@ function TaskCard({
type: 'script' | 'video'
onPreview: (task: ScriptTask | VideoTask, type: 'script' | 'video') => void
}) {
const toast = useToast()
const href = type === 'script' ? `/brand/review/script/${task.id}` : `/brand/review/video/${task.id}`
const platform = getPlatformInfo(task.platform)
@ -141,7 +143,7 @@ function TaskCard({
const handleDownload = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
alert(`下载文件: ${task.fileName}`)
toast.info(`下载文件: ${task.fileName}`)
}
return (
@ -243,6 +245,7 @@ function TaskCard({
}
export default function BrandReviewListPage() {
const toast = useToast()
const [searchQuery, setSearchQuery] = useState('')
const [activeTab, setActiveTab] = useState<'all' | 'script' | 'video'>('all')
const [previewTask, setPreviewTask] = useState<{ task: ScriptTask | VideoTask; type: 'script' | 'video' } | null>(null)
@ -445,7 +448,7 @@ export default function BrandReviewListPage() {
<Button variant="secondary" onClick={() => setPreviewTask(null)}>
</Button>
<Button onClick={() => alert(`下载文件: ${previewTask?.task.fileName}`)}>
<Button onClick={() => toast.info(`下载文件: ${previewTask?.task.fileName}`)}>
<Download size={16} />
</Button>

View File

@ -7,6 +7,7 @@ import { Button } from '@/components/ui/Button'
import { Modal, ConfirmModal } from '@/components/ui/Modal'
import { SuccessTag, WarningTag, ErrorTag, PendingTag } from '@/components/ui/Tag'
import { ReviewSteps, getBrandReviewSteps } from '@/components/ui/ReviewSteps'
import { useToast } from '@/components/ui/Toast'
import {
ArrowLeft,
FileText,
@ -99,6 +100,7 @@ function ReviewProgressBar({ taskStatus }: { taskStatus: string }) {
export default function BrandScriptReviewPage() {
const router = useRouter()
const params = useParams()
const toast = useToast()
const [showApproveModal, setShowApproveModal] = useState(false)
const [showRejectModal, setShowRejectModal] = useState(false)
const [rejectReason, setRejectReason] = useState('')
@ -109,17 +111,17 @@ export default function BrandScriptReviewPage() {
const handleApprove = () => {
setShowApproveModal(false)
alert('审核通过!')
toast.success('审核通过')
router.push('/brand/review')
}
const handleReject = () => {
if (!rejectReason.trim()) {
alert('请填写驳回原因')
toast.error('请填写驳回原因')
return
}
setShowRejectModal(false)
alert('已驳回')
toast.success('已驳回')
router.push('/brand/review')
}

View File

@ -2,6 +2,7 @@
import { useState } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Modal, ConfirmModal } from '@/components/ui/Modal'
@ -123,6 +124,7 @@ function RiskLevelTag({ level }: { level: string }) {
export default function BrandVideoReviewPage() {
const router = useRouter()
const params = useParams()
const toast = useToast()
const [isPlaying, setIsPlaying] = useState(false)
const [showApproveModal, setShowApproveModal] = useState(false)
const [showRejectModal, setShowRejectModal] = useState(false)
@ -135,17 +137,17 @@ export default function BrandVideoReviewPage() {
const handleApprove = () => {
setShowApproveModal(false)
alert('审核通过!')
toast.success('审核通过!')
router.push('/brand/review')
}
const handleReject = () => {
if (!rejectReason.trim()) {
alert('请填写驳回原因')
toast.error('请填写驳回原因')
return
}
setShowRejectModal(false)
alert('已驳回')
toast.success('已驳回')
router.push('/brand/review')
}

View File

@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Modal } from '@/components/ui/Modal'
import { useToast } from '@/components/ui/Toast'
import {
Bell,
Shield,
@ -32,6 +33,7 @@ import {
export default function BrandSettingsPage() {
const router = useRouter()
const toast = useToast()
const [notifications, setNotifications] = useState({
email: true,
push: true,
@ -95,7 +97,7 @@ export default function BrandSettingsPage() {
]
const handleSave = () => {
alert('设置已保存')
toast.success('设置已保存')
}
const handleLogout = () => {
@ -105,10 +107,10 @@ export default function BrandSettingsPage() {
const handleChangePassword = () => {
if (passwordForm.new !== passwordForm.confirm) {
alert('两次输入的密码不一致')
toast.error('两次输入的密码不一致')
return
}
alert('密码修改成功')
toast.success('密码修改成功')
setShowPasswordModal(false)
setPasswordForm({ current: '', new: '', confirm: '' })
}
@ -116,18 +118,18 @@ export default function BrandSettingsPage() {
const handleEnable2FA = () => {
setTwoFAEnabled(true)
setShow2FAModal(false)
alert('双因素认证已启用')
toast.success('双因素认证已启用')
}
const handleChangeEmail = () => {
alert(`邮箱已更新为 ${newEmail}`)
toast.success(`邮箱已更新为 ${newEmail}`)
setShowEmailModal(false)
setNewEmail('')
setEmailCode('')
}
const handleChangePhone = () => {
alert(`手机号已更新为 ${newPhone}`)
toast.success(`手机号已更新为 ${newPhone}`)
setShowPhoneModal(false)
setNewPhone('')
setPhoneCode('')
@ -139,11 +141,11 @@ export default function BrandSettingsPage() {
await new Promise(resolve => setTimeout(resolve, 2000))
setIsExporting(false)
setShowExportModal(false)
alert('导出任务已创建,完成后将通知您下载')
toast.info('导出任务已创建,完成后将通知您下载')
}
const handleRemoveDevice = (deviceId: string) => {
alert('已移除该设备的登录状态')
toast.success('已移除该设备的登录状态')
}
return (

View File

@ -7,6 +7,7 @@ import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { cn } from '@/lib/utils'
import { useToast } from '@/components/ui/Toast'
// 模拟用户数据
const mockUser = {
@ -21,6 +22,7 @@ const mockUser = {
export default function ProfileEditPage() {
const router = useRouter()
const toast = useToast()
const [isSaving, setIsSaving] = useState(false)
const [idCopied, setIdCopied] = useState(false)
const [formData, setFormData] = useState({
@ -37,8 +39,8 @@ export default function ProfileEditPage() {
await navigator.clipboard.writeText(mockUser.creatorId)
setIdCopied(true)
setTimeout(() => setIdCopied(false), 2000)
} catch (err) {
console.error('复制失败:', err)
} catch {
toast.error('复制失败,请重试')
}
}

View File

@ -16,6 +16,7 @@ import {
} from 'lucide-react'
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
import { cn } from '@/lib/utils'
import { useToast } from '@/components/ui/Toast'
// 用户数据
const mockUser = {
@ -84,6 +85,7 @@ const menuItems = [
// 用户卡片组件
function UserCard() {
const toast = useToast()
const [copied, setCopied] = useState(false)
// 复制达人ID
@ -92,8 +94,8 @@ function UserCard() {
await navigator.clipboard.writeText(mockUser.creatorId)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('复制失败:', err)
} catch {
toast.error('复制失败,请重试')
}
}

View File

@ -2,6 +2,7 @@
import { useState } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { useToast } from '@/components/ui/Toast'
import {
ArrowLeft,
FileText,
@ -71,14 +72,15 @@ const mockAgencyBrief = {
export default function TaskBriefPage() {
const router = useRouter()
const params = useParams()
const toast = useToast()
const [previewFile, setPreviewFile] = useState<AgencyBriefFile | null>(null)
const handleDownload = (file: AgencyBriefFile) => {
alert(`下载文件: ${file.name}`)
toast.info(`下载文件: ${file.name}`)
}
const handleDownloadAll = () => {
alert('下载全部文件')
toast.info('下载全部文件')
}
const requiredPoints = mockAgencyBrief.sellingPoints.filter(sp => sp.required)

View File

@ -2,6 +2,7 @@
import { useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { useToast } from '@/components/ui/Toast'
import {
Upload, Check, X, Folder, Bell, MessageCircle,
XCircle, CheckCircle, Loader2, Scan, ArrowLeft,
@ -389,12 +390,12 @@ function ReviewProgressBar({ task }: { task: TaskData }) {
}
// Brief文档查看组件
function AgencyBriefSection() {
function AgencyBriefSection({ toast }: { toast: ReturnType<typeof useToast> }) {
const [isExpanded, setIsExpanded] = useState(true)
const [previewFile, setPreviewFile] = useState<AgencyBriefFile | null>(null)
const handleDownload = (file: AgencyBriefFile) => {
alert(`下载文件: ${file.name}`)
toast.info(`下载文件: ${file.name}`)
}
const requiredPoints = mockAgencyBrief.sellingPoints.filter(sp => sp.required)
@ -547,14 +548,14 @@ function AgencyBriefSection() {
}
// 上传界面
function UploadView({ task }: { task: TaskData }) {
function UploadView({ task, toast }: { task: TaskData; toast: ReturnType<typeof useToast> }) {
const [isDragging, setIsDragging] = useState(false)
const isScript = task.phase === 'script'
return (
<div className="flex flex-col gap-6 h-full">
{/* Brief文档区域 - 仅脚本阶段显示 */}
{isScript && <AgencyBriefSection />}
{isScript && <AgencyBriefSection toast={toast} />}
<div className="flex items-center justify-between">
<div>
@ -962,6 +963,7 @@ function ApprovedView({ task }: { task: TaskData }) {
export default function TaskDetailPage() {
const params = useParams()
const router = useRouter()
const toast = useToast()
const taskId = params.id as string
const taskData = allTasksData[taskId]
@ -995,7 +997,7 @@ export default function TaskDetailPage() {
const renderContent = () => {
switch (taskData.stage) {
case 'upload':
return <UploadView task={taskData} />
return <UploadView task={taskData} toast={toast} />
case 'ai_reviewing':
return <AIReviewingView task={taskData} />
case 'ai_result':

View File

@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'
import { useRouter, useParams, useSearchParams } from 'next/navigation'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { SuccessTag, WarningTag, ErrorTag, PendingTag } from '@/components/ui/Tag'
@ -17,8 +18,48 @@ import {
Loader2,
RefreshCw,
Eye,
MessageSquare
MessageSquare,
Download,
File,
Target,
Ban,
ChevronDown,
ChevronUp
} from 'lucide-react'
import { Modal } from '@/components/ui/Modal'
// 代理商Brief文档达人可查看
type AgencyBriefFile = {
id: string
name: string
size: string
uploadedAt: string
description?: string
}
const mockAgencyBrief = {
// 代理商上传的Brief文档
files: [
{ id: 'af1', name: '达人拍摄指南.pdf', size: '1.5MB', uploadedAt: '2026-02-02', description: '详细的拍摄流程和注意事项' },
{ id: 'af2', name: '产品卖点话术.docx', size: '800KB', uploadedAt: '2026-02-02', description: '推荐使用的话术和表达方式' },
{ id: 'af3', name: '品牌视觉参考.pdf', size: '3.2MB', uploadedAt: '2026-02-02', description: '视觉风格和拍摄参考示例' },
] as AgencyBriefFile[],
// 卖点要求
sellingPoints: [
{ id: 'sp1', content: 'SPF50+ PA++++', required: true },
{ id: 'sp2', content: '轻薄质地,不油腻', required: true },
{ id: 'sp3', content: '延展性好,易推开', required: false },
{ id: 'sp4', content: '适合敏感肌', required: false },
{ id: 'sp5', content: '夏日必备防晒', required: true },
],
// 违禁词
blacklistWords: [
{ id: 'bw1', word: '最好', reason: '绝对化用语' },
{ id: 'bw2', word: '第一', reason: '绝对化用语' },
{ id: 'bw3', word: '神器', reason: '夸大宣传' },
{ id: 'bw4', word: '完美', reason: '绝对化用语' },
],
}
// 模拟任务数据
const mockTask = {
@ -105,6 +146,161 @@ function getTaskByStatus(status: string) {
return task
}
// 代理商Brief文档查看组件
function AgencyBriefSection({ toast }: { toast: ReturnType<typeof useToast> }) {
const [isExpanded, setIsExpanded] = useState(true)
const [previewFile, setPreviewFile] = useState<AgencyBriefFile | null>(null)
const handleDownload = (file: AgencyBriefFile) => {
toast.info(`下载文件: ${file.name}`)
}
const handlePreview = (file: AgencyBriefFile) => {
setPreviewFile(file)
}
const requiredPoints = mockAgencyBrief.sellingPoints.filter(sp => sp.required)
const optionalPoints = mockAgencyBrief.sellingPoints.filter(sp => !sp.required)
return (
<>
<Card className="border-accent-indigo/30">
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
<File size={18} className="text-accent-indigo" />
Brief
</span>
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="p-1 hover:bg-bg-elevated rounded"
>
{isExpanded ? (
<ChevronUp size={18} className="text-text-tertiary" />
) : (
<ChevronDown size={18} className="text-text-tertiary" />
)}
</button>
</CardTitle>
</CardHeader>
{isExpanded && (
<CardContent className="space-y-4">
{/* Brief文档列表 */}
<div>
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2">
<FileText size={14} className="text-accent-indigo" />
</h4>
<div className="space-y-2">
{mockAgencyBrief.files.map((file) => (
<div key={file.id} className="flex items-center justify-between p-3 bg-bg-elevated rounded-lg">
<div className="flex items-center gap-3 min-w-0">
<div className="w-8 h-8 rounded bg-accent-indigo/15 flex items-center justify-center flex-shrink-0">
<FileText size={16} className="text-accent-indigo" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{file.name}</p>
<p className="text-xs text-text-tertiary">{file.size}</p>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<Button variant="ghost" size="sm" onClick={() => handlePreview(file)}>
<Eye size={14} />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDownload(file)}>
<Download size={14} />
</Button>
</div>
</div>
))}
</div>
</div>
{/* 卖点要求 */}
<div>
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2">
<Target size={14} className="text-accent-green" />
</h4>
<div className="space-y-2">
{requiredPoints.length > 0 && (
<div className="p-3 bg-accent-coral/10 rounded-lg border border-accent-coral/30">
<p className="text-xs text-accent-coral font-medium mb-2"></p>
<div className="flex flex-wrap gap-2">
{requiredPoints.map((sp) => (
<span key={sp.id} className="px-2 py-1 text-xs bg-accent-coral/20 text-accent-coral rounded">
{sp.content}
</span>
))}
</div>
</div>
)}
{optionalPoints.length > 0 && (
<div className="p-3 bg-bg-elevated rounded-lg">
<p className="text-xs text-text-tertiary font-medium mb-2"></p>
<div className="flex flex-wrap gap-2">
{optionalPoints.map((sp) => (
<span key={sp.id} className="px-2 py-1 text-xs bg-bg-page text-text-secondary rounded">
{sp.content}
</span>
))}
</div>
</div>
)}
</div>
</div>
{/* 违禁词 */}
<div>
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2">
<Ban size={14} className="text-accent-coral" />
使
</h4>
<div className="flex flex-wrap gap-2">
{mockAgencyBrief.blacklistWords.map((bw) => (
<span key={bw.id} className="px-2 py-1 text-xs bg-accent-coral/15 text-accent-coral rounded border border-accent-coral/30">
{bw.word}
</span>
))}
</div>
</div>
</CardContent>
)}
</Card>
{/* 文件预览弹窗 */}
<Modal
isOpen={!!previewFile}
onClose={() => setPreviewFile(null)}
title={previewFile?.name || '文件预览'}
size="lg"
>
<div className="space-y-4">
<div className="aspect-[4/3] bg-bg-elevated rounded-lg flex items-center justify-center">
<div className="text-center">
<FileText size={48} className="mx-auto text-accent-indigo mb-4" />
<p className="text-text-secondary"></p>
<p className="text-xs text-text-tertiary mt-1"></p>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setPreviewFile(null)}>
</Button>
{previewFile && (
<Button onClick={() => handleDownload(previewFile)}>
<Download size={16} />
</Button>
)}
</div>
</div>
</Modal>
</>
)
}
function UploadSection({ onUpload }: { onUpload: () => void }) {
const [file, setFile] = useState<File | null>(null)
@ -336,6 +532,7 @@ export default function CreatorScriptPage() {
const router = useRouter()
const params = useParams()
const searchParams = useSearchParams()
const toast = useToast()
const status = searchParams.get('status') || 'pending_upload'
const [task, setTask] = useState(getTaskByStatus(status))
@ -390,6 +587,9 @@ export default function CreatorScriptPage() {
</CardContent>
</Card>
{/* Brief文档与要求始终显示 */}
<AgencyBriefSection toast={toast} />
{/* 根据状态显示不同内容 */}
{task.scriptStatus === 'pending_upload' && (
<UploadSection onUpload={simulateUpload} />

View File

@ -1,5 +1,6 @@
import '../styles/globals.css'
import { AuthProvider } from '@/contexts/AuthContext'
import { ToastProvider } from '@/components/ui/Toast'
export const metadata = {
title: '秒思智能审核',
@ -14,7 +15,9 @@ export default function RootLayout({
return (
<html lang="zh-CN" className="h-full">
<body className="h-full bg-bg-page text-text-primary font-sans">
<AuthProvider>{children}</AuthProvider>
<ToastProvider>
<AuthProvider>{children}</AuthProvider>
</ToastProvider>
</body>
</html>
)

View File

@ -22,6 +22,7 @@ export {
getFileCategory,
type FileInfo
} from './ui/FilePreview';
export { ToastProvider, useToast } from './ui/Toast';
// 导航组件
export { BottomNav } from './navigation/BottomNav';

View File

@ -0,0 +1,134 @@
'use client'
import { createContext, useContext, useState, useCallback, ReactNode } from 'react'
import { CheckCircle, XCircle, AlertTriangle, Info, X } from 'lucide-react'
// Toast 类型
type ToastType = 'success' | 'error' | 'warning' | 'info'
// Toast 项
interface ToastItem {
id: string
type: ToastType
message: string
duration?: number
}
// Toast Context
interface ToastContextType {
toast: {
success: (message: string, duration?: number) => void
error: (message: string, duration?: number) => void
warning: (message: string, duration?: number) => void
info: (message: string, duration?: number) => void
}
}
const ToastContext = createContext<ToastContextType | null>(null)
// Toast 图标配置
const toastConfig = {
success: {
icon: CheckCircle,
bgColor: 'bg-accent-green/15',
borderColor: 'border-accent-green/30',
iconColor: 'text-accent-green',
textColor: 'text-accent-green',
},
error: {
icon: XCircle,
bgColor: 'bg-accent-coral/15',
borderColor: 'border-accent-coral/30',
iconColor: 'text-accent-coral',
textColor: 'text-accent-coral',
},
warning: {
icon: AlertTriangle,
bgColor: 'bg-accent-amber/15',
borderColor: 'border-accent-amber/30',
iconColor: 'text-accent-amber',
textColor: 'text-accent-amber',
},
info: {
icon: Info,
bgColor: 'bg-accent-indigo/15',
borderColor: 'border-accent-indigo/30',
iconColor: 'text-accent-indigo',
textColor: 'text-accent-indigo',
},
}
// 单个 Toast 组件
function ToastItem({ item, onClose }: { item: ToastItem; onClose: (id: string) => void }) {
const config = toastConfig[item.type]
const Icon = config.icon
return (
<div
className={`flex items-center gap-3 px-4 py-3 rounded-xl border ${config.bgColor} ${config.borderColor} shadow-lg animate-slide-in min-w-[280px] max-w-[400px]`}
>
<Icon size={20} className={config.iconColor} />
<span className={`flex-1 text-sm font-medium ${config.textColor}`}>{item.message}</span>
<button
type="button"
onClick={() => onClose(item.id)}
className="p-1 rounded-lg hover:bg-white/10 transition-colors"
>
<X size={16} className="text-text-tertiary" />
</button>
</div>
)
}
// Toast Provider
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([])
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, [])
const addToast = useCallback((type: ToastType, message: string, duration = 3000) => {
const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
const newToast: ToastItem = { id, type, message, duration }
setToasts((prev) => [...prev, newToast])
// 自动移除
if (duration > 0) {
setTimeout(() => {
removeToast(id)
}, duration)
}
}, [removeToast])
const toast = {
success: (message: string, duration?: number) => addToast('success', message, duration),
error: (message: string, duration?: number) => addToast('error', message, duration),
warning: (message: string, duration?: number) => addToast('warning', message, duration),
info: (message: string, duration?: number) => addToast('info', message, duration),
}
return (
<ToastContext.Provider value={{ toast }}>
{children}
{/* Toast 容器 */}
<div className="fixed top-4 right-4 z-[9999] flex flex-col gap-2">
{toasts.map((item) => (
<ToastItem key={item.id} item={item} onClose={removeToast} />
))}
</div>
</ToastContext.Provider>
)
}
// useToast Hook
export function useToast() {
const context = useContext(ToastContext)
if (!context) {
throw new Error('useToast must be used within a ToastProvider')
}
return context.toast
}
export default ToastProvider

View File

@ -1,6 +1,6 @@
'use client'
import { useState, useCallback, useEffect } from 'react'
import { useState, useCallback, useEffect, useRef } from 'react'
import { api } from '@/lib/api'
import type {
VideoReviewRequest,
@ -24,6 +24,7 @@ export function useReview(options: UseReviewOptions = {}) {
const [isPolling, setIsPolling] = useState(false)
const [task, setTask] = useState<ReviewTask | null>(null)
const [error, setError] = useState<Error | null>(null)
const intervalRef = useRef<NodeJS.Timeout | null>(null)
/**
*
@ -97,10 +98,22 @@ export function useReview(options: UseReviewOptions = {}) {
}
}, [task?.createdAt])
/**
*
*/
const clearPollingInterval = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}, [])
/**
*
*/
const startPolling = useCallback((reviewId: string) => {
// 清除之前的轮询(如果有)
clearPollingInterval()
setIsPolling(true)
const poll = async () => {
@ -108,36 +121,45 @@ export function useReview(options: UseReviewOptions = {}) {
const progress = await fetchProgress(reviewId)
if (progress.status === 'completed') {
clearPollingInterval()
setIsPolling(false)
const result = await fetchResult(reviewId)
onComplete?.(result)
} else if (progress.status === 'failed') {
clearPollingInterval()
setIsPolling(false)
const error = new Error('审核失败')
setError(error)
onError?.(error)
}
} catch (err) {
} catch {
// 继续轮询,忽略单次错误
console.error('Polling error:', err)
}
}
const intervalId = setInterval(poll, pollingInterval)
intervalRef.current = setInterval(poll, pollingInterval)
poll() // 立即执行一次
return () => {
clearInterval(intervalId)
clearPollingInterval()
setIsPolling(false)
}
}, [fetchProgress, fetchResult, pollingInterval, onComplete, onError])
}, [fetchProgress, fetchResult, pollingInterval, onComplete, onError, clearPollingInterval])
/**
*
*/
const stopPolling = useCallback(() => {
clearPollingInterval()
setIsPolling(false)
}, [])
}, [clearPollingInterval])
// 组件卸载时清除定时器,防止内存泄漏
useEffect(() => {
return () => {
clearPollingInterval()
}
}, [clearPollingInterval])
/**
*

View File

@ -41,7 +41,6 @@ class ApiClient {
(response) => response,
(error) => {
const message = error.response?.data?.detail || error.message || '请求失败'
console.error('API Error:', message)
return Promise.reject(new Error(message))
}
)

View File

@ -140,6 +140,22 @@
animation: spin 1s linear infinite;
}
/* Toast 滑入动画 */
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.animate-slide-in {
animation: slideIn 0.3s ease-out;
}
/* ========================================
5. Utility Classes (工具类)
======================================== */