feat: 前端剩余页面全面对接后端 API(Phase 2 完成)
为品牌方端(8页)、代理商端(10页)、达人端(6页)共24个页面添加真实API调用: - 每页新增 USE_MOCK 条件分支,开发环境使用 mock 数据,生产环境调用真实 API - 添加 loading 骨架屏、error toast 提示、submitting 状态管理 - 数据映射:TaskResponse → 页面视图模型,处理类型差异 - 审核操作(通过/驳回/强制通过)对接 api.reviewScript/reviewVideo - Brief/规则/AI配置对接 api.getBrief/updateBrief/listForbiddenWords 等 - 申诉/历史/额度管理对接 api.listTasks + 状态过滤映射 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
54eaa54966
commit
a8be7bbca9
@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useRouter, useParams } from 'next/navigation'
|
import { useRouter, useParams } from 'next/navigation'
|
||||||
import { useToast } from '@/components/ui/Toast'
|
import { useToast } from '@/components/ui/Toast'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||||
@ -18,14 +18,47 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
File,
|
File,
|
||||||
Send,
|
Send,
|
||||||
Image as ImageIcon
|
Image as ImageIcon,
|
||||||
|
Loader2
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
|
import type { TaskResponse } from '@/types/task'
|
||||||
|
|
||||||
// 申诉状态类型
|
// 申诉状态类型
|
||||||
type AppealStatus = 'pending' | 'processing' | 'approved' | 'rejected'
|
type AppealStatus = 'pending' | 'processing' | 'approved' | 'rejected'
|
||||||
|
|
||||||
|
// 申诉详情类型
|
||||||
|
interface AppealDetail {
|
||||||
|
id: string
|
||||||
|
taskId: string
|
||||||
|
taskTitle: string
|
||||||
|
creatorId: string
|
||||||
|
creatorName: string
|
||||||
|
creatorAvatar: string
|
||||||
|
type: 'ai' | 'agency'
|
||||||
|
contentType: 'script' | 'video'
|
||||||
|
reason: string
|
||||||
|
content: string
|
||||||
|
status: AppealStatus
|
||||||
|
createdAt: string
|
||||||
|
appealCount: number
|
||||||
|
attachments: { id: string; name: string; size: string; type: string }[]
|
||||||
|
originalIssue: {
|
||||||
|
type: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
location: string
|
||||||
|
}
|
||||||
|
taskInfo: {
|
||||||
|
projectName: string
|
||||||
|
scriptFileName: string
|
||||||
|
scriptFileSize: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 模拟申诉详情数据
|
// 模拟申诉详情数据
|
||||||
const mockAppealDetail = {
|
const mockAppealDetail: AppealDetail = {
|
||||||
id: 'appeal-001',
|
id: 'appeal-001',
|
||||||
taskId: 'task-001',
|
taskId: 'task-001',
|
||||||
taskTitle: '夏日护肤推广脚本',
|
taskTitle: '夏日护肤推广脚本',
|
||||||
@ -38,6 +71,7 @@ const mockAppealDetail = {
|
|||||||
content: '脚本中提到的"某品牌"是泛指,并非特指竞品,AI系统可能误解了语境。我在脚本中使用的是泛化表述,并没有提及任何具体的竞品名称。请代理商重新审核此处,谢谢!',
|
content: '脚本中提到的"某品牌"是泛指,并非特指竞品,AI系统可能误解了语境。我在脚本中使用的是泛化表述,并没有提及任何具体的竞品名称。请代理商重新审核此处,谢谢!',
|
||||||
status: 'pending' as AppealStatus,
|
status: 'pending' as AppealStatus,
|
||||||
createdAt: '2026-02-06 10:30',
|
createdAt: '2026-02-06 10:30',
|
||||||
|
appealCount: 1,
|
||||||
// 附件
|
// 附件
|
||||||
attachments: [
|
attachments: [
|
||||||
{ id: 'att-001', name: '品牌授权证明.pdf', size: '1.2 MB', type: 'pdf' },
|
{ id: 'att-001', name: '品牌授权证明.pdf', size: '1.2 MB', type: 'pdf' },
|
||||||
@ -58,6 +92,66 @@ const mockAppealDetail = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Derive a UI-compatible appeal detail from a TaskResponse
|
||||||
|
function mapTaskToAppealDetail(task: TaskResponse) {
|
||||||
|
const isVideoStage = task.stage.startsWith('video')
|
||||||
|
const contentType: 'script' | 'video' = isVideoStage ? 'video' : 'script'
|
||||||
|
const type: 'ai' | 'agency' = task.stage.includes('ai') ? 'ai' : 'agency'
|
||||||
|
|
||||||
|
let status: AppealStatus = 'pending'
|
||||||
|
if (task.stage === 'completed') {
|
||||||
|
status = 'approved'
|
||||||
|
} else if (task.stage === 'rejected') {
|
||||||
|
status = 'rejected'
|
||||||
|
} else if (task.stage.includes('review')) {
|
||||||
|
status = 'processing'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
}).replace(/\//g, '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract original issue from AI results if available
|
||||||
|
const aiResult = isVideoStage ? task.video_ai_result : task.script_ai_result
|
||||||
|
const agencyComment = isVideoStage ? task.video_agency_comment : task.script_agency_comment
|
||||||
|
const originalIssueTitle = aiResult?.violations?.[0]?.type || agencyComment || '审核问题'
|
||||||
|
const originalIssueDesc = aiResult?.violations?.[0]?.content || agencyComment || ''
|
||||||
|
const originalIssueLocation = aiResult?.violations?.[0]?.source || ''
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
taskId: task.id,
|
||||||
|
taskTitle: task.name,
|
||||||
|
creatorId: task.creator.id,
|
||||||
|
creatorName: task.creator.name,
|
||||||
|
creatorAvatar: task.creator.name.charAt(0),
|
||||||
|
type,
|
||||||
|
contentType,
|
||||||
|
reason: task.appeal_reason || '申诉',
|
||||||
|
content: task.appeal_reason || '',
|
||||||
|
status,
|
||||||
|
createdAt: formatDate(task.updated_at),
|
||||||
|
appealCount: task.appeal_count,
|
||||||
|
attachments: [] as { id: string; name: string; size: string; type: string }[],
|
||||||
|
originalIssue: {
|
||||||
|
type: type === 'ai' ? 'ai' : 'agency',
|
||||||
|
title: originalIssueTitle,
|
||||||
|
description: originalIssueDesc,
|
||||||
|
location: originalIssueLocation,
|
||||||
|
},
|
||||||
|
taskInfo: {
|
||||||
|
projectName: task.project.name,
|
||||||
|
scriptFileName: isVideoStage
|
||||||
|
? (task.video_file_name || '视频文件')
|
||||||
|
: (task.script_file_name || '脚本文件'),
|
||||||
|
scriptFileSize: '-',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 状态配置
|
// 状态配置
|
||||||
const statusConfig: Record<AppealStatus, { label: string; color: string; bgColor: string; icon: React.ElementType }> = {
|
const statusConfig: Record<AppealStatus, { label: string; color: string; bgColor: string; icon: React.ElementType }> = {
|
||||||
pending: { label: '待处理', color: 'text-accent-amber', bgColor: 'bg-accent-amber/15', icon: Clock },
|
pending: { label: '待处理', color: 'text-accent-amber', bgColor: 'bg-accent-amber/15', icon: Clock },
|
||||||
@ -70,9 +164,35 @@ export default function AgencyAppealDetailPage() {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const [appeal] = useState(mockAppealDetail)
|
const taskId = params.id as string
|
||||||
|
|
||||||
|
const [appeal, setAppeal] = useState(mockAppealDetail)
|
||||||
const [replyContent, setReplyContent] = useState('')
|
const [replyContent, setReplyContent] = useState('')
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const fetchAppeal = useCallback(async () => {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
setAppeal(mockAppealDetail)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const task = await api.getTask(taskId)
|
||||||
|
setAppeal(mapTaskToAppealDetail(task))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch appeal detail:', err)
|
||||||
|
toast.error('加载申诉详情失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [taskId, toast])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAppeal()
|
||||||
|
}, [fetchAppeal])
|
||||||
|
|
||||||
const status = statusConfig[appeal.status]
|
const status = statusConfig[appeal.status]
|
||||||
const StatusIcon = status.icon
|
const StatusIcon = status.icon
|
||||||
@ -83,10 +203,26 @@ export default function AgencyAppealDetailPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
// 模拟提交
|
|
||||||
|
try {
|
||||||
|
if (USE_MOCK) {
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
|
} else {
|
||||||
|
// Determine if this is script or video review based on the appeal's content type
|
||||||
|
const isVideo = appeal.contentType === 'video'
|
||||||
|
if (isVideo) {
|
||||||
|
await api.reviewVideo(taskId, { action: 'pass', comment: replyContent })
|
||||||
|
} else {
|
||||||
|
await api.reviewScript(taskId, { action: 'pass', comment: replyContent })
|
||||||
|
}
|
||||||
|
}
|
||||||
toast.success('申诉已通过')
|
toast.success('申诉已通过')
|
||||||
router.push('/agency/appeals')
|
router.push('/agency/appeals')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to approve appeal:', err)
|
||||||
|
toast.error('操作失败,请重试')
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReject = async () => {
|
const handleReject = async () => {
|
||||||
@ -95,10 +231,34 @@ export default function AgencyAppealDetailPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
// 模拟提交
|
|
||||||
|
try {
|
||||||
|
if (USE_MOCK) {
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
|
} else {
|
||||||
|
const isVideo = appeal.contentType === 'video'
|
||||||
|
if (isVideo) {
|
||||||
|
await api.reviewVideo(taskId, { action: 'reject', comment: replyContent })
|
||||||
|
} else {
|
||||||
|
await api.reviewScript(taskId, { action: 'reject', comment: replyContent })
|
||||||
|
}
|
||||||
|
}
|
||||||
toast.success('申诉已驳回')
|
toast.success('申诉已驳回')
|
||||||
router.push('/agency/appeals')
|
router.push('/agency/appeals')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to reject appeal:', err)
|
||||||
|
toast.error('操作失败,请重试')
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-24 text-text-tertiary">
|
||||||
|
<Loader2 size={32} className="animate-spin mb-4" />
|
||||||
|
<p>加载中...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -186,7 +346,9 @@ export default function AgencyAppealDetailPage() {
|
|||||||
<span className="font-medium text-text-primary">{appeal.originalIssue.title}</span>
|
<span className="font-medium text-text-primary">{appeal.originalIssue.title}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-text-secondary">{appeal.originalIssue.description}</p>
|
<p className="text-sm text-text-secondary">{appeal.originalIssue.description}</p>
|
||||||
|
{appeal.originalIssue.location && (
|
||||||
<p className="text-xs text-text-tertiary mt-2">位置: {appeal.originalIssue.location}</p>
|
<p className="text-xs text-text-tertiary mt-2">位置: {appeal.originalIssue.location}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -209,6 +371,10 @@ export default function AgencyAppealDetailPage() {
|
|||||||
<span className="text-sm text-text-tertiary">详细说明</span>
|
<span className="text-sm text-text-tertiary">详细说明</span>
|
||||||
<p className="text-text-primary mt-1 leading-relaxed">{appeal.content}</p>
|
<p className="text-text-primary mt-1 leading-relaxed">{appeal.content}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-text-tertiary">申诉次数</span>
|
||||||
|
<p className="text-text-primary mt-1">{appeal.appealCount} 次</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 附件 */}
|
{/* 附件 */}
|
||||||
{appeal.attachments.length > 0 && (
|
{appeal.attachments.length > 0 && (
|
||||||
@ -303,7 +469,7 @@ export default function AgencyAppealDetailPage() {
|
|||||||
onClick={handleApprove}
|
onClick={handleApprove}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
<CheckCircle size={16} />
|
{isSubmitting ? <Loader2 size={16} className="animate-spin" /> : <CheckCircle size={16} />}
|
||||||
通过申诉
|
通过申诉
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@ -312,7 +478,7 @@ export default function AgencyAppealDetailPage() {
|
|||||||
onClick={handleReject}
|
onClick={handleReject}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
<XCircle size={16} />
|
{isSubmitting ? <Loader2 size={16} className="animate-spin" /> : <XCircle size={16} />}
|
||||||
驳回申诉
|
驳回申诉
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
@ -15,9 +15,13 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
User,
|
User,
|
||||||
FileText,
|
FileText,
|
||||||
Video
|
Video,
|
||||||
|
Loader2
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { getPlatformInfo } from '@/lib/platforms'
|
import { getPlatformInfo } from '@/lib/platforms'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
|
import type { TaskResponse } from '@/types/task'
|
||||||
|
|
||||||
// 申诉状态类型
|
// 申诉状态类型
|
||||||
type AppealStatus = 'pending' | 'processing' | 'approved' | 'rejected'
|
type AppealStatus = 'pending' | 'processing' | 'approved' | 'rejected'
|
||||||
@ -118,6 +122,46 @@ const typeConfig: Record<AppealType, { label: string; color: string }> = {
|
|||||||
agency: { label: '代理商审核申诉', color: 'text-purple-400' },
|
agency: { label: '代理商审核申诉', color: 'text-purple-400' },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a TaskResponse (with is_appeal === true) to the Appeal UI model.
|
||||||
|
*/
|
||||||
|
function mapTaskToAppeal(task: TaskResponse): Appeal {
|
||||||
|
// Determine which stage the task was appealing from
|
||||||
|
const isVideoStage = task.stage.startsWith('video')
|
||||||
|
const contentType: 'script' | 'video' = isVideoStage ? 'video' : 'script'
|
||||||
|
|
||||||
|
// Determine appeal type based on stage
|
||||||
|
const type: AppealType = task.stage.includes('ai') ? 'ai' : 'agency'
|
||||||
|
|
||||||
|
// Derive appeal status from the task stage
|
||||||
|
let status: AppealStatus = 'pending'
|
||||||
|
if (task.stage === 'completed') {
|
||||||
|
status = 'approved'
|
||||||
|
} else if (task.stage === 'rejected') {
|
||||||
|
status = 'rejected'
|
||||||
|
} else if (task.stage.includes('review')) {
|
||||||
|
status = 'processing'
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
taskId: task.id,
|
||||||
|
taskTitle: task.name,
|
||||||
|
creatorId: task.creator.id,
|
||||||
|
creatorName: task.creator.name,
|
||||||
|
platform: 'douyin', // Backend does not expose platform on task; default for now
|
||||||
|
type,
|
||||||
|
contentType,
|
||||||
|
reason: task.appeal_reason || '申诉',
|
||||||
|
content: task.appeal_reason || '',
|
||||||
|
status,
|
||||||
|
createdAt: task.updated_at ? new Date(task.updated_at).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).replace(/\//g, '-') : '',
|
||||||
|
updatedAt: task.stage === 'completed' || task.stage === 'rejected'
|
||||||
|
? new Date(task.updated_at).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).replace(/\//g, '-')
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function AppealCard({ appeal }: { appeal: Appeal }) {
|
function AppealCard({ appeal }: { appeal: Appeal }) {
|
||||||
const status = statusConfig[appeal.status]
|
const status = statusConfig[appeal.status]
|
||||||
const type = typeConfig[appeal.type]
|
const type = typeConfig[appeal.type]
|
||||||
@ -191,13 +235,40 @@ function AppealCard({ appeal }: { appeal: Appeal }) {
|
|||||||
export default function AgencyAppealsPage() {
|
export default function AgencyAppealsPage() {
|
||||||
const [filter, setFilter] = useState<AppealStatus | 'all'>('all')
|
const [filter, setFilter] = useState<AppealStatus | 'all'>('all')
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [appeals, setAppeals] = useState<Appeal[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const fetchAppeals = useCallback(async () => {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
setAppeals(mockAppeals)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
// Fetch tasks and filter for those with is_appeal === true
|
||||||
|
const response = await api.listTasks(1, 50)
|
||||||
|
const appealTasks = response.items.filter((t) => t.is_appeal === true)
|
||||||
|
setAppeals(appealTasks.map(mapTaskToAppeal))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch appeals:', err)
|
||||||
|
setAppeals([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAppeals()
|
||||||
|
}, [fetchAppeals])
|
||||||
|
|
||||||
// 统计
|
// 统计
|
||||||
const pendingCount = mockAppeals.filter(a => a.status === 'pending').length
|
const pendingCount = appeals.filter(a => a.status === 'pending').length
|
||||||
const processingCount = mockAppeals.filter(a => a.status === 'processing').length
|
const processingCount = appeals.filter(a => a.status === 'processing').length
|
||||||
|
|
||||||
// 筛选
|
// 筛选
|
||||||
const filteredAppeals = mockAppeals.filter(appeal => {
|
const filteredAppeals = appeals.filter(appeal => {
|
||||||
const matchesSearch = searchQuery === '' ||
|
const matchesSearch = searchQuery === '' ||
|
||||||
appeal.taskTitle.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
appeal.taskTitle.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
appeal.creatorName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
appeal.creatorName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
@ -270,7 +341,12 @@ export default function AgencyAppealsPage() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{filteredAppeals.length > 0 ? (
|
{loading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-text-tertiary">
|
||||||
|
<Loader2 size={32} className="animate-spin mb-4" />
|
||||||
|
<p>加载中...</p>
|
||||||
|
</div>
|
||||||
|
) : filteredAppeals.length > 0 ? (
|
||||||
filteredAppeals.map((appeal) => (
|
filteredAppeals.map((appeal) => (
|
||||||
<AppealCard key={appeal.id} appeal={appeal} />
|
<AppealCard key={appeal.id} appeal={appeal} />
|
||||||
))
|
))
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useRouter, useParams } from 'next/navigation'
|
import { useRouter, useParams } from 'next/navigation'
|
||||||
import { useToast } from '@/components/ui/Toast'
|
import { useToast } from '@/components/ui/Toast'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||||
@ -26,9 +26,14 @@ import {
|
|||||||
Save,
|
Save,
|
||||||
Upload,
|
Upload,
|
||||||
Trash2,
|
Trash2,
|
||||||
File
|
File,
|
||||||
|
Loader2
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { getPlatformInfo } from '@/lib/platforms'
|
import { getPlatformInfo } from '@/lib/platforms'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
|
import type { BriefResponse, SellingPoint, BlacklistWord, BriefAttachment } from '@/types/brief'
|
||||||
|
import type { ProjectResponse } from '@/types/project'
|
||||||
|
|
||||||
// 文件类型
|
// 文件类型
|
||||||
type BriefFile = {
|
type BriefFile = {
|
||||||
@ -39,8 +44,32 @@ type BriefFile = {
|
|||||||
uploadedAt: string
|
uploadedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 代理商上传的Brief文档(可编辑)
|
||||||
|
type AgencyFile = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
size: string
|
||||||
|
uploadedAt: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 视图类型 ====================
|
||||||
|
interface BrandBriefView {
|
||||||
|
id: string
|
||||||
|
projectName: string
|
||||||
|
brandName: string
|
||||||
|
platform: string
|
||||||
|
files: BriefFile[]
|
||||||
|
brandRules: {
|
||||||
|
restrictions: string
|
||||||
|
competitors: string[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Mock 数据 ====================
|
||||||
|
|
||||||
// 模拟品牌方 Brief(只读)
|
// 模拟品牌方 Brief(只读)
|
||||||
const mockBrandBrief = {
|
const mockBrandBrief: BrandBriefView = {
|
||||||
id: 'brief-001',
|
id: 'brief-001',
|
||||||
projectName: 'XX品牌618推广',
|
projectName: 'XX品牌618推广',
|
||||||
brandName: 'XX护肤品牌',
|
brandName: 'XX护肤品牌',
|
||||||
@ -58,15 +87,6 @@ const mockBrandBrief = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// 代理商上传的Brief文档(可编辑)
|
|
||||||
type AgencyFile = {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
size: string
|
|
||||||
uploadedAt: string
|
|
||||||
description?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// 代理商自己的配置(可编辑)
|
// 代理商自己的配置(可编辑)
|
||||||
const mockAgencyConfig = {
|
const mockAgencyConfig = {
|
||||||
status: 'configured',
|
status: 'configured',
|
||||||
@ -125,13 +145,50 @@ const platformRules = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 组件 ====================
|
||||||
|
|
||||||
|
function BriefDetailSkeleton() {
|
||||||
|
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="flex-1">
|
||||||
|
<div className="h-6 w-48 bg-bg-elevated rounded" />
|
||||||
|
<div className="h-4 w-32 bg-bg-elevated rounded mt-2" />
|
||||||
|
</div>
|
||||||
|
<div className="h-10 w-24 bg-bg-elevated rounded-lg" />
|
||||||
|
<div className="h-10 w-24 bg-bg-elevated rounded-lg" />
|
||||||
|
</div>
|
||||||
|
<div className="h-20 bg-bg-elevated rounded-lg" />
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-2 h-48 bg-bg-elevated rounded-xl" />
|
||||||
|
<div className="h-48 bg-bg-elevated rounded-xl" />
|
||||||
|
</div>
|
||||||
|
<div className="h-20 bg-bg-elevated rounded-lg" />
|
||||||
|
<div className="h-48 bg-bg-elevated rounded-xl" />
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
<div className="h-40 bg-bg-elevated rounded-xl" />
|
||||||
|
<div className="h-48 bg-bg-elevated rounded-xl" />
|
||||||
|
</div>
|
||||||
|
<div className="h-64 bg-bg-elevated rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function BriefConfigPage() {
|
export default function BriefConfigPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const projectId = params.id as string
|
||||||
|
|
||||||
|
// 加载状态
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
// 品牌方 Brief(只读)
|
// 品牌方 Brief(只读)
|
||||||
const [brandBrief] = useState(mockBrandBrief)
|
const [brandBrief, setBrandBrief] = useState(mockBrandBrief)
|
||||||
|
|
||||||
// 代理商配置(可编辑)
|
// 代理商配置(可编辑)
|
||||||
const [agencyConfig, setAgencyConfig] = useState(mockAgencyConfig)
|
const [agencyConfig, setAgencyConfig] = useState(mockAgencyConfig)
|
||||||
@ -148,6 +205,94 @@ export default function BriefConfigPage() {
|
|||||||
const [isAIParsing, setIsAIParsing] = useState(false)
|
const [isAIParsing, setIsAIParsing] = useState(false)
|
||||||
const [isUploading, setIsUploading] = useState(false)
|
const [isUploading, setIsUploading] = useState(false)
|
||||||
|
|
||||||
|
// 加载数据
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
// Mock 模式使用默认数据
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 获取项目信息
|
||||||
|
const project = await api.getProject(projectId)
|
||||||
|
|
||||||
|
// 2. 获取 Brief
|
||||||
|
let brief: BriefResponse | null = null
|
||||||
|
try {
|
||||||
|
brief = await api.getBrief(projectId)
|
||||||
|
} catch {
|
||||||
|
// Brief 不存在,保持空状态
|
||||||
|
}
|
||||||
|
|
||||||
|
// 映射到品牌方 Brief 视图
|
||||||
|
const briefFiles: BriefFile[] = brief?.attachments?.map((att, i) => ({
|
||||||
|
id: att.id || `att-${i}`,
|
||||||
|
name: att.name,
|
||||||
|
type: 'brief' as const,
|
||||||
|
size: att.size || '未知',
|
||||||
|
uploadedAt: brief!.created_at.split('T')[0],
|
||||||
|
})) || []
|
||||||
|
|
||||||
|
if (brief?.file_name) {
|
||||||
|
briefFiles.unshift({
|
||||||
|
id: 'main-file',
|
||||||
|
name: brief.file_name,
|
||||||
|
type: 'brief' as const,
|
||||||
|
size: '未知',
|
||||||
|
uploadedAt: brief.created_at.split('T')[0],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setBrandBrief({
|
||||||
|
id: brief?.id || `no-brief-${projectId}`,
|
||||||
|
projectName: project.name,
|
||||||
|
brandName: project.brand_name || '未知品牌',
|
||||||
|
platform: 'douyin', // 后端暂无 platform 字段
|
||||||
|
files: briefFiles,
|
||||||
|
brandRules: {
|
||||||
|
restrictions: brief?.other_requirements || '暂无限制条件',
|
||||||
|
competitors: brief?.competitors || [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 映射到代理商配置视图
|
||||||
|
const hasBrief = !!(brief?.selling_points?.length || brief?.blacklist_words?.length || brief?.brand_tone)
|
||||||
|
|
||||||
|
setAgencyConfig({
|
||||||
|
status: hasBrief ? 'configured' : 'pending',
|
||||||
|
configuredAt: hasBrief ? (brief!.updated_at.split('T')[0]) : '',
|
||||||
|
agencyFiles: [], // 后端暂无代理商文档管理
|
||||||
|
aiParsedContent: {
|
||||||
|
productName: brief?.brand_tone || '待解析',
|
||||||
|
targetAudience: '待解析',
|
||||||
|
contentRequirements: brief?.min_duration && brief?.max_duration
|
||||||
|
? `视频时长 ${brief.min_duration}-${brief.max_duration} 秒`
|
||||||
|
: (brief?.other_requirements || '待解析'),
|
||||||
|
},
|
||||||
|
sellingPoints: (brief?.selling_points || []).map((sp, i) => ({
|
||||||
|
id: `sp-${i}`,
|
||||||
|
content: sp.content,
|
||||||
|
required: sp.required,
|
||||||
|
})),
|
||||||
|
blacklistWords: (brief?.blacklist_words || []).map((bw, i) => ({
|
||||||
|
id: `bw-${i}`,
|
||||||
|
word: bw.word,
|
||||||
|
reason: bw.reason,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('加载 Brief 详情失败:', err)
|
||||||
|
toast.error('加载 Brief 详情失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [projectId, toast])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
}, [loadData])
|
||||||
|
|
||||||
const platform = getPlatformInfo(brandBrief.platform)
|
const platform = getPlatformInfo(brandBrief.platform)
|
||||||
const rules = platformRules[brandBrief.platform as keyof typeof platformRules] || platformRules.douyin
|
const rules = platformRules[brandBrief.platform as keyof typeof platformRules] || platformRules.douyin
|
||||||
|
|
||||||
@ -180,6 +325,42 @@ export default function BriefConfigPage() {
|
|||||||
// 保存配置
|
// 保存配置
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
|
|
||||||
|
if (!USE_MOCK) {
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
selling_points: agencyConfig.sellingPoints.map(sp => ({
|
||||||
|
content: sp.content,
|
||||||
|
required: sp.required,
|
||||||
|
})),
|
||||||
|
blacklist_words: agencyConfig.blacklistWords.map(bw => ({
|
||||||
|
word: bw.word,
|
||||||
|
reason: bw.reason,
|
||||||
|
})),
|
||||||
|
competitors: brandBrief.brandRules.competitors,
|
||||||
|
brand_tone: agencyConfig.aiParsedContent.productName,
|
||||||
|
other_requirements: brandBrief.brandRules.restrictions,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试更新,如果 Brief 不存在则创建
|
||||||
|
try {
|
||||||
|
await api.updateBrief(projectId, payload)
|
||||||
|
} catch {
|
||||||
|
await api.createBrief(projectId, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(false)
|
||||||
|
toast.success('配置已保存!')
|
||||||
|
return
|
||||||
|
} catch (err) {
|
||||||
|
console.error('保存 Brief 失败:', err)
|
||||||
|
setIsSaving(false)
|
||||||
|
toast.error('保存配置失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock 模式
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
toast.success('配置已保存!')
|
toast.success('配置已保存!')
|
||||||
@ -263,6 +444,10 @@ export default function BriefConfigPage() {
|
|||||||
toast.info(`下载文件: ${file.name}`)
|
toast.info(`下载文件: ${file.name}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <BriefDetailSkeleton />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 顶部导航 */}
|
{/* 顶部导航 */}
|
||||||
@ -290,7 +475,7 @@ export default function BriefConfigPage() {
|
|||||||
{isExporting ? '导出中...' : '导出规则'}
|
{isExporting ? '导出中...' : '导出规则'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave} disabled={isSaving}>
|
<Button onClick={handleSave} disabled={isSaving}>
|
||||||
<Save size={16} />
|
{isSaving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
||||||
{isSaving ? '保存中...' : '保存配置'}
|
{isSaving ? '保存中...' : '保存配置'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -357,6 +542,12 @@ export default function BriefConfigPage() {
|
|||||||
查看全部 {brandBrief.files.length} 个文件 →
|
查看全部 {brandBrief.files.length} 个文件 →
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{brandBrief.files.length === 0 && (
|
||||||
|
<div className="py-8 text-center">
|
||||||
|
<FileText size={32} className="mx-auto text-text-tertiary mb-2" />
|
||||||
|
<p className="text-sm text-text-secondary">暂无 Brief 文件</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@ -381,6 +572,9 @@ export default function BriefConfigPage() {
|
|||||||
{c}
|
{c}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
{brandBrief.brandRules.competitors.length === 0 && (
|
||||||
|
<span className="text-sm text-text-tertiary">暂无竞品</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -609,7 +803,7 @@ export default function BriefConfigPage() {
|
|||||||
{agencyConfig.blacklistWords.map((bw) => (
|
{agencyConfig.blacklistWords.map((bw) => (
|
||||||
<div key={bw.id} className="flex items-center justify-between p-3 bg-accent-coral/10 rounded-lg border border-accent-coral/30">
|
<div key={bw.id} className="flex items-center justify-between p-3 bg-accent-coral/10 rounded-lg border border-accent-coral/30">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-accent-coral">「{bw.word}」</span>
|
<span className="font-medium text-accent-coral">{'\u300C'}{bw.word}{'\u300D'}</span>
|
||||||
<span className="text-xs text-text-tertiary ml-2">{bw.reason}</span>
|
<span className="text-xs text-text-tertiary ml-2">{bw.reason}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@ -652,7 +846,7 @@ export default function BriefConfigPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-text-secondary">配置时间</span>
|
<span className="text-text-secondary">配置时间</span>
|
||||||
<span className="text-text-primary">{agencyConfig.configuredAt}</span>
|
<span className="text-text-primary">{agencyConfig.configuredAt || '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -705,6 +899,12 @@ export default function BriefConfigPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{brandBrief.files.length === 0 && (
|
||||||
|
<div className="py-12 text-center">
|
||||||
|
<FileText size={48} className="mx-auto text-text-tertiary mb-4" />
|
||||||
|
<p className="text-text-secondary">暂无文件</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
@ -13,14 +13,35 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Settings
|
Settings,
|
||||||
|
Loader2
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { getPlatformInfo } from '@/lib/platforms'
|
import { getPlatformInfo } from '@/lib/platforms'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
|
import type { ProjectResponse } from '@/types/project'
|
||||||
|
import type { BriefResponse, SellingPoint, BlacklistWord } from '@/types/brief'
|
||||||
|
|
||||||
// 模拟 Brief 列表
|
// ==================== 本地视图模型 ====================
|
||||||
const mockBriefs = [
|
interface BriefItem {
|
||||||
|
id: string
|
||||||
|
projectId: string
|
||||||
|
projectName: string
|
||||||
|
brandName: string
|
||||||
|
platform: string
|
||||||
|
status: 'configured' | 'pending'
|
||||||
|
uploadedAt: string
|
||||||
|
configuredAt: string | null
|
||||||
|
creatorCount: number
|
||||||
|
sellingPoints: number
|
||||||
|
blacklistWords: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Mock 数据 ====================
|
||||||
|
const mockBriefs: BriefItem[] = [
|
||||||
{
|
{
|
||||||
id: 'brief-001',
|
id: 'brief-001',
|
||||||
|
projectId: 'proj-001',
|
||||||
projectName: 'XX品牌618推广',
|
projectName: 'XX品牌618推广',
|
||||||
brandName: 'XX护肤品牌',
|
brandName: 'XX护肤品牌',
|
||||||
platform: 'douyin',
|
platform: 'douyin',
|
||||||
@ -33,6 +54,7 @@ const mockBriefs = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'brief-002',
|
id: 'brief-002',
|
||||||
|
projectId: 'proj-002',
|
||||||
projectName: '新品口红系列',
|
projectName: '新品口红系列',
|
||||||
brandName: 'XX美妆品牌',
|
brandName: 'XX美妆品牌',
|
||||||
platform: 'xiaohongshu',
|
platform: 'xiaohongshu',
|
||||||
@ -45,6 +67,7 @@ const mockBriefs = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'brief-003',
|
id: 'brief-003',
|
||||||
|
projectId: 'proj-003',
|
||||||
projectName: '护肤品秋季活动',
|
projectName: '护肤品秋季活动',
|
||||||
brandName: 'XX护肤品牌',
|
brandName: 'XX护肤品牌',
|
||||||
platform: 'bilibili',
|
platform: 'bilibili',
|
||||||
@ -63,19 +86,118 @@ function StatusTag({ status }: { status: string }) {
|
|||||||
return <PendingTag>处理中</PendingTag>
|
return <PendingTag>处理中</PendingTag>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function BriefsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-pulse">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="h-8 w-40 bg-bg-elevated rounded" />
|
||||||
|
<div className="h-4 w-56 bg-bg-elevated rounded mt-2" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-8 w-20 bg-bg-elevated rounded-lg" />
|
||||||
|
<div className="h-8 w-20 bg-bg-elevated rounded-lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="h-10 w-80 bg-bg-elevated rounded-lg" />
|
||||||
|
<div className="h-10 w-60 bg-bg-elevated rounded-lg" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
{[1, 2, 3].map(i => (
|
||||||
|
<div key={i} className="h-28 bg-bg-elevated rounded-xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function AgencyBriefsPage() {
|
export default function AgencyBriefsPage() {
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||||
|
const [briefs, setBriefs] = useState<BriefItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
const filteredBriefs = mockBriefs.filter(brief => {
|
const loadData = useCallback(async () => {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
setBriefs(mockBriefs)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 获取所有项目
|
||||||
|
const projectsData = await api.listProjects(1, 100)
|
||||||
|
const projects = projectsData.items
|
||||||
|
|
||||||
|
// 2. 对每个项目获取 Brief(并行请求)
|
||||||
|
const briefResults = await Promise.allSettled(
|
||||||
|
projects.map(async (project): Promise<BriefItem> => {
|
||||||
|
try {
|
||||||
|
const brief = await api.getBrief(project.id)
|
||||||
|
const hasBrief = !!(brief.selling_points?.length || brief.blacklist_words?.length || brief.brand_tone)
|
||||||
|
return {
|
||||||
|
id: brief.id,
|
||||||
|
projectId: project.id,
|
||||||
|
projectName: project.name,
|
||||||
|
brandName: project.brand_name || '未知品牌',
|
||||||
|
platform: 'douyin', // 后端暂无 platform 字段,默认值
|
||||||
|
status: hasBrief ? 'configured' : 'pending',
|
||||||
|
uploadedAt: project.created_at.split('T')[0],
|
||||||
|
configuredAt: hasBrief ? brief.updated_at.split('T')[0] : null,
|
||||||
|
creatorCount: project.task_count || 0,
|
||||||
|
sellingPoints: brief.selling_points?.length || 0,
|
||||||
|
blacklistWords: brief.blacklist_words?.length || 0,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Brief 不存在,标记为待配置
|
||||||
|
return {
|
||||||
|
id: `no-brief-${project.id}`,
|
||||||
|
projectId: project.id,
|
||||||
|
projectName: project.name,
|
||||||
|
brandName: project.brand_name || '未知品牌',
|
||||||
|
platform: 'douyin',
|
||||||
|
status: 'pending',
|
||||||
|
uploadedAt: project.created_at.split('T')[0],
|
||||||
|
configuredAt: null,
|
||||||
|
creatorCount: project.task_count || 0,
|
||||||
|
sellingPoints: 0,
|
||||||
|
blacklistWords: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const items: BriefItem[] = briefResults
|
||||||
|
.filter((r): r is PromiseFulfilledResult<BriefItem> => r.status === 'fulfilled')
|
||||||
|
.map(r => r.value)
|
||||||
|
|
||||||
|
setBriefs(items)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('加载 Brief 列表失败:', err)
|
||||||
|
setBriefs([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
}, [loadData])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <BriefsSkeleton />
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredBriefs = briefs.filter(brief => {
|
||||||
const matchesSearch = brief.projectName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
const matchesSearch = brief.projectName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
brief.brandName.toLowerCase().includes(searchQuery.toLowerCase())
|
brief.brandName.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
const matchesStatus = statusFilter === 'all' || brief.status === statusFilter
|
const matchesStatus = statusFilter === 'all' || brief.status === statusFilter
|
||||||
return matchesSearch && matchesStatus
|
return matchesSearch && matchesStatus
|
||||||
})
|
})
|
||||||
|
|
||||||
const pendingCount = mockBriefs.filter(b => b.status === 'pending').length
|
const pendingCount = briefs.filter(b => b.status === 'pending').length
|
||||||
const configuredCount = mockBriefs.filter(b => b.status === 'configured').length
|
const configuredCount = briefs.filter(b => b.status === 'configured').length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 min-h-0">
|
<div className="space-y-6 min-h-0">
|
||||||
@ -143,7 +265,7 @@ export default function AgencyBriefsPage() {
|
|||||||
{filteredBriefs.map((brief) => {
|
{filteredBriefs.map((brief) => {
|
||||||
const platform = getPlatformInfo(brief.platform)
|
const platform = getPlatformInfo(brief.platform)
|
||||||
return (
|
return (
|
||||||
<Link key={brief.id} href={`/agency/briefs/${brief.id}`}>
|
<Link key={brief.id} href={`/agency/briefs/${brief.projectId}`}>
|
||||||
<Card className="hover:border-accent-indigo/50 transition-colors cursor-pointer overflow-hidden">
|
<Card className="hover:border-accent-indigo/50 transition-colors cursor-pointer overflow-hidden">
|
||||||
{/* 平台顶部条 */}
|
{/* 平台顶部条 */}
|
||||||
{platform && (
|
{platform && (
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useToast } from '@/components/ui/Toast'
|
import { useToast } from '@/components/ui/Toast'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
@ -26,9 +26,14 @@ import {
|
|||||||
MessageSquareText,
|
MessageSquareText,
|
||||||
Trash2,
|
Trash2,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
X
|
X,
|
||||||
|
Loader2
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { getPlatformInfo } from '@/lib/platforms'
|
import { getPlatformInfo } from '@/lib/platforms'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
|
import type { CreatorDetail } from '@/types/organization'
|
||||||
|
import type { TaskResponse } from '@/types/task'
|
||||||
|
|
||||||
// 任务进度阶段
|
// 任务进度阶段
|
||||||
type TaskStage = 'script_pending' | 'script_ai_review' | 'script_agency_review' | 'script_brand_review' |
|
type TaskStage = 'script_pending' | 'script_ai_review' | 'script_agency_review' | 'script_brand_review' |
|
||||||
@ -47,6 +52,23 @@ const stageConfig: Record<TaskStage, { label: string; color: string; bgColor: st
|
|||||||
completed: { label: '已完成', color: 'text-accent-green', bgColor: 'bg-accent-green/15' },
|
completed: { label: '已完成', color: 'text-accent-green', bgColor: 'bg-accent-green/15' },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 后端 TaskStage 到本地 TaskStage 的映射
|
||||||
|
function mapBackendStage(backendStage: string): TaskStage {
|
||||||
|
const mapping: Record<string, TaskStage> = {
|
||||||
|
'script_upload': 'script_pending',
|
||||||
|
'script_ai_review': 'script_ai_review',
|
||||||
|
'script_agency_review': 'script_agency_review',
|
||||||
|
'script_brand_review': 'script_brand_review',
|
||||||
|
'video_upload': 'video_pending',
|
||||||
|
'video_ai_review': 'video_ai_review',
|
||||||
|
'video_agency_review': 'video_agency_review',
|
||||||
|
'video_brand_review': 'video_brand_review',
|
||||||
|
'completed': 'completed',
|
||||||
|
'rejected': 'completed',
|
||||||
|
}
|
||||||
|
return mapping[backendStage] || 'script_pending'
|
||||||
|
}
|
||||||
|
|
||||||
// 任务类型
|
// 任务类型
|
||||||
interface CreatorTask {
|
interface CreatorTask {
|
||||||
id: string
|
id: string
|
||||||
@ -172,9 +194,19 @@ export default function AgencyCreatorsPage() {
|
|||||||
const [inviteCreatorId, setInviteCreatorId] = useState('')
|
const [inviteCreatorId, setInviteCreatorId] = useState('')
|
||||||
const [inviteResult, setInviteResult] = useState<{ success: boolean; message: string } | null>(null)
|
const [inviteResult, setInviteResult] = useState<{ success: boolean; message: string } | null>(null)
|
||||||
const [expandedCreators, setExpandedCreators] = useState<string[]>([])
|
const [expandedCreators, setExpandedCreators] = useState<string[]>([])
|
||||||
const [creators, setCreators] = useState(mockCreators)
|
const [creators, setCreators] = useState<Creator[]>(USE_MOCK ? mockCreators : [])
|
||||||
const [copiedId, setCopiedId] = useState<string | null>(null)
|
const [copiedId, setCopiedId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// 加载状态
|
||||||
|
const [loading, setLoading] = useState(!USE_MOCK)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
// 项目列表(API 模式用于分配弹窗)
|
||||||
|
const [projects, setProjects] = useState<{ id: string; name: string }[]>(USE_MOCK ? mockProjects : [])
|
||||||
|
|
||||||
|
// 任务数据(API 模式按达人ID分组)
|
||||||
|
const [creatorTasksMap, setCreatorTasksMap] = useState<Record<string, CreatorTask[]>>({})
|
||||||
|
|
||||||
// 操作菜单状态
|
// 操作菜单状态
|
||||||
const [openMenuId, setOpenMenuId] = useState<string | null>(null)
|
const [openMenuId, setOpenMenuId] = useState<string | null>(null)
|
||||||
|
|
||||||
@ -189,11 +221,97 @@ export default function AgencyCreatorsPage() {
|
|||||||
const [assignModal, setAssignModal] = useState<{ open: boolean; creator: Creator | null }>({ open: false, creator: null })
|
const [assignModal, setAssignModal] = useState<{ open: boolean; creator: Creator | null }>({ open: false, creator: null })
|
||||||
const [selectedProject, setSelectedProject] = useState('')
|
const [selectedProject, setSelectedProject] = useState('')
|
||||||
|
|
||||||
|
// API 模式下将 CreatorDetail 转换为 Creator 类型
|
||||||
|
const mapCreatorDetailToCreator = useCallback((detail: CreatorDetail, tasks: CreatorTask[]): Creator => {
|
||||||
|
return {
|
||||||
|
id: detail.id,
|
||||||
|
creatorId: detail.id,
|
||||||
|
name: detail.name,
|
||||||
|
avatar: detail.avatar || detail.name.charAt(0),
|
||||||
|
status: 'active',
|
||||||
|
projectCount: 0,
|
||||||
|
scriptCount: { total: 0, passed: 0 },
|
||||||
|
videoCount: { total: 0, passed: 0 },
|
||||||
|
passRate: 0,
|
||||||
|
trend: 'stable',
|
||||||
|
joinedAt: '-',
|
||||||
|
tasks,
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 将后端 TaskResponse 转为本地 CreatorTask
|
||||||
|
const mapTaskResponseToCreatorTask = useCallback((task: TaskResponse): CreatorTask => {
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
name: task.name,
|
||||||
|
projectName: task.project?.name || '-',
|
||||||
|
platform: 'douyin', // 后端暂未返回平台信息,默认
|
||||||
|
stage: mapBackendStage(task.stage),
|
||||||
|
appealRemaining: task.appeal_count,
|
||||||
|
appealUsed: task.is_appeal ? 1 : 0,
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 加载数据(API 模式)
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
if (USE_MOCK) return
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
// 并行加载达人列表、任务列表、项目列表
|
||||||
|
const [creatorsRes, tasksRes, projectsRes] = await Promise.all([
|
||||||
|
api.listAgencyCreators(),
|
||||||
|
api.listTasks(1, 100),
|
||||||
|
api.listProjects(1, 100),
|
||||||
|
])
|
||||||
|
|
||||||
|
// 构建项目列表
|
||||||
|
setProjects(projectsRes.items.map(p => ({ id: p.id, name: p.name })))
|
||||||
|
|
||||||
|
// 按达人ID分组任务
|
||||||
|
const tasksMap: Record<string, CreatorTask[]> = {}
|
||||||
|
for (const task of tasksRes.items) {
|
||||||
|
const cid = task.creator?.id
|
||||||
|
if (cid) {
|
||||||
|
if (!tasksMap[cid]) tasksMap[cid] = []
|
||||||
|
tasksMap[cid].push(mapTaskResponseToCreatorTask(task))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setCreatorTasksMap(tasksMap)
|
||||||
|
|
||||||
|
// 构建达人列表
|
||||||
|
const mappedCreators = creatorsRes.items.map(detail =>
|
||||||
|
mapCreatorDetailToCreator(detail, tasksMap[detail.id] || [])
|
||||||
|
)
|
||||||
|
setCreators(mappedCreators)
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '加载达人数据失败'
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [mapCreatorDetailToCreator, mapTaskResponseToCreatorTask, toast])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData()
|
||||||
|
}, [fetchData])
|
||||||
|
|
||||||
const filteredCreators = creators.filter(creator =>
|
const filteredCreators = creators.filter(creator =>
|
||||||
creator.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
creator.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
creator.creatorId.toLowerCase().includes(searchQuery.toLowerCase())
|
creator.creatorId.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 统计数据
|
||||||
|
const totalCreators = creators.length
|
||||||
|
const activeCreators = USE_MOCK
|
||||||
|
? creators.filter(c => c.status === 'active').length
|
||||||
|
: creators.length // API 模式下返回的都是已关联达人
|
||||||
|
const totalScripts = USE_MOCK
|
||||||
|
? creators.reduce((sum, c) => sum + c.scriptCount.total, 0)
|
||||||
|
: 0
|
||||||
|
const totalVideos = USE_MOCK
|
||||||
|
? creators.reduce((sum, c) => sum + c.videoCount.total, 0)
|
||||||
|
: 0
|
||||||
|
|
||||||
// 切换展开状态
|
// 切换展开状态
|
||||||
const toggleExpand = (creatorId: string) => {
|
const toggleExpand = (creatorId: string) => {
|
||||||
setExpandedCreators(prev =>
|
setExpandedCreators(prev =>
|
||||||
@ -211,7 +329,8 @@ export default function AgencyCreatorsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 增加申诉次数
|
// 增加申诉次数
|
||||||
const handleAddAppealQuota = (creatorId: string, taskId: string) => {
|
const handleAddAppealQuota = async (creatorId: string, taskId: string) => {
|
||||||
|
if (USE_MOCK) {
|
||||||
setCreators(prev => prev.map(creator => {
|
setCreators(prev => prev.map(creator => {
|
||||||
if (creator.id === creatorId) {
|
if (creator.id === creatorId) {
|
||||||
return {
|
return {
|
||||||
@ -226,15 +345,44 @@ export default function AgencyCreatorsPage() {
|
|||||||
}
|
}
|
||||||
return creator
|
return creator
|
||||||
}))
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
await api.increaseAppealCount(taskId)
|
||||||
|
// 更新本地状态
|
||||||
|
setCreators(prev => prev.map(creator => {
|
||||||
|
if (creator.id === creatorId) {
|
||||||
|
return {
|
||||||
|
...creator,
|
||||||
|
tasks: creator.tasks.map(task => {
|
||||||
|
if (task.id === taskId) {
|
||||||
|
return { ...task, appealRemaining: task.appealRemaining + 1 }
|
||||||
|
}
|
||||||
|
return task
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return creator
|
||||||
|
}))
|
||||||
|
toast.success('已增加 1 次申诉机会')
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '增加申诉次数失败'
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 邀请达人
|
// 邀请达人
|
||||||
const handleInvite = () => {
|
const handleInvite = async () => {
|
||||||
if (!inviteCreatorId.trim()) {
|
if (!inviteCreatorId.trim()) {
|
||||||
setInviteResult({ success: false, message: '请输入达人ID' })
|
setInviteResult({ success: false, message: '请输入达人ID' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (USE_MOCK) {
|
||||||
// 模拟检查达人ID是否存在
|
// 模拟检查达人ID是否存在
|
||||||
const idPattern = /^CR\d{6}$/
|
const idPattern = /^CR\d{6}$/
|
||||||
if (!idPattern.test(inviteCreatorId.toUpperCase())) {
|
if (!idPattern.test(inviteCreatorId.toUpperCase())) {
|
||||||
@ -250,6 +398,21 @@ export default function AgencyCreatorsPage() {
|
|||||||
|
|
||||||
// 模拟发送邀请成功
|
// 模拟发送邀请成功
|
||||||
setInviteResult({ success: true, message: `已向达人 ${inviteCreatorId.toUpperCase()} 发送邀请` })
|
setInviteResult({ success: true, message: `已向达人 ${inviteCreatorId.toUpperCase()} 发送邀请` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 模式
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
await api.inviteCreator(inviteCreatorId.trim())
|
||||||
|
setInviteResult({ success: true, message: `已向达人 ${inviteCreatorId.trim()} 发送邀请` })
|
||||||
|
toast.success('邀请已发送')
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '邀请达人失败'
|
||||||
|
setInviteResult({ success: false, message })
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCloseInviteModal = () => {
|
const handleCloseInviteModal = () => {
|
||||||
@ -283,11 +446,28 @@ export default function AgencyCreatorsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 确认删除
|
// 确认删除
|
||||||
const handleConfirmDelete = () => {
|
const handleConfirmDelete = async () => {
|
||||||
if (deleteModal.creator) {
|
if (!deleteModal.creator) return
|
||||||
|
|
||||||
|
if (USE_MOCK) {
|
||||||
setCreators(prev => prev.filter(c => c.id !== deleteModal.creator!.id))
|
setCreators(prev => prev.filter(c => c.id !== deleteModal.creator!.id))
|
||||||
}
|
|
||||||
setDeleteModal({ open: false, creator: null })
|
setDeleteModal({ open: false, creator: null })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 模式
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
await api.removeCreator(deleteModal.creator.id)
|
||||||
|
setCreators(prev => prev.filter(c => c.id !== deleteModal.creator!.id))
|
||||||
|
toast.success(`已移除达人「${deleteModal.creator.name}」`)
|
||||||
|
setDeleteModal({ open: false, creator: null })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '移除达人失败'
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 打开分配项目弹窗
|
// 打开分配项目弹窗
|
||||||
@ -299,14 +479,66 @@ export default function AgencyCreatorsPage() {
|
|||||||
|
|
||||||
// 确认分配项目
|
// 确认分配项目
|
||||||
const handleConfirmAssign = () => {
|
const handleConfirmAssign = () => {
|
||||||
|
const projectList = USE_MOCK ? mockProjects : projects
|
||||||
if (assignModal.creator && selectedProject) {
|
if (assignModal.creator && selectedProject) {
|
||||||
const project = mockProjects.find(p => p.id === selectedProject)
|
const project = projectList.find(p => p.id === selectedProject)
|
||||||
toast.success(`已将达人「${assignModal.creator.name}」分配到项目「${project?.name}」`)
|
toast.success(`已将达人「${assignModal.creator.name}」分配到项目「${project?.name}」`)
|
||||||
}
|
}
|
||||||
setAssignModal({ open: false, creator: null })
|
setAssignModal({ open: false, creator: null })
|
||||||
setSelectedProject('')
|
setSelectedProject('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 骨架屏
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 min-h-0">
|
||||||
|
{/* 页面标题 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-text-primary">达人管理</h1>
|
||||||
|
<p className="text-sm text-text-secondary mt-1">管理合作达人,查看任务进度和申诉次数</p>
|
||||||
|
</div>
|
||||||
|
<Button disabled>
|
||||||
|
<Plus size={16} />
|
||||||
|
邀请达人
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 统计卡片骨架 */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
{[1, 2, 3, 4].map(i => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-4 w-16 bg-bg-elevated rounded animate-pulse" />
|
||||||
|
<div className="h-8 w-10 bg-bg-elevated rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-bg-elevated animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 搜索骨架 */}
|
||||||
|
<div className="h-11 w-full max-w-md bg-bg-elevated rounded-xl animate-pulse" />
|
||||||
|
|
||||||
|
{/* 表格骨架 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 size={32} className="animate-spin text-accent-indigo" />
|
||||||
|
<span className="ml-3 text-text-secondary">加载达人数据...</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectList = USE_MOCK ? mockProjects : projects
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 min-h-0">
|
<div className="space-y-6 min-h-0">
|
||||||
{/* 页面标题 */}
|
{/* 页面标题 */}
|
||||||
@ -328,7 +560,7 @@ export default function AgencyCreatorsPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-text-secondary">总达人数</p>
|
<p className="text-sm text-text-secondary">总达人数</p>
|
||||||
<p className="text-2xl font-bold text-text-primary">{mockCreators.length}</p>
|
<p className="text-2xl font-bold text-text-primary">{totalCreators}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-10 h-10 rounded-lg bg-accent-indigo/20 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-lg bg-accent-indigo/20 flex items-center justify-center">
|
||||||
<Users size={20} className="text-accent-indigo" />
|
<Users size={20} className="text-accent-indigo" />
|
||||||
@ -341,7 +573,7 @@ export default function AgencyCreatorsPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-text-secondary">已激活</p>
|
<p className="text-sm text-text-secondary">已激活</p>
|
||||||
<p className="text-2xl font-bold text-accent-green">{mockCreators.filter(c => c.status === 'active').length}</p>
|
<p className="text-2xl font-bold text-accent-green">{activeCreators}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-10 h-10 rounded-lg bg-accent-green/20 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-lg bg-accent-green/20 flex items-center justify-center">
|
||||||
<CheckCircle size={20} className="text-accent-green" />
|
<CheckCircle size={20} className="text-accent-green" />
|
||||||
@ -354,7 +586,7 @@ export default function AgencyCreatorsPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-text-secondary">总脚本数</p>
|
<p className="text-sm text-text-secondary">总脚本数</p>
|
||||||
<p className="text-2xl font-bold text-text-primary">{mockCreators.reduce((sum, c) => sum + c.scriptCount.total, 0)}</p>
|
<p className="text-2xl font-bold text-text-primary">{USE_MOCK ? totalScripts : '-'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
|
||||||
<FileText size={20} className="text-purple-400" />
|
<FileText size={20} className="text-purple-400" />
|
||||||
@ -367,7 +599,7 @@ export default function AgencyCreatorsPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-text-secondary">总视频数</p>
|
<p className="text-sm text-text-secondary">总视频数</p>
|
||||||
<p className="text-2xl font-bold text-text-primary">{mockCreators.reduce((sum, c) => sum + c.videoCount.total, 0)}</p>
|
<p className="text-2xl font-bold text-text-primary">{USE_MOCK ? totalVideos : '-'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-10 h-10 rounded-lg bg-orange-500/20 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-lg bg-orange-500/20 flex items-center justify-center">
|
||||||
<Video size={20} className="text-orange-400" />
|
<Video size={20} className="text-orange-400" />
|
||||||
@ -488,18 +720,34 @@ export default function AgencyCreatorsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
|
{USE_MOCK ? (
|
||||||
<StatusTag status={creator.status} />
|
<StatusTag status={creator.status} />
|
||||||
|
) : (
|
||||||
|
<SuccessTag>已关联</SuccessTag>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
|
{USE_MOCK ? (
|
||||||
|
<>
|
||||||
<span className="text-text-primary">{creator.scriptCount.passed}</span>
|
<span className="text-text-primary">{creator.scriptCount.passed}</span>
|
||||||
<span className="text-text-tertiary">/{creator.scriptCount.total}</span>
|
<span className="text-text-tertiary">/{creator.scriptCount.total}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-text-tertiary">-</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
|
{USE_MOCK ? (
|
||||||
|
<>
|
||||||
<span className="text-text-primary">{creator.videoCount.passed}</span>
|
<span className="text-text-primary">{creator.videoCount.passed}</span>
|
||||||
<span className="text-text-tertiary">/{creator.videoCount.total}</span>
|
<span className="text-text-tertiary">/{creator.videoCount.total}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-text-tertiary">-</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
{creator.status === 'active' && creator.passRate > 0 ? (
|
{USE_MOCK && creator.status === 'active' && creator.passRate > 0 ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={`font-medium ${creator.passRate >= 90 ? 'text-accent-green' : creator.passRate >= 80 ? 'text-accent-indigo' : 'text-orange-400'}`}>
|
<span className={`font-medium ${creator.passRate >= 90 ? 'text-accent-green' : creator.passRate >= 80 ? 'text-accent-indigo' : 'text-orange-400'}`}>
|
||||||
{creator.passRate}%
|
{creator.passRate}%
|
||||||
@ -589,9 +837,14 @@ export default function AgencyCreatorsPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
disabled={submitting}
|
||||||
onClick={() => handleAddAppealQuota(creator.id, task.id)}
|
onClick={() => handleAddAppealQuota(creator.id, task.id)}
|
||||||
>
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
) : (
|
||||||
<PlusCircle size={14} />
|
<PlusCircle size={14} />
|
||||||
|
)}
|
||||||
+1 次
|
+1 次
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -639,7 +892,8 @@ export default function AgencyCreatorsPage() {
|
|||||||
placeholder="例如: CR123456"
|
placeholder="例如: CR123456"
|
||||||
className="flex-1 px-4 py-2.5 border border-border-subtle rounded-xl bg-bg-elevated text-text-primary font-mono focus:outline-none focus:ring-2 focus:ring-accent-indigo"
|
className="flex-1 px-4 py-2.5 border border-border-subtle rounded-xl bg-bg-elevated text-text-primary font-mono focus:outline-none focus:ring-2 focus:ring-accent-indigo"
|
||||||
/>
|
/>
|
||||||
<Button variant="secondary" onClick={handleInvite}>
|
<Button variant="secondary" onClick={handleInvite} disabled={submitting}>
|
||||||
|
{submitting ? <Loader2 size={16} className="animate-spin" /> : null}
|
||||||
查找
|
查找
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -669,6 +923,9 @@ export default function AgencyCreatorsPage() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (inviteResult?.success) {
|
if (inviteResult?.success) {
|
||||||
handleCloseInviteModal()
|
handleCloseInviteModal()
|
||||||
|
if (!USE_MOCK) {
|
||||||
|
fetchData() // 刷新达人列表
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={!inviteResult?.success}
|
disabled={!inviteResult?.success}
|
||||||
@ -734,8 +991,9 @@ export default function AgencyCreatorsPage() {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="border-accent-coral text-accent-coral hover:bg-accent-coral/10"
|
className="border-accent-coral text-accent-coral hover:bg-accent-coral/10"
|
||||||
onClick={handleConfirmDelete}
|
onClick={handleConfirmDelete}
|
||||||
|
disabled={submitting}
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
{submitting ? <Loader2 size={16} className="animate-spin" /> : <Trash2 size={16} />}
|
||||||
确认移除
|
确认移除
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -755,7 +1013,7 @@ export default function AgencyCreatorsPage() {
|
|||||||
<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="space-y-2">
|
<div className="space-y-2">
|
||||||
{mockProjects.map((project) => (
|
{projectList.map((project) => (
|
||||||
<label
|
<label
|
||||||
key={project.id}
|
key={project.id}
|
||||||
className={`flex items-center gap-3 p-4 rounded-xl border cursor-pointer transition-colors ${
|
className={`flex items-center gap-3 p-4 rounded-xl border cursor-pointer transition-colors ${
|
||||||
@ -775,6 +1033,9 @@ export default function AgencyCreatorsPage() {
|
|||||||
<span className="text-text-primary">{project.name}</span>
|
<span className="text-text-primary">{project.name}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
|
{projectList.length === 0 && (
|
||||||
|
<p className="text-text-tertiary text-sm text-center py-4">暂无可分配的项目</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 justify-end pt-2">
|
<div className="flex gap-3 justify-end pt-2">
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useToast } from '@/components/ui/Toast'
|
import { useToast } from '@/components/ui/Toast'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
@ -19,9 +19,13 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
File,
|
File,
|
||||||
Check
|
Check,
|
||||||
|
Loader2
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { getPlatformInfo } from '@/lib/platforms'
|
import { getPlatformInfo } from '@/lib/platforms'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
|
import type { AgencyDashboard } from '@/types/dashboard'
|
||||||
|
|
||||||
// 时间范围类型
|
// 时间范围类型
|
||||||
type DateRange = 'week' | 'month' | 'quarter' | 'year'
|
type DateRange = 'week' | 'month' | 'quarter' | 'year'
|
||||||
@ -180,9 +184,48 @@ export default function AgencyReportsPage() {
|
|||||||
const [exportFormat, setExportFormat] = useState<'csv' | 'excel' | 'pdf'>('excel')
|
const [exportFormat, setExportFormat] = useState<'csv' | 'excel' | 'pdf'>('excel')
|
||||||
const [isExporting, setIsExporting] = useState(false)
|
const [isExporting, setIsExporting] = useState(false)
|
||||||
const [exportSuccess, setExportSuccess] = useState(false)
|
const [exportSuccess, setExportSuccess] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [dashboardData, setDashboardData] = useState<AgencyDashboard | null>(null)
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
const currentData = mockDataByRange[dateRange]
|
const fetchData = useCallback(async () => {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const data = await api.getAgencyDashboard()
|
||||||
|
setDashboardData(data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch agency dashboard:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData()
|
||||||
|
}, [fetchData])
|
||||||
|
|
||||||
|
// In API mode, derive stats from dashboard data where possible
|
||||||
|
// For fields the backend doesn't provide (trend data, project stats, creator ranking),
|
||||||
|
// we still use mock data as placeholders since there's no dedicated reports API yet.
|
||||||
|
const currentData = USE_MOCK ? mockDataByRange[dateRange] : (() => {
|
||||||
|
const base = mockDataByRange[dateRange]
|
||||||
|
if (dashboardData) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
stats: {
|
||||||
|
...base.stats,
|
||||||
|
totalScripts: dashboardData.pending_review.script + dashboardData.today_passed.script + dashboardData.in_progress.script,
|
||||||
|
totalVideos: dashboardData.pending_review.video + dashboardData.today_passed.video + dashboardData.in_progress.video,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
})()
|
||||||
|
|
||||||
// 导出报表
|
// 导出报表
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
@ -246,6 +289,15 @@ export default function AgencyReportsPage() {
|
|||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-24 text-text-tertiary">
|
||||||
|
<Loader2 size={32} className="animate-spin mb-4" />
|
||||||
|
<p>加载中...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 页面标题 */}
|
{/* 页面标题 */}
|
||||||
@ -276,6 +328,30 @@ export default function AgencyReportsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Dashboard summary banner (API mode only) */}
|
||||||
|
{!USE_MOCK && dashboardData && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<div className="p-3 rounded-xl bg-accent-amber/10 border border-accent-amber/20">
|
||||||
|
<p className="text-xs text-text-tertiary">待审核 (脚本/视频)</p>
|
||||||
|
<p className="text-lg font-bold text-accent-amber mt-1">
|
||||||
|
{dashboardData.pending_review.script} / {dashboardData.pending_review.video}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-xl bg-accent-coral/10 border border-accent-coral/20">
|
||||||
|
<p className="text-xs text-text-tertiary">待处理申诉</p>
|
||||||
|
<p className="text-lg font-bold text-accent-coral mt-1">{dashboardData.pending_appeal}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-xl bg-accent-indigo/10 border border-accent-indigo/20">
|
||||||
|
<p className="text-xs text-text-tertiary">达人总数</p>
|
||||||
|
<p className="text-lg font-bold text-accent-indigo mt-1">{dashboardData.total_creators}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-xl bg-accent-green/10 border border-accent-green/20">
|
||||||
|
<p className="text-xs text-text-tertiary">任务总数</p>
|
||||||
|
<p className="text-lg font-bold text-accent-green mt-1">{dashboardData.total_tasks}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 核心指标 */}
|
{/* 核心指标 */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<StatCard
|
<StatCard
|
||||||
@ -504,7 +580,7 @@ export default function AgencyReportsPage() {
|
|||||||
<Button onClick={handleExport} disabled={isExporting}>
|
<Button onClick={handleExport} disabled={isExporting}>
|
||||||
{isExporting ? (
|
{isExporting ? (
|
||||||
<>
|
<>
|
||||||
<Clock size={16} className="animate-spin" />
|
<Loader2 size={16} className="animate-spin" />
|
||||||
导出中...
|
导出中...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
@ -15,8 +15,12 @@ import {
|
|||||||
Video,
|
Video,
|
||||||
User,
|
User,
|
||||||
Calendar,
|
Calendar,
|
||||||
Download
|
Download,
|
||||||
|
Loader2
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
|
import type { TaskResponse } from '@/types/task'
|
||||||
|
|
||||||
// 审核历史记录类型
|
// 审核历史记录类型
|
||||||
interface ReviewHistoryItem {
|
interface ReviewHistoryItem {
|
||||||
@ -87,14 +91,84 @@ const mockHistoryData: ReviewHistoryItem[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a completed TaskResponse to the ReviewHistoryItem UI model.
|
||||||
|
*/
|
||||||
|
function mapTaskToHistoryItem(task: TaskResponse): ReviewHistoryItem {
|
||||||
|
// Determine content type based on the latest stage info
|
||||||
|
// If the task reached video stages, it's a video review; otherwise script
|
||||||
|
const hasVideoReview = task.video_agency_status !== null && task.video_agency_status !== undefined
|
||||||
|
const contentType: 'script' | 'video' = hasVideoReview ? 'video' : 'script'
|
||||||
|
|
||||||
|
// Determine result
|
||||||
|
let result: 'approved' | 'rejected' = 'approved'
|
||||||
|
let reason: string | undefined
|
||||||
|
|
||||||
|
if (task.stage === 'rejected') {
|
||||||
|
result = 'rejected'
|
||||||
|
// Try to pick up the rejection reason
|
||||||
|
if (hasVideoReview) {
|
||||||
|
reason = task.video_agency_comment || task.video_brand_comment || undefined
|
||||||
|
} else {
|
||||||
|
reason = task.script_agency_comment || task.script_brand_comment || undefined
|
||||||
|
}
|
||||||
|
} else if (task.stage === 'completed') {
|
||||||
|
result = 'approved'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
}).replace(/\//g, '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
taskId: task.id,
|
||||||
|
taskTitle: task.name,
|
||||||
|
creatorName: task.creator.name,
|
||||||
|
contentType,
|
||||||
|
result,
|
||||||
|
reason,
|
||||||
|
reviewedAt: formatDate(task.updated_at),
|
||||||
|
projectName: task.project.name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function AgencyReviewHistoryPage() {
|
export default function AgencyReviewHistoryPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [filterResult, setFilterResult] = useState<'all' | 'approved' | 'rejected'>('all')
|
const [filterResult, setFilterResult] = useState<'all' | 'approved' | 'rejected'>('all')
|
||||||
const [filterType, setFilterType] = useState<'all' | 'script' | 'video'>('all')
|
const [filterType, setFilterType] = useState<'all' | 'script' | 'video'>('all')
|
||||||
|
const [historyData, setHistoryData] = useState<ReviewHistoryItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const fetchHistory = useCallback(async () => {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
setHistoryData(mockHistoryData)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const response = await api.listTasks(1, 50, 'completed')
|
||||||
|
setHistoryData(response.items.map(mapTaskToHistoryItem))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch review history:', err)
|
||||||
|
setHistoryData([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchHistory()
|
||||||
|
}, [fetchHistory])
|
||||||
|
|
||||||
// 筛选数据
|
// 筛选数据
|
||||||
const filteredHistory = mockHistoryData.filter(item => {
|
const filteredHistory = historyData.filter(item => {
|
||||||
const matchesSearch = searchQuery === '' ||
|
const matchesSearch = searchQuery === '' ||
|
||||||
item.taskTitle.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
item.taskTitle.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
item.creatorName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
item.creatorName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
@ -105,8 +179,8 @@ export default function AgencyReviewHistoryPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 统计
|
// 统计
|
||||||
const approvedCount = mockHistoryData.filter(i => i.result === 'approved').length
|
const approvedCount = historyData.filter(i => i.result === 'approved').length
|
||||||
const rejectedCount = mockHistoryData.filter(i => i.result === 'rejected').length
|
const rejectedCount = historyData.filter(i => i.result === 'rejected').length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@ -139,7 +213,7 @@ export default function AgencyReviewHistoryPage() {
|
|||||||
<History size={20} className="text-accent-indigo" />
|
<History size={20} className="text-accent-indigo" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-2xl font-bold text-text-primary">{mockHistoryData.length}</p>
|
<p className="text-2xl font-bold text-text-primary">{historyData.length}</p>
|
||||||
<p className="text-sm text-text-secondary">总审核数</p>
|
<p className="text-sm text-text-secondary">总审核数</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -236,7 +310,12 @@ export default function AgencyReviewHistoryPage() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{filteredHistory.length > 0 ? (
|
{loading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-text-tertiary">
|
||||||
|
<Loader2 size={32} className="animate-spin mb-4" />
|
||||||
|
<p>加载中...</p>
|
||||||
|
</div>
|
||||||
|
) : filteredHistory.length > 0 ? (
|
||||||
filteredHistory.map((item) => (
|
filteredHistory.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useRouter, useParams } from 'next/navigation'
|
import { useRouter, useParams } from 'next/navigation'
|
||||||
import { useToast } from '@/components/ui/Toast'
|
import { useToast } from '@/components/ui/Toast'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||||
@ -19,9 +19,13 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
Shield,
|
Shield,
|
||||||
Download,
|
Download,
|
||||||
MessageSquareWarning
|
MessageSquareWarning,
|
||||||
|
Loader2
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { FilePreview, FileInfoCard, FilePreviewModal, type FileInfo } from '@/components/ui/FilePreview'
|
import { FilePreview, FileInfoCard, FilePreviewModal, type FileInfo } from '@/components/ui/FilePreview'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
|
import type { TaskResponse } from '@/types/task'
|
||||||
|
|
||||||
// 模拟脚本任务数据
|
// 模拟脚本任务数据
|
||||||
const mockScriptTask = {
|
const mockScriptTask = {
|
||||||
@ -70,6 +74,53 @@ const mockScriptTask = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 从 TaskResponse 映射到页面视图模型
|
||||||
|
function mapTaskToViewModel(task: TaskResponse) {
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
title: task.name,
|
||||||
|
creatorName: task.creator?.name || '未知达人',
|
||||||
|
projectName: task.project?.name || '未知项目',
|
||||||
|
submittedAt: task.script_uploaded_at || task.created_at,
|
||||||
|
aiScore: task.script_ai_score ?? 0,
|
||||||
|
status: task.stage,
|
||||||
|
file: {
|
||||||
|
id: `file-${task.id}`,
|
||||||
|
fileName: task.script_file_name || '未知文件',
|
||||||
|
fileSize: '',
|
||||||
|
fileType: 'application/octet-stream',
|
||||||
|
fileUrl: task.script_file_url || '',
|
||||||
|
uploadedAt: task.script_uploaded_at || task.created_at,
|
||||||
|
} as FileInfo,
|
||||||
|
isAppeal: task.is_appeal,
|
||||||
|
appealReason: task.appeal_reason || '',
|
||||||
|
scriptContent: {
|
||||||
|
opening: '',
|
||||||
|
productIntro: '',
|
||||||
|
demo: '',
|
||||||
|
closing: '',
|
||||||
|
},
|
||||||
|
aiAnalysis: {
|
||||||
|
violations: (task.script_ai_result?.violations || []).map((v, idx) => ({
|
||||||
|
id: `v${idx + 1}`,
|
||||||
|
type: v.type,
|
||||||
|
content: v.content,
|
||||||
|
suggestion: v.suggestion,
|
||||||
|
severity: v.severity,
|
||||||
|
})),
|
||||||
|
complianceChecks: (task.script_ai_result?.soft_warnings || []).map((w) => ({
|
||||||
|
item: w.type,
|
||||||
|
passed: false,
|
||||||
|
note: w.content,
|
||||||
|
})),
|
||||||
|
sellingPoints: [] as Array<{ point: string; covered: boolean }>,
|
||||||
|
},
|
||||||
|
aiSummary: task.script_ai_result?.summary || '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScriptTaskViewModel = ReturnType<typeof mapTaskToViewModel>
|
||||||
|
|
||||||
function ReviewProgressBar({ taskStatus }: { taskStatus: string }) {
|
function ReviewProgressBar({ taskStatus }: { taskStatus: string }) {
|
||||||
const steps = getAgencyReviewSteps(taskStatus)
|
const steps = getAgencyReviewSteps(taskStatus)
|
||||||
const currentStep = steps.find(s => s.status === 'current')
|
const currentStep = steps.find(s => s.status === 'current')
|
||||||
@ -89,10 +140,40 @@ function ReviewProgressBar({ taskStatus }: { taskStatus: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LoadingSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 animate-pulse">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 bg-bg-elevated rounded-full" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="h-6 bg-bg-elevated rounded w-1/3" />
|
||||||
|
<div className="h-4 bg-bg-elevated rounded w-1/4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-16 bg-bg-elevated rounded-xl" />
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-2 space-y-4">
|
||||||
|
<div className="h-32 bg-bg-elevated rounded-xl" />
|
||||||
|
<div className="h-64 bg-bg-elevated rounded-xl" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="h-20 bg-bg-elevated rounded-xl" />
|
||||||
|
<div className="h-40 bg-bg-elevated rounded-xl" />
|
||||||
|
<div className="h-40 bg-bg-elevated rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function AgencyScriptReviewPage() {
|
export default function AgencyScriptReviewPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
|
const taskId = params.id as string
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(!USE_MOCK)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [showApproveModal, setShowApproveModal] = useState(false)
|
const [showApproveModal, setShowApproveModal] = useState(false)
|
||||||
const [showRejectModal, setShowRejectModal] = useState(false)
|
const [showRejectModal, setShowRejectModal] = useState(false)
|
||||||
const [showForcePassModal, setShowForcePassModal] = useState(false)
|
const [showForcePassModal, setShowForcePassModal] = useState(false)
|
||||||
@ -100,33 +181,99 @@ export default function AgencyScriptReviewPage() {
|
|||||||
const [forcePassReason, setForcePassReason] = useState('')
|
const [forcePassReason, setForcePassReason] = useState('')
|
||||||
const [viewMode, setViewMode] = useState<'file' | 'parsed'>('file') // 'file' 显示原文件, 'parsed' 显示解析内容
|
const [viewMode, setViewMode] = useState<'file' | 'parsed'>('file') // 'file' 显示原文件, 'parsed' 显示解析内容
|
||||||
const [showFilePreview, setShowFilePreview] = useState(false)
|
const [showFilePreview, setShowFilePreview] = useState(false)
|
||||||
|
const [task, setTask] = useState<ScriptTaskViewModel>(mockScriptTask as unknown as ScriptTaskViewModel)
|
||||||
|
|
||||||
const task = mockScriptTask
|
const loadTask = useCallback(async () => {
|
||||||
|
if (USE_MOCK) return
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await api.getTask(taskId)
|
||||||
|
setTask(mapTaskToViewModel(data))
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : '加载任务详情失败'
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [taskId, toast])
|
||||||
|
|
||||||
const handleApprove = () => {
|
useEffect(() => {
|
||||||
|
loadTask()
|
||||||
|
}, [loadTask])
|
||||||
|
|
||||||
|
const handleApprove = async () => {
|
||||||
|
if (USE_MOCK) {
|
||||||
setShowApproveModal(false)
|
setShowApproveModal(false)
|
||||||
toast.success('已提交品牌方终审')
|
toast.success('已提交品牌方终审')
|
||||||
router.push('/agency/review')
|
router.push('/agency/review')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
await api.reviewScript(taskId, { action: 'pass' })
|
||||||
|
setShowApproveModal(false)
|
||||||
|
toast.success('已提交品牌方终审')
|
||||||
|
router.push('/agency/review')
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : '操作失败'
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReject = () => {
|
const handleReject = async () => {
|
||||||
if (!rejectReason.trim()) {
|
if (!rejectReason.trim()) {
|
||||||
toast.error('请填写驳回原因')
|
toast.error('请填写驳回原因')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (USE_MOCK) {
|
||||||
setShowRejectModal(false)
|
setShowRejectModal(false)
|
||||||
toast.success('已驳回')
|
toast.success('已驳回')
|
||||||
router.push('/agency/review')
|
router.push('/agency/review')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
await api.reviewScript(taskId, { action: 'reject', comment: rejectReason })
|
||||||
|
setShowRejectModal(false)
|
||||||
|
toast.success('已驳回')
|
||||||
|
router.push('/agency/review')
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : '操作失败'
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleForcePass = () => {
|
const handleForcePass = async () => {
|
||||||
if (!forcePassReason.trim()) {
|
if (!forcePassReason.trim()) {
|
||||||
toast.error('请填写强制通过原因')
|
toast.error('请填写强制通过原因')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (USE_MOCK) {
|
||||||
setShowForcePassModal(false)
|
setShowForcePassModal(false)
|
||||||
toast.success('已强制通过并提交品牌方终审')
|
toast.success('已强制通过并提交品牌方终审')
|
||||||
router.push('/agency/review')
|
router.push('/agency/review')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
await api.reviewScript(taskId, { action: 'force_pass', comment: forcePassReason })
|
||||||
|
setShowForcePassModal(false)
|
||||||
|
toast.success('已强制通过并提交品牌方终审')
|
||||||
|
router.push('/agency/review')
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : '操作失败'
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <LoadingSkeleton />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -226,21 +373,27 @@ export default function AgencyScriptReviewPage() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
{task.aiSummary ? (
|
||||||
|
<div className="p-4 bg-bg-elevated rounded-lg">
|
||||||
|
<div className="text-xs text-accent-indigo font-medium mb-2">AI 总结</div>
|
||||||
|
<p className="text-text-primary">{task.aiSummary}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="p-4 bg-bg-elevated rounded-lg">
|
<div className="p-4 bg-bg-elevated rounded-lg">
|
||||||
<div className="text-xs text-accent-indigo font-medium mb-2">开场白</div>
|
<div className="text-xs text-accent-indigo font-medium mb-2">开场白</div>
|
||||||
<p className="text-text-primary">{task.scriptContent.opening}</p>
|
<p className="text-text-primary">{task.scriptContent.opening || '(无内容)'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 bg-bg-elevated rounded-lg">
|
<div className="p-4 bg-bg-elevated rounded-lg">
|
||||||
<div className="text-xs text-purple-400 font-medium mb-2">产品介绍</div>
|
<div className="text-xs text-purple-400 font-medium mb-2">产品介绍</div>
|
||||||
<p className="text-text-primary">{task.scriptContent.productIntro}</p>
|
<p className="text-text-primary">{task.scriptContent.productIntro || '(无内容)'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 bg-bg-elevated rounded-lg">
|
<div className="p-4 bg-bg-elevated rounded-lg">
|
||||||
<div className="text-xs text-orange-400 font-medium mb-2">使用演示</div>
|
<div className="text-xs text-orange-400 font-medium mb-2">使用演示</div>
|
||||||
<p className="text-text-primary">{task.scriptContent.demo}</p>
|
<p className="text-text-primary">{task.scriptContent.demo || '(无内容)'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 bg-bg-elevated rounded-lg">
|
<div className="p-4 bg-bg-elevated rounded-lg">
|
||||||
<div className="text-xs text-accent-green font-medium mb-2">结尾引导</div>
|
<div className="text-xs text-accent-green font-medium mb-2">结尾引导</div>
|
||||||
<p className="text-text-primary">{task.scriptContent.closing}</p>
|
<p className="text-text-primary">{task.scriptContent.closing || '(无内容)'}</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -275,7 +428,7 @@ export default function AgencyScriptReviewPage() {
|
|||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<WarningTag>{v.type}</WarningTag>
|
<WarningTag>{v.type}</WarningTag>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-text-primary">「{v.content}」</p>
|
<p className="text-sm text-text-primary">{v.content}</p>
|
||||||
<p className="text-xs text-accent-indigo mt-1">{v.suggestion}</p>
|
<p className="text-xs text-accent-indigo mt-1">{v.suggestion}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -331,6 +484,9 @@ export default function AgencyScriptReviewPage() {
|
|||||||
<span className="text-sm text-text-primary">{sp.point}</span>
|
<span className="text-sm text-text-primary">{sp.point}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{task.aiAnalysis.sellingPoints.length === 0 && (
|
||||||
|
<p className="text-sm text-text-tertiary text-center py-4">暂无卖点数据</p>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@ -344,13 +500,16 @@ export default function AgencyScriptReviewPage() {
|
|||||||
项目:{task.projectName}
|
项目:{task.projectName}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Button variant="danger" onClick={() => setShowRejectModal(true)}>
|
<Button variant="danger" onClick={() => setShowRejectModal(true)} disabled={submitting}>
|
||||||
|
{submitting ? <Loader2 size={16} className="animate-spin mr-1" /> : null}
|
||||||
驳回
|
驳回
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" onClick={() => setShowForcePassModal(true)}>
|
<Button variant="secondary" onClick={() => setShowForcePassModal(true)} disabled={submitting}>
|
||||||
|
{submitting ? <Loader2 size={16} className="animate-spin mr-1" /> : null}
|
||||||
强制通过
|
强制通过
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="success" onClick={() => setShowApproveModal(true)}>
|
<Button variant="success" onClick={() => setShowApproveModal(true)} disabled={submitting}>
|
||||||
|
{submitting ? <Loader2 size={16} className="animate-spin mr-1" /> : null}
|
||||||
通过
|
通过
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -382,8 +541,11 @@ export default function AgencyScriptReviewPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 justify-end">
|
<div className="flex gap-3 justify-end">
|
||||||
<Button variant="ghost" onClick={() => setShowRejectModal(false)}>取消</Button>
|
<Button variant="ghost" onClick={() => setShowRejectModal(false)} disabled={submitting}>取消</Button>
|
||||||
<Button variant="danger" onClick={handleReject}>确认驳回</Button>
|
<Button variant="danger" onClick={handleReject} disabled={submitting}>
|
||||||
|
{submitting ? <Loader2 size={16} className="animate-spin mr-1" /> : null}
|
||||||
|
确认驳回
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
@ -407,8 +569,11 @@ export default function AgencyScriptReviewPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 justify-end">
|
<div className="flex gap-3 justify-end">
|
||||||
<Button variant="ghost" onClick={() => setShowForcePassModal(false)}>取消</Button>
|
<Button variant="ghost" onClick={() => setShowForcePassModal(false)} disabled={submitting}>取消</Button>
|
||||||
<Button onClick={handleForcePass}>确认强制通过</Button>
|
<Button onClick={handleForcePass} disabled={submitting}>
|
||||||
|
{submitting ? <Loader2 size={16} className="animate-spin mr-1" /> : null}
|
||||||
|
确认强制通过
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useRouter, useParams } from 'next/navigation'
|
import { useRouter, useParams } from 'next/navigation'
|
||||||
import { useToast } from '@/components/ui/Toast'
|
import { useToast } from '@/components/ui/Toast'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||||
@ -21,9 +21,13 @@ import {
|
|||||||
XCircle,
|
XCircle,
|
||||||
Download,
|
Download,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
MessageSquareWarning
|
MessageSquareWarning,
|
||||||
|
Loader2
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { FileInfoCard, FilePreviewModal, type FileInfo } from '@/components/ui/FilePreview'
|
import { FileInfoCard, FilePreviewModal, type FileInfo } from '@/components/ui/FilePreview'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
|
import type { TaskResponse } from '@/types/task'
|
||||||
|
|
||||||
// 模拟视频任务数据
|
// 模拟视频任务数据
|
||||||
const mockVideoTask = {
|
const mockVideoTask = {
|
||||||
@ -82,6 +86,63 @@ const mockVideoTask = {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 从 TaskResponse 映射到页面视图模型
|
||||||
|
function mapTaskToViewModel(task: TaskResponse) {
|
||||||
|
const violations = (task.video_ai_result?.violations || []).map((v, idx) => ({
|
||||||
|
id: `v${idx + 1}`,
|
||||||
|
type: v.type,
|
||||||
|
content: v.content,
|
||||||
|
timestamp: v.timestamp ?? 0,
|
||||||
|
source: v.source ?? 'unknown',
|
||||||
|
riskLevel: v.severity === 'high' ? 'high' : v.severity === 'medium' ? 'medium' : 'low',
|
||||||
|
aiConfidence: 0.9,
|
||||||
|
suggestion: v.suggestion,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const softWarnings = (task.video_ai_result?.soft_warnings || []).map((w, idx) => ({
|
||||||
|
id: `s${idx + 1}`,
|
||||||
|
type: w.type,
|
||||||
|
timestamp: 0,
|
||||||
|
content: w.content,
|
||||||
|
riskLevel: 'medium',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number) => {
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
const secs = Math.floor(seconds % 60)
|
||||||
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
title: task.name,
|
||||||
|
creatorName: task.creator?.name || '未知达人',
|
||||||
|
projectName: task.project?.name || '未知项目',
|
||||||
|
submittedAt: task.video_uploaded_at || task.created_at,
|
||||||
|
duration: task.video_duration ?? 0,
|
||||||
|
aiScore: task.video_ai_score ?? 0,
|
||||||
|
status: task.stage,
|
||||||
|
file: {
|
||||||
|
id: `file-${task.id}`,
|
||||||
|
fileName: task.video_file_name || '未知文件',
|
||||||
|
fileSize: '',
|
||||||
|
fileType: 'video/mp4',
|
||||||
|
fileUrl: task.video_file_url || '',
|
||||||
|
uploadedAt: task.video_uploaded_at || task.created_at,
|
||||||
|
duration: task.video_duration ? formatDuration(task.video_duration) : '',
|
||||||
|
thumbnail: task.video_thumbnail_url || '',
|
||||||
|
} as FileInfo,
|
||||||
|
isAppeal: task.is_appeal,
|
||||||
|
appealReason: task.appeal_reason || '',
|
||||||
|
hardViolations: violations,
|
||||||
|
sentimentWarnings: softWarnings,
|
||||||
|
sellingPointsCovered: [] as Array<{ point: string; covered: boolean; timestamp: number }>,
|
||||||
|
aiSummary: task.video_ai_result?.summary || '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type VideoTaskViewModel = ReturnType<typeof mapTaskToViewModel>
|
||||||
|
|
||||||
function formatTimestamp(seconds: number): string {
|
function formatTimestamp(seconds: number): string {
|
||||||
const mins = Math.floor(seconds / 60)
|
const mins = Math.floor(seconds / 60)
|
||||||
const secs = Math.floor(seconds % 60)
|
const secs = Math.floor(seconds % 60)
|
||||||
@ -113,10 +174,41 @@ function RiskLevelTag({ level }: { level: string }) {
|
|||||||
return <SuccessTag>低风险</SuccessTag>
|
return <SuccessTag>低风险</SuccessTag>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LoadingSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 animate-pulse">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 bg-bg-elevated rounded-full" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="h-6 bg-bg-elevated rounded w-1/3" />
|
||||||
|
<div className="h-4 bg-bg-elevated rounded w-1/4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-16 bg-bg-elevated rounded-xl" />
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||||
|
<div className="lg:col-span-3 space-y-4">
|
||||||
|
<div className="h-32 bg-bg-elevated rounded-xl" />
|
||||||
|
<div className="aspect-video bg-bg-elevated rounded-xl" />
|
||||||
|
<div className="h-24 bg-bg-elevated rounded-xl" />
|
||||||
|
</div>
|
||||||
|
<div className="lg:col-span-2 space-y-4">
|
||||||
|
<div className="h-40 bg-bg-elevated rounded-xl" />
|
||||||
|
<div className="h-32 bg-bg-elevated rounded-xl" />
|
||||||
|
<div className="h-32 bg-bg-elevated rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function AgencyVideoReviewPage() {
|
export default function AgencyVideoReviewPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
|
const taskId = params.id as string
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(!USE_MOCK)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [isPlaying, setIsPlaying] = useState(false)
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
const [showApproveModal, setShowApproveModal] = useState(false)
|
const [showApproveModal, setShowApproveModal] = useState(false)
|
||||||
const [showRejectModal, setShowRejectModal] = useState(false)
|
const [showRejectModal, setShowRejectModal] = useState(false)
|
||||||
@ -127,33 +219,95 @@ export default function AgencyVideoReviewPage() {
|
|||||||
const [checkedViolations, setCheckedViolations] = useState<Record<string, boolean>>({})
|
const [checkedViolations, setCheckedViolations] = useState<Record<string, boolean>>({})
|
||||||
const [showFilePreview, setShowFilePreview] = useState(false)
|
const [showFilePreview, setShowFilePreview] = useState(false)
|
||||||
const [videoError, setVideoError] = useState(false)
|
const [videoError, setVideoError] = useState(false)
|
||||||
|
const [task, setTask] = useState<VideoTaskViewModel>(mockVideoTask as unknown as VideoTaskViewModel)
|
||||||
|
|
||||||
const task = mockVideoTask
|
const loadTask = useCallback(async () => {
|
||||||
|
if (USE_MOCK) return
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await api.getTask(taskId)
|
||||||
|
setTask(mapTaskToViewModel(data))
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : '加载任务详情失败'
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [taskId, toast])
|
||||||
|
|
||||||
const handleApprove = () => {
|
useEffect(() => {
|
||||||
|
loadTask()
|
||||||
|
}, [loadTask])
|
||||||
|
|
||||||
|
const handleApprove = async () => {
|
||||||
|
if (USE_MOCK) {
|
||||||
setShowApproveModal(false)
|
setShowApproveModal(false)
|
||||||
toast.success('已提交品牌方终审')
|
toast.success('已提交品牌方终审')
|
||||||
router.push('/agency/review')
|
router.push('/agency/review')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
await api.reviewVideo(taskId, { action: 'pass' })
|
||||||
|
setShowApproveModal(false)
|
||||||
|
toast.success('已提交品牌方终审')
|
||||||
|
router.push('/agency/review')
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : '操作失败'
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReject = () => {
|
const handleReject = async () => {
|
||||||
if (!rejectReason.trim()) {
|
if (!rejectReason.trim()) {
|
||||||
toast.error('请填写驳回原因')
|
toast.error('请填写驳回原因')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (USE_MOCK) {
|
||||||
setShowRejectModal(false)
|
setShowRejectModal(false)
|
||||||
toast.success('已驳回')
|
toast.success('已驳回')
|
||||||
router.push('/agency/review')
|
router.push('/agency/review')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
await api.reviewVideo(taskId, { action: 'reject', comment: rejectReason })
|
||||||
|
setShowRejectModal(false)
|
||||||
|
toast.success('已驳回')
|
||||||
|
router.push('/agency/review')
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : '操作失败'
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleForcePass = () => {
|
const handleForcePass = async () => {
|
||||||
if (!forcePassReason.trim()) {
|
if (!forcePassReason.trim()) {
|
||||||
toast.error('请填写强制通过原因')
|
toast.error('请填写强制通过原因')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (USE_MOCK) {
|
||||||
setShowForcePassModal(false)
|
setShowForcePassModal(false)
|
||||||
toast.success('已强制通过并提交品牌方终审')
|
toast.success('已强制通过并提交品牌方终审')
|
||||||
router.push('/agency/review')
|
router.push('/agency/review')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
await api.reviewVideo(taskId, { action: 'force_pass', comment: forcePassReason })
|
||||||
|
setShowForcePassModal(false)
|
||||||
|
toast.success('已强制通过并提交品牌方终审')
|
||||||
|
router.push('/agency/review')
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : '操作失败'
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算问题时间点用于进度条展示
|
// 计算问题时间点用于进度条展示
|
||||||
@ -163,6 +317,10 @@ export default function AgencyVideoReviewPage() {
|
|||||||
...task.sellingPointsCovered.filter(s => s.covered).map(s => ({ time: s.timestamp, type: 'selling' as const })),
|
...task.sellingPointsCovered.filter(s => s.covered).map(s => ({ time: s.timestamp, type: 'selling' as const })),
|
||||||
].sort((a, b) => a.time - b.time)
|
].sort((a, b) => a.time - b.time)
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <LoadingSkeleton />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 顶部导航 */}
|
{/* 顶部导航 */}
|
||||||
@ -298,7 +456,7 @@ export default function AgencyVideoReviewPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-text-secondary text-sm">
|
<p className="text-text-secondary text-sm">
|
||||||
视频整体合规,发现{task.hardViolations.length}处硬性问题和{task.sentimentWarnings.length}处舆情提示需人工确认
|
{task.aiSummary || `视频整体合规,发现${task.hardViolations.length}处硬性问题和${task.sentimentWarnings.length}处舆情提示需人工确认`}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -329,12 +487,15 @@ export default function AgencyVideoReviewPage() {
|
|||||||
<ErrorTag>{v.type}</ErrorTag>
|
<ErrorTag>{v.type}</ErrorTag>
|
||||||
<span className="text-xs text-text-tertiary">{formatTimestamp(v.timestamp)}</span>
|
<span className="text-xs text-text-tertiary">{formatTimestamp(v.timestamp)}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium text-text-primary">「{v.content}」</p>
|
<p className="text-sm font-medium text-text-primary">{v.content}</p>
|
||||||
<p className="text-xs text-accent-indigo mt-1">{v.suggestion}</p>
|
<p className="text-xs text-accent-indigo mt-1">{v.suggestion}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{task.hardViolations.length === 0 && (
|
||||||
|
<p className="text-sm text-text-tertiary text-center py-4">未发现硬性违规</p>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@ -355,7 +516,7 @@ export default function AgencyVideoReviewPage() {
|
|||||||
<span className="text-xs text-text-tertiary">{formatTimestamp(w.timestamp)}</span>
|
<span className="text-xs text-text-tertiary">{formatTimestamp(w.timestamp)}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-orange-400">{w.content}</p>
|
<p className="text-sm text-orange-400">{w.content}</p>
|
||||||
<p className="text-xs text-text-tertiary mt-1">⚠️ 软性风险仅作提示,不强制拦截</p>
|
<p className="text-xs text-text-tertiary mt-1">Soft risk warning only, not enforced</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -386,6 +547,9 @@ export default function AgencyVideoReviewPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{task.sellingPointsCovered.length === 0 && (
|
||||||
|
<p className="text-sm text-text-tertiary text-center py-4">暂无卖点数据</p>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@ -399,13 +563,16 @@ export default function AgencyVideoReviewPage() {
|
|||||||
已检查 {Object.values(checkedViolations).filter(Boolean).length}/{task.hardViolations.length} 个问题
|
已检查 {Object.values(checkedViolations).filter(Boolean).length}/{task.hardViolations.length} 个问题
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Button variant="danger" onClick={() => setShowRejectModal(true)}>
|
<Button variant="danger" onClick={() => setShowRejectModal(true)} disabled={submitting}>
|
||||||
|
{submitting ? <Loader2 size={16} className="animate-spin mr-1" /> : null}
|
||||||
驳回
|
驳回
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" onClick={() => setShowForcePassModal(true)}>
|
<Button variant="secondary" onClick={() => setShowForcePassModal(true)} disabled={submitting}>
|
||||||
|
{submitting ? <Loader2 size={16} className="animate-spin mr-1" /> : null}
|
||||||
强制通过
|
强制通过
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="success" onClick={() => setShowApproveModal(true)}>
|
<Button variant="success" onClick={() => setShowApproveModal(true)} disabled={submitting}>
|
||||||
|
{submitting ? <Loader2 size={16} className="animate-spin mr-1" /> : null}
|
||||||
通过
|
通过
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -430,7 +597,7 @@ export default function AgencyVideoReviewPage() {
|
|||||||
<div className="p-3 bg-bg-elevated rounded-lg">
|
<div className="p-3 bg-bg-elevated rounded-lg">
|
||||||
<p className="text-sm font-medium text-text-primary mb-2">已选问题 ({Object.values(checkedViolations).filter(Boolean).length})</p>
|
<p className="text-sm font-medium text-text-primary mb-2">已选问题 ({Object.values(checkedViolations).filter(Boolean).length})</p>
|
||||||
{task.hardViolations.filter(v => checkedViolations[v.id]).map(v => (
|
{task.hardViolations.filter(v => checkedViolations[v.id]).map(v => (
|
||||||
<div key={v.id} className="text-sm text-text-secondary">• {v.type}: {v.content}</div>
|
<div key={v.id} className="text-sm text-text-secondary">* {v.type}: {v.content}</div>
|
||||||
))}
|
))}
|
||||||
{Object.values(checkedViolations).filter(Boolean).length === 0 && (
|
{Object.values(checkedViolations).filter(Boolean).length === 0 && (
|
||||||
<div className="text-sm text-text-tertiary">未选择任何问题</div>
|
<div className="text-sm text-text-tertiary">未选择任何问题</div>
|
||||||
@ -446,8 +613,11 @@ export default function AgencyVideoReviewPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 justify-end">
|
<div className="flex gap-3 justify-end">
|
||||||
<Button variant="ghost" onClick={() => setShowRejectModal(false)}>取消</Button>
|
<Button variant="ghost" onClick={() => setShowRejectModal(false)} disabled={submitting}>取消</Button>
|
||||||
<Button variant="danger" onClick={handleReject}>确认驳回</Button>
|
<Button variant="danger" onClick={handleReject} disabled={submitting}>
|
||||||
|
{submitting ? <Loader2 size={16} className="animate-spin mr-1" /> : null}
|
||||||
|
确认驳回
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
@ -480,8 +650,11 @@ export default function AgencyVideoReviewPage() {
|
|||||||
<span className="text-sm text-text-secondary">保存为特例(需品牌方确认后生效)</span>
|
<span className="text-sm text-text-secondary">保存为特例(需品牌方确认后生效)</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-3 justify-end">
|
<div className="flex gap-3 justify-end">
|
||||||
<Button variant="ghost" onClick={() => setShowForcePassModal(false)}>取消</Button>
|
<Button variant="ghost" onClick={() => setShowForcePassModal(false)} disabled={submitting}>取消</Button>
|
||||||
<Button onClick={handleForcePass}>确认强制通过</Button>
|
<Button onClick={handleForcePass} disabled={submitting}>
|
||||||
|
{submitting ? <Loader2 size={16} className="animate-spin mr-1" /> : null}
|
||||||
|
确认强制通过
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@ -1,13 +1,37 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useRouter, useParams } from 'next/navigation'
|
import { useRouter, useParams } from 'next/navigation'
|
||||||
import { ArrowLeft, Download, Play } from 'lucide-react'
|
import { ArrowLeft, Download, Play, Loader2 } from 'lucide-react'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { SuccessTag, WarningTag, ErrorTag, PendingTag } from '@/components/ui/Tag'
|
import { SuccessTag, WarningTag, ErrorTag, PendingTag } from '@/components/ui/Tag'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
|
import type { TaskResponse, TaskStage } from '@/types/task'
|
||||||
|
|
||||||
// 模拟任务详情
|
// ==================== 本地视图模型 ====================
|
||||||
const mockTaskDetail = {
|
interface TaskViewModel {
|
||||||
|
id: string
|
||||||
|
videoTitle: string
|
||||||
|
creatorName: string
|
||||||
|
brandName: string
|
||||||
|
platform: string
|
||||||
|
status: string
|
||||||
|
aiScore: number | null
|
||||||
|
finalScore: number | null
|
||||||
|
aiSummary: string
|
||||||
|
submittedAt: string
|
||||||
|
reviewedAt: string
|
||||||
|
reviewerName: string
|
||||||
|
reviewNotes: string
|
||||||
|
videoUrl: string | null
|
||||||
|
softWarnings: Array<{ id: string; content: string; suggestion: string }>
|
||||||
|
timeline: Array<{ time: string; event: string; actor: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Mock 数据 ====================
|
||||||
|
const mockTaskDetail: TaskViewModel = {
|
||||||
id: 'task-004',
|
id: 'task-004',
|
||||||
videoTitle: '美食探店vlog',
|
videoTitle: '美食探店vlog',
|
||||||
creatorName: '吃货小胖',
|
creatorName: '吃货小胖',
|
||||||
@ -21,6 +45,7 @@ const mockTaskDetail = {
|
|||||||
reviewedAt: '2024-02-04 12:00',
|
reviewedAt: '2024-02-04 12:00',
|
||||||
reviewerName: '审核员A',
|
reviewerName: '审核员A',
|
||||||
reviewNotes: '内容积极正面,品牌露出合适,通过审核。',
|
reviewNotes: '内容积极正面,品牌露出合适,通过审核。',
|
||||||
|
videoUrl: null,
|
||||||
softWarnings: [
|
softWarnings: [
|
||||||
{ id: 'w1', content: '品牌提及次数适中', suggestion: '可考虑适当增加品牌提及' },
|
{ id: 'w1', content: '品牌提及次数适中', suggestion: '可考虑适当增加品牌提及' },
|
||||||
],
|
],
|
||||||
@ -32,6 +57,191 @@ const mockTaskDetail = {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 辅助函数 ====================
|
||||||
|
|
||||||
|
function mapStageToStatus(stage: TaskStage, task: TaskResponse): string {
|
||||||
|
if (stage === 'completed') return 'approved'
|
||||||
|
if (stage === 'rejected') return 'rejected'
|
||||||
|
|
||||||
|
// 检查视频审核状态
|
||||||
|
if (task.video_agency_status === 'passed' || task.video_brand_status === 'passed') return 'approved'
|
||||||
|
if (task.video_agency_status === 'rejected' || task.video_brand_status === 'rejected') return 'rejected'
|
||||||
|
|
||||||
|
// 检查脚本审核状态
|
||||||
|
if (task.script_agency_status === 'passed' || task.script_brand_status === 'passed') {
|
||||||
|
// 脚本通过但视频还在流程中
|
||||||
|
if (stage.startsWith('video_')) return 'pending_review'
|
||||||
|
return 'approved'
|
||||||
|
}
|
||||||
|
if (task.script_agency_status === 'rejected' || task.script_brand_status === 'rejected') return 'rejected'
|
||||||
|
|
||||||
|
return 'pending_review'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(isoStr: string | null | undefined): string {
|
||||||
|
if (!isoStr) return '-'
|
||||||
|
try {
|
||||||
|
const d = new Date(isoStr)
|
||||||
|
return d.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return isoStr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTimeline(task: TaskResponse): Array<{ time: string; event: string; actor: string }> {
|
||||||
|
const timeline: Array<{ time: string; event: string; actor: string }> = []
|
||||||
|
|
||||||
|
// 任务创建
|
||||||
|
timeline.push({
|
||||||
|
time: formatDateTime(task.created_at),
|
||||||
|
event: '任务创建',
|
||||||
|
actor: '系统',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 脚本上传
|
||||||
|
if (task.script_uploaded_at) {
|
||||||
|
timeline.push({
|
||||||
|
time: formatDateTime(task.script_uploaded_at),
|
||||||
|
event: '达人提交脚本',
|
||||||
|
actor: task.creator?.name || '达人',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 脚本 AI 审核
|
||||||
|
if (task.script_ai_score != null) {
|
||||||
|
timeline.push({
|
||||||
|
time: formatDateTime(task.script_uploaded_at),
|
||||||
|
event: `AI 脚本审核完成,得分 ${task.script_ai_score} 分`,
|
||||||
|
actor: '系统',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 脚本代理商审核
|
||||||
|
if (task.script_agency_status && task.script_agency_status !== 'pending') {
|
||||||
|
const statusText = task.script_agency_status === 'passed' ? '通过' :
|
||||||
|
task.script_agency_status === 'rejected' ? '驳回' : '强制通过'
|
||||||
|
timeline.push({
|
||||||
|
time: formatDateTime(task.updated_at),
|
||||||
|
event: `代理商脚本审核${statusText}`,
|
||||||
|
actor: task.agency?.name || '代理商',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 脚本品牌方审核
|
||||||
|
if (task.script_brand_status && task.script_brand_status !== 'pending') {
|
||||||
|
const statusText = task.script_brand_status === 'passed' ? '通过' :
|
||||||
|
task.script_brand_status === 'rejected' ? '驳回' : '强制通过'
|
||||||
|
timeline.push({
|
||||||
|
time: formatDateTime(task.updated_at),
|
||||||
|
event: `品牌方脚本审核${statusText}`,
|
||||||
|
actor: '品牌方',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 视频上传
|
||||||
|
if (task.video_uploaded_at) {
|
||||||
|
timeline.push({
|
||||||
|
time: formatDateTime(task.video_uploaded_at),
|
||||||
|
event: '达人提交视频',
|
||||||
|
actor: task.creator?.name || '达人',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 视频 AI 审核
|
||||||
|
if (task.video_ai_score != null) {
|
||||||
|
timeline.push({
|
||||||
|
time: formatDateTime(task.video_uploaded_at),
|
||||||
|
event: `AI 视频审核完成,得分 ${task.video_ai_score} 分`,
|
||||||
|
actor: '系统',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 视频代理商审核
|
||||||
|
if (task.video_agency_status && task.video_agency_status !== 'pending') {
|
||||||
|
const statusText = task.video_agency_status === 'passed' ? '通过' :
|
||||||
|
task.video_agency_status === 'rejected' ? '驳回' : '强制通过'
|
||||||
|
timeline.push({
|
||||||
|
time: formatDateTime(task.updated_at),
|
||||||
|
event: `代理商视频审核${statusText}`,
|
||||||
|
actor: task.agency?.name || '代理商',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 视频品牌方审核
|
||||||
|
if (task.video_brand_status && task.video_brand_status !== 'pending') {
|
||||||
|
const statusText = task.video_brand_status === 'passed' ? '通过' :
|
||||||
|
task.video_brand_status === 'rejected' ? '驳回' : '强制通过'
|
||||||
|
timeline.push({
|
||||||
|
time: formatDateTime(task.updated_at),
|
||||||
|
event: `品牌方视频审核${statusText}`,
|
||||||
|
actor: '品牌方',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 申诉
|
||||||
|
if (task.is_appeal && task.appeal_reason) {
|
||||||
|
timeline.push({
|
||||||
|
time: formatDateTime(task.updated_at),
|
||||||
|
event: `达人发起申诉:${task.appeal_reason}`,
|
||||||
|
actor: task.creator?.name || '达人',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return timeline
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapTaskResponseToViewModel(task: TaskResponse): TaskViewModel {
|
||||||
|
const status = mapStageToStatus(task.stage, task)
|
||||||
|
|
||||||
|
// 选择最新的 AI 评分(优先视频,其次脚本)
|
||||||
|
const aiScore = task.video_ai_score ?? task.script_ai_score ?? null
|
||||||
|
const aiResult = task.video_ai_result ?? task.script_ai_result ?? null
|
||||||
|
|
||||||
|
// 最终评分等于 AI 评分(人工审核不改分)
|
||||||
|
const finalScore = aiScore
|
||||||
|
|
||||||
|
// AI 摘要
|
||||||
|
const aiSummary = aiResult?.summary || '暂无 AI 分析摘要'
|
||||||
|
|
||||||
|
// 审核备注(优先视频代理商审核意见)
|
||||||
|
const reviewNotes = task.video_agency_comment || task.script_agency_comment ||
|
||||||
|
task.video_brand_comment || task.script_brand_comment || ''
|
||||||
|
|
||||||
|
// 软警告
|
||||||
|
const softWarnings = (aiResult?.soft_warnings || []).map((w, i) => ({
|
||||||
|
id: `w-${i}`,
|
||||||
|
content: w.content,
|
||||||
|
suggestion: w.suggestion,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 时间线
|
||||||
|
const timeline = buildTimeline(task)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
videoTitle: task.name,
|
||||||
|
creatorName: task.creator?.name || '未知达人',
|
||||||
|
brandName: task.project?.brand_name || '未知品牌',
|
||||||
|
platform: '小红书', // 后端暂无 platform 字段
|
||||||
|
status,
|
||||||
|
aiScore,
|
||||||
|
finalScore,
|
||||||
|
aiSummary,
|
||||||
|
submittedAt: formatDateTime(task.video_uploaded_at || task.script_uploaded_at || task.created_at),
|
||||||
|
reviewedAt: formatDateTime(task.updated_at),
|
||||||
|
reviewerName: task.agency?.name || '-',
|
||||||
|
reviewNotes,
|
||||||
|
videoUrl: task.video_file_url || null,
|
||||||
|
softWarnings,
|
||||||
|
timeline,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 组件 ====================
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: string }) {
|
function StatusBadge({ status }: { status: string }) {
|
||||||
if (status === 'approved') return <SuccessTag>已通过</SuccessTag>
|
if (status === 'approved') return <SuccessTag>已通过</SuccessTag>
|
||||||
if (status === 'rejected') return <ErrorTag>已驳回</ErrorTag>
|
if (status === 'rejected') return <ErrorTag>已驳回</ErrorTag>
|
||||||
@ -39,10 +249,64 @@ function StatusBadge({ status }: { status: string }) {
|
|||||||
return <PendingTag>处理中</PendingTag>
|
return <PendingTag>处理中</PendingTag>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TaskDetailSkeleton() {
|
||||||
|
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="flex-1">
|
||||||
|
<div className="h-6 w-48 bg-bg-elevated rounded" />
|
||||||
|
<div className="h-4 w-64 bg-bg-elevated rounded mt-2" />
|
||||||
|
</div>
|
||||||
|
<div className="h-10 w-28 bg-bg-elevated rounded-lg" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-2 space-y-4">
|
||||||
|
<div className="aspect-video bg-bg-elevated rounded-xl" />
|
||||||
|
<div className="h-48 bg-bg-elevated rounded-xl" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="h-64 bg-bg-elevated rounded-xl" />
|
||||||
|
<div className="h-48 bg-bg-elevated rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function TaskDetailPage() {
|
export default function TaskDetailPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const task = mockTaskDetail
|
const taskId = params.id as string
|
||||||
|
|
||||||
|
const [task, setTask] = useState<TaskViewModel>(mockTaskDetail)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
setTask(mockTaskDetail)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const taskData = await api.getTask(taskId)
|
||||||
|
setTask(mapTaskResponseToViewModel(taskData))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('加载任务详情失败:', err)
|
||||||
|
// 加载失败时保持 mock 数据作为 fallback
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [taskId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
}, [loadData])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <TaskDetailSkeleton />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@ -67,9 +331,17 @@ export default function TaskDetailPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="aspect-video bg-gray-900 rounded-t-lg flex items-center justify-center">
|
<div className="aspect-video bg-gray-900 rounded-t-lg flex items-center justify-center">
|
||||||
|
{task.videoUrl ? (
|
||||||
|
<video
|
||||||
|
src={task.videoUrl}
|
||||||
|
controls
|
||||||
|
className="w-full h-full rounded-t-lg object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<button type="button" className="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center hover:bg-white/30">
|
<button type="button" className="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center hover:bg-white/30">
|
||||||
<Play size={32} className="text-white ml-1" />
|
<Play size={32} className="text-white ml-1" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -80,14 +352,14 @@ export default function TaskDetailPage() {
|
|||||||
<div className="grid grid-cols-2 gap-6">
|
<div className="grid grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-gray-500">AI 评分</div>
|
<div className="text-sm text-gray-500">AI 评分</div>
|
||||||
<div className={`text-3xl font-bold ${task.aiScore >= 80 ? 'text-green-600' : 'text-yellow-600'}`}>
|
<div className={`text-3xl font-bold ${task.aiScore != null && task.aiScore >= 80 ? 'text-green-600' : 'text-yellow-600'}`}>
|
||||||
{task.aiScore}
|
{task.aiScore ?? '-'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-gray-500">最终评分</div>
|
<div className="text-sm text-gray-500">最终评分</div>
|
||||||
<div className={`text-3xl font-bold ${task.finalScore >= 80 ? 'text-green-600' : 'text-yellow-600'}`}>
|
<div className={`text-3xl font-bold ${task.finalScore != null && task.finalScore >= 80 ? 'text-green-600' : 'text-yellow-600'}`}>
|
||||||
{task.finalScore}
|
{task.finalScore ?? '-'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,144 +1,112 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
import { Card, CardContent } from '@/components/ui/Card'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Modal } from '@/components/ui/Modal'
|
import { Modal } from '@/components/ui/Modal'
|
||||||
import { SuccessTag, PendingTag, WarningTag } from '@/components/ui/Tag'
|
import { SuccessTag, PendingTag } from '@/components/ui/Tag'
|
||||||
import { useToast } from '@/components/ui/Toast'
|
import { useToast } from '@/components/ui/Toast'
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
Plus,
|
Plus,
|
||||||
Users,
|
Users,
|
||||||
TrendingUp,
|
|
||||||
TrendingDown,
|
|
||||||
Copy,
|
Copy,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Clock,
|
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
Building2,
|
Building2,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
MessageSquareText,
|
|
||||||
Trash2,
|
Trash2,
|
||||||
FolderPlus
|
FolderPlus,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
|
import type { AgencyDetail } from '@/types/organization'
|
||||||
|
import type { ProjectResponse } from '@/types/project'
|
||||||
|
|
||||||
// 代理商类型
|
// ==================== Mock 数据 ====================
|
||||||
interface Agency {
|
const mockAgencies: AgencyDetail[] = [
|
||||||
id: string
|
{ id: 'AG789012', name: '星耀传媒', contact_name: '张经理', force_pass_enabled: true },
|
||||||
agencyId: string // 代理商ID(AG开头)
|
{ id: 'AG456789', name: '创意无限', contact_name: '李总', force_pass_enabled: false },
|
||||||
name: string
|
{ id: 'AG123456', name: '美妆达人MCN', contact_name: '王经理', force_pass_enabled: false },
|
||||||
companyName: string
|
{ id: 'AG111111', name: '蓝海科技', force_pass_enabled: true },
|
||||||
email: string
|
]
|
||||||
status: 'active' | 'pending' | 'paused'
|
|
||||||
creatorCount: number
|
const mockProjects: ProjectResponse[] = [
|
||||||
projectCount: number
|
{ id: 'PJ000001', name: 'XX品牌618推广', brand_id: 'BR000001', status: 'active', agencies: [], task_count: 5, created_at: '2025-06-01', updated_at: '2025-06-01' },
|
||||||
passRate: number
|
{ id: 'PJ000002', name: '口红系列推广', brand_id: 'BR000001', status: 'active', agencies: [], task_count: 3, created_at: '2025-07-01', updated_at: '2025-07-01' },
|
||||||
trend: 'up' | 'down' | 'stable'
|
]
|
||||||
joinedAt: string
|
|
||||||
remark?: string
|
function StatusTag({ forcePass }: { forcePass: boolean }) {
|
||||||
|
if (forcePass) return <SuccessTag>可强制通过</SuccessTag>
|
||||||
|
return <PendingTag>标准权限</PendingTag>
|
||||||
}
|
}
|
||||||
|
|
||||||
// 模拟项目列表(用于分配代理商)
|
function AgencySkeleton() {
|
||||||
const mockProjects = [
|
return (
|
||||||
{ id: 'proj-001', name: 'XX品牌618推广' },
|
<div className="animate-pulse">
|
||||||
{ id: 'proj-002', name: '口红系列推广' },
|
<div className="h-20 bg-bg-elevated rounded-lg mb-2" />
|
||||||
{ id: 'proj-003', name: 'XX运动品牌' },
|
<div className="h-20 bg-bg-elevated rounded-lg mb-2" />
|
||||||
{ id: 'proj-004', name: '护肤品秋季活动' },
|
<div className="h-20 bg-bg-elevated rounded-lg" />
|
||||||
]
|
</div>
|
||||||
|
)
|
||||||
// 模拟代理商列表
|
|
||||||
const initialAgencies: Agency[] = [
|
|
||||||
{
|
|
||||||
id: 'a-001',
|
|
||||||
agencyId: 'AG789012',
|
|
||||||
name: '星耀传媒',
|
|
||||||
companyName: '上海星耀文化传媒有限公司',
|
|
||||||
email: 'contact@xingyao.com',
|
|
||||||
status: 'active',
|
|
||||||
creatorCount: 50,
|
|
||||||
projectCount: 8,
|
|
||||||
passRate: 92,
|
|
||||||
trend: 'up',
|
|
||||||
joinedAt: '2025-06-15',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'a-002',
|
|
||||||
agencyId: 'AG456789',
|
|
||||||
name: '创意无限',
|
|
||||||
companyName: '深圳创意无限广告有限公司',
|
|
||||||
email: 'hello@chuangyi.com',
|
|
||||||
status: 'active',
|
|
||||||
creatorCount: 35,
|
|
||||||
projectCount: 5,
|
|
||||||
passRate: 88,
|
|
||||||
trend: 'up',
|
|
||||||
joinedAt: '2025-08-20',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'a-003',
|
|
||||||
agencyId: 'AG123456',
|
|
||||||
name: '美妆达人MCN',
|
|
||||||
companyName: '杭州美妆达人网络科技有限公司',
|
|
||||||
email: 'biz@meizhuang.com',
|
|
||||||
status: 'active',
|
|
||||||
creatorCount: 28,
|
|
||||||
projectCount: 4,
|
|
||||||
passRate: 75,
|
|
||||||
trend: 'down',
|
|
||||||
joinedAt: '2025-10-10',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'a-004',
|
|
||||||
agencyId: 'AG111111',
|
|
||||||
name: '蓝海科技',
|
|
||||||
companyName: '北京蓝海数字科技有限公司',
|
|
||||||
email: 'info@lanhai.com',
|
|
||||||
status: 'pending',
|
|
||||||
creatorCount: 0,
|
|
||||||
projectCount: 0,
|
|
||||||
passRate: 0,
|
|
||||||
trend: 'stable',
|
|
||||||
joinedAt: '2026-02-01',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
function StatusTag({ status }: { status: string }) {
|
|
||||||
if (status === 'active') return <SuccessTag>已激活</SuccessTag>
|
|
||||||
if (status === 'pending') return <PendingTag>待接受</PendingTag>
|
|
||||||
return <WarningTag>已暂停</WarningTag>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AgenciesManagePage() {
|
export default function AgenciesManagePage() {
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [agencies, setAgencies] = useState<Agency[]>(initialAgencies)
|
const [agencies, setAgencies] = useState<AgencyDetail[]>([])
|
||||||
|
const [projects, setProjects] = useState<ProjectResponse[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
const [copiedId, setCopiedId] = useState<string | null>(null)
|
const [copiedId, setCopiedId] = useState<string | null>(null)
|
||||||
|
|
||||||
// 邀请代理商弹窗
|
// 邀请代理商弹窗
|
||||||
const [showInviteModal, setShowInviteModal] = useState(false)
|
const [showInviteModal, setShowInviteModal] = useState(false)
|
||||||
const [inviteAgencyId, setInviteAgencyId] = useState('')
|
const [inviteAgencyId, setInviteAgencyId] = useState('')
|
||||||
|
const [inviting, setInviting] = useState(false)
|
||||||
const [inviteResult, setInviteResult] = useState<{ success: boolean; message: string } | null>(null)
|
const [inviteResult, setInviteResult] = useState<{ success: boolean; message: string } | null>(null)
|
||||||
|
|
||||||
// 操作菜单状态
|
// 操作菜单状态
|
||||||
const [openMenuId, setOpenMenuId] = useState<string | null>(null)
|
const [openMenuId, setOpenMenuId] = useState<string | null>(null)
|
||||||
|
|
||||||
// 备注弹窗状态
|
|
||||||
const [remarkModal, setRemarkModal] = useState<{ open: boolean; agency: Agency | null }>({ open: false, agency: null })
|
|
||||||
const [remarkText, setRemarkText] = useState('')
|
|
||||||
|
|
||||||
// 删除确认弹窗状态
|
// 删除确认弹窗状态
|
||||||
const [deleteModal, setDeleteModal] = useState<{ open: boolean; agency: Agency | null }>({ open: false, agency: null })
|
const [deleteModal, setDeleteModal] = useState<{ open: boolean; agency: AgencyDetail | null }>({ open: false, agency: null })
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
|
||||||
// 分配项目弹窗状态
|
// 分配项目弹窗状态
|
||||||
const [assignModal, setAssignModal] = useState<{ open: boolean; agency: Agency | null }>({ open: false, agency: null })
|
const [assignModal, setAssignModal] = useState<{ open: boolean; agency: AgencyDetail | null }>({ open: false, agency: null })
|
||||||
const [selectedProjects, setSelectedProjects] = useState<string[]>([])
|
const [selectedProjects, setSelectedProjects] = useState<string[]>([])
|
||||||
|
const [assigning, setAssigning] = useState(false)
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
setAgencies(mockAgencies)
|
||||||
|
setProjects(mockProjects)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const [agencyRes, projectRes] = await Promise.all([
|
||||||
|
api.listBrandAgencies(),
|
||||||
|
api.listProjects(1, 100),
|
||||||
|
])
|
||||||
|
setAgencies(agencyRes.items)
|
||||||
|
setProjects(projectRes.items)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load data:', err)
|
||||||
|
toast.error('加载数据失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [toast])
|
||||||
|
|
||||||
|
useEffect(() => { loadData() }, [loadData])
|
||||||
|
|
||||||
const filteredAgencies = agencies.filter(agency =>
|
const filteredAgencies = agencies.filter(agency =>
|
||||||
agency.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
agency.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
agency.agencyId.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
agency.id.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
agency.companyName.toLowerCase().includes(searchQuery.toLowerCase())
|
(agency.contact_name || '').toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
)
|
)
|
||||||
|
|
||||||
// 复制代理商ID
|
// 复制代理商ID
|
||||||
@ -149,27 +117,36 @@ export default function AgenciesManagePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 邀请代理商
|
// 邀请代理商
|
||||||
const handleInvite = () => {
|
const handleInvite = async () => {
|
||||||
if (!inviteAgencyId.trim()) {
|
if (!inviteAgencyId.trim()) {
|
||||||
setInviteResult({ success: false, message: '请输入代理商ID' })
|
setInviteResult({ success: false, message: '请输入代理商ID' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查代理商ID格式
|
|
||||||
const idPattern = /^AG\d{6}$/
|
const idPattern = /^AG\d{6}$/
|
||||||
if (!idPattern.test(inviteAgencyId.toUpperCase())) {
|
if (!idPattern.test(inviteAgencyId.toUpperCase())) {
|
||||||
setInviteResult({ success: false, message: '代理商ID格式错误,应为AG+6位数字' })
|
setInviteResult({ success: false, message: '代理商ID格式错误,应为AG+6位数字' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否已邀请
|
if (agencies.some(a => a.id === inviteAgencyId.toUpperCase())) {
|
||||||
if (agencies.some(a => a.agencyId === inviteAgencyId.toUpperCase())) {
|
|
||||||
setInviteResult({ success: false, message: '该代理商已在您的列表中' })
|
setInviteResult({ success: false, message: '该代理商已在您的列表中' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 模拟发送邀请成功
|
setInviting(true)
|
||||||
|
try {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500))
|
||||||
|
} else {
|
||||||
|
await api.inviteAgency(inviteAgencyId.toUpperCase())
|
||||||
|
}
|
||||||
setInviteResult({ success: true, message: `已向代理商 ${inviteAgencyId.toUpperCase()} 发送邀请` })
|
setInviteResult({ success: true, message: `已向代理商 ${inviteAgencyId.toUpperCase()} 发送邀请` })
|
||||||
|
} catch (err) {
|
||||||
|
setInviteResult({ success: false, message: err instanceof Error ? err.message : '邀请失败' })
|
||||||
|
} finally {
|
||||||
|
setInviting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCloseInviteModal = () => {
|
const handleCloseInviteModal = () => {
|
||||||
@ -178,40 +155,42 @@ export default function AgenciesManagePage() {
|
|||||||
setInviteResult(null)
|
setInviteResult(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 打开备注弹窗
|
const handleConfirmInvite = async () => {
|
||||||
const handleOpenRemark = (agency: Agency) => {
|
if (inviteResult?.success) {
|
||||||
setRemarkText(agency.remark || '')
|
handleCloseInviteModal()
|
||||||
setRemarkModal({ open: true, agency })
|
await loadData()
|
||||||
setOpenMenuId(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存备注
|
|
||||||
const handleSaveRemark = () => {
|
|
||||||
if (remarkModal.agency) {
|
|
||||||
setAgencies(prev => prev.map(a =>
|
|
||||||
a.id === remarkModal.agency!.id ? { ...a, remark: remarkText } : a
|
|
||||||
))
|
|
||||||
}
|
|
||||||
setRemarkModal({ open: false, agency: null })
|
|
||||||
setRemarkText('')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 打开删除确认
|
// 打开删除确认
|
||||||
const handleOpenDelete = (agency: Agency) => {
|
const handleOpenDelete = (agency: AgencyDetail) => {
|
||||||
setDeleteModal({ open: true, agency })
|
setDeleteModal({ open: true, agency })
|
||||||
setOpenMenuId(null)
|
setOpenMenuId(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确认删除
|
// 确认删除
|
||||||
const handleConfirmDelete = () => {
|
const handleConfirmDelete = async () => {
|
||||||
if (deleteModal.agency) {
|
if (!deleteModal.agency) return
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500))
|
||||||
setAgencies(prev => prev.filter(a => a.id !== deleteModal.agency!.id))
|
setAgencies(prev => prev.filter(a => a.id !== deleteModal.agency!.id))
|
||||||
|
} else {
|
||||||
|
await api.removeAgency(deleteModal.agency.id)
|
||||||
|
await loadData()
|
||||||
}
|
}
|
||||||
|
toast.success('已移除代理商')
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('移除失败')
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
setDeleteModal({ open: false, agency: null })
|
setDeleteModal({ open: false, agency: null })
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 打开分配项目弹窗
|
// 打开分配项目弹窗
|
||||||
const handleOpenAssign = (agency: Agency) => {
|
const handleOpenAssign = (agency: AgencyDetail) => {
|
||||||
setSelectedProjects([])
|
setSelectedProjects([])
|
||||||
setAssignModal({ open: true, agency })
|
setAssignModal({ open: true, agency })
|
||||||
setOpenMenuId(null)
|
setOpenMenuId(null)
|
||||||
@ -220,24 +199,35 @@ export default function AgenciesManagePage() {
|
|||||||
// 切换项目选择
|
// 切换项目选择
|
||||||
const toggleProjectSelection = (projectId: string) => {
|
const toggleProjectSelection = (projectId: string) => {
|
||||||
setSelectedProjects(prev =>
|
setSelectedProjects(prev =>
|
||||||
prev.includes(projectId)
|
prev.includes(projectId) ? prev.filter(id => id !== projectId) : [...prev, projectId]
|
||||||
? prev.filter(id => id !== projectId)
|
|
||||||
: [...prev, projectId]
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确认分配项目
|
// 确认分配项目
|
||||||
const handleConfirmAssign = () => {
|
const handleConfirmAssign = async () => {
|
||||||
if (assignModal.agency && selectedProjects.length > 0) {
|
if (!assignModal.agency || selectedProjects.length === 0) return
|
||||||
const projectNames = mockProjects
|
setAssigning(true)
|
||||||
|
try {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500))
|
||||||
|
} else {
|
||||||
|
for (const projectId of selectedProjects) {
|
||||||
|
await api.assignAgencies(projectId, [assignModal.agency.id])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const projectNames = projects
|
||||||
.filter(p => selectedProjects.includes(p.id))
|
.filter(p => selectedProjects.includes(p.id))
|
||||||
.map(p => p.name)
|
.map(p => p.name)
|
||||||
.join('、')
|
.join('、')
|
||||||
toast.success(`已将代理商「${assignModal.agency.name}」分配到项目「${projectNames}」`)
|
toast.success(`已将代理商「${assignModal.agency.name}」分配到项目「${projectNames}」`)
|
||||||
}
|
} catch (err) {
|
||||||
|
toast.error('分配失败')
|
||||||
|
} finally {
|
||||||
|
setAssigning(false)
|
||||||
setAssignModal({ open: false, agency: null })
|
setAssignModal({ open: false, agency: null })
|
||||||
setSelectedProjects([])
|
setSelectedProjects([])
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 min-h-0">
|
<div className="space-y-6 min-h-0">
|
||||||
@ -254,7 +244,7 @@ export default function AgenciesManagePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 统计卡片 */}
|
{/* 统计卡片 */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-4">
|
<CardContent className="py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@ -272,8 +262,8 @@ export default function AgenciesManagePage() {
|
|||||||
<CardContent className="py-4">
|
<CardContent className="py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-text-secondary">已激活</p>
|
<p className="text-sm text-text-secondary">可强制通过</p>
|
||||||
<p className="text-2xl font-bold text-accent-green">{agencies.filter(a => a.status === 'active').length}</p>
|
<p className="text-2xl font-bold text-accent-green">{agencies.filter(a => a.force_pass_enabled).length}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-10 h-10 rounded-lg bg-accent-green/20 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-lg bg-accent-green/20 flex items-center justify-center">
|
||||||
<CheckCircle size={20} className="text-accent-green" />
|
<CheckCircle size={20} className="text-accent-green" />
|
||||||
@ -285,28 +275,11 @@ export default function AgenciesManagePage() {
|
|||||||
<CardContent className="py-4">
|
<CardContent className="py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-text-secondary">待接受</p>
|
<p className="text-sm text-text-secondary">关联项目</p>
|
||||||
<p className="text-2xl font-bold text-yellow-400">{agencies.filter(a => a.status === 'pending').length}</p>
|
<p className="text-2xl font-bold text-text-primary">{projects.length}</p>
|
||||||
</div>
|
|
||||||
<div className="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center">
|
|
||||||
<Clock size={20} className="text-yellow-400" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="py-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-text-secondary">平均通过率</p>
|
|
||||||
<p className="text-2xl font-bold text-text-primary">
|
|
||||||
{agencies.filter(a => a.status === 'active').length > 0
|
|
||||||
? Math.round(agencies.filter(a => a.status === 'active').reduce((sum, a) => sum + a.passRate, 0) / agencies.filter(a => a.status === 'active').length)
|
|
||||||
: 0}%
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
|
||||||
<TrendingUp size={20} className="text-purple-400" />
|
<Building2 size={20} className="text-purple-400" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -318,7 +291,7 @@ export default function AgenciesManagePage() {
|
|||||||
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-tertiary" />
|
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-tertiary" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="搜索代理商名称、ID或公司名..."
|
placeholder="搜索代理商名称、ID..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="w-full pl-10 pr-4 py-2.5 border border-border-subtle rounded-xl bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
|
className="w-full pl-10 pr-4 py-2.5 border border-border-subtle rounded-xl bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
|
||||||
@ -328,16 +301,16 @@ export default function AgenciesManagePage() {
|
|||||||
{/* 代理商列表 */}
|
{/* 代理商列表 */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-0 overflow-x-auto">
|
<CardContent className="p-0 overflow-x-auto">
|
||||||
<table className="w-full min-w-[900px]">
|
{loading ? (
|
||||||
|
<div className="p-6"><AgencySkeleton /></div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full min-w-[700px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border-subtle text-left text-sm text-text-secondary bg-bg-elevated">
|
<tr className="border-b border-border-subtle text-left text-sm text-text-secondary bg-bg-elevated">
|
||||||
<th className="px-6 py-4 font-medium">代理商</th>
|
<th className="px-6 py-4 font-medium">代理商</th>
|
||||||
<th className="px-6 py-4 font-medium">代理商ID</th>
|
<th className="px-6 py-4 font-medium">代理商ID</th>
|
||||||
<th className="px-6 py-4 font-medium">状态</th>
|
<th className="px-6 py-4 font-medium">联系人</th>
|
||||||
<th className="px-6 py-4 font-medium">达人数</th>
|
<th className="px-6 py-4 font-medium">权限</th>
|
||||||
<th className="px-6 py-4 font-medium">项目数</th>
|
|
||||||
<th className="px-6 py-4 font-medium">通过率</th>
|
|
||||||
<th className="px-6 py-4 font-medium">加入时间</th>
|
|
||||||
<th className="px-6 py-4 font-medium">操作</th>
|
<th className="px-6 py-4 font-medium">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -349,34 +322,21 @@ export default function AgenciesManagePage() {
|
|||||||
<div className="w-10 h-10 rounded-lg bg-accent-indigo/15 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-lg bg-accent-indigo/15 flex items-center justify-center">
|
||||||
<Building2 size={20} className="text-accent-indigo" />
|
<Building2 size={20} className="text-accent-indigo" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-medium text-text-primary">{agency.name}</span>
|
<span className="font-medium text-text-primary">{agency.name}</span>
|
||||||
{agency.remark && (
|
|
||||||
<span className="px-2 py-0.5 text-xs rounded bg-accent-amber/15 text-accent-amber" title={agency.remark}>
|
|
||||||
有备注
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-text-tertiary">{agency.companyName}</div>
|
|
||||||
{agency.remark && (
|
|
||||||
<p className="text-xs text-text-tertiary mt-0.5 line-clamp-1">{agency.remark}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<code className="px-2 py-1 rounded bg-bg-elevated text-sm font-mono text-accent-indigo">
|
<code className="px-2 py-1 rounded bg-bg-elevated text-sm font-mono text-accent-indigo">
|
||||||
{agency.agencyId}
|
{agency.id}
|
||||||
</code>
|
</code>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleCopyAgencyId(agency.agencyId)}
|
onClick={() => handleCopyAgencyId(agency.id)}
|
||||||
className="p-1 rounded hover:bg-bg-elevated transition-colors"
|
className="p-1 rounded hover:bg-bg-elevated transition-colors"
|
||||||
title="复制代理商ID"
|
title="复制代理商ID"
|
||||||
>
|
>
|
||||||
{copiedId === agency.agencyId ? (
|
{copiedId === agency.id ? (
|
||||||
<CheckCircle size={14} className="text-accent-green" />
|
<CheckCircle size={14} className="text-accent-green" />
|
||||||
) : (
|
) : (
|
||||||
<Copy size={14} className="text-text-tertiary" />
|
<Copy size={14} className="text-text-tertiary" />
|
||||||
@ -384,25 +344,12 @@ export default function AgenciesManagePage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4 text-text-secondary text-sm">
|
||||||
<StatusTag status={agency.status} />
|
{agency.contact_name || '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-text-primary">{agency.creatorCount}</td>
|
|
||||||
<td className="px-6 py-4 text-text-primary">{agency.projectCount}</td>
|
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
{agency.status === 'active' ? (
|
<StatusTag forcePass={agency.force_pass_enabled} />
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={`font-medium ${agency.passRate >= 90 ? 'text-accent-green' : agency.passRate >= 80 ? 'text-accent-indigo' : 'text-orange-400'}`}>
|
|
||||||
{agency.passRate}%
|
|
||||||
</span>
|
|
||||||
{agency.trend === 'up' && <TrendingUp size={14} className="text-accent-green" />}
|
|
||||||
{agency.trend === 'down' && <TrendingDown size={14} className="text-accent-coral" />}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-text-tertiary">-</span>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-sm text-text-tertiary">{agency.joinedAt}</td>
|
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Button
|
<Button
|
||||||
@ -412,17 +359,8 @@ export default function AgenciesManagePage() {
|
|||||||
>
|
>
|
||||||
<MoreVertical size={16} />
|
<MoreVertical size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
{/* 下拉菜单 */}
|
|
||||||
{openMenuId === agency.id && (
|
{openMenuId === agency.id && (
|
||||||
<div className="absolute right-0 top-full mt-1 w-40 bg-bg-card rounded-xl shadow-lg border border-border-subtle z-10 overflow-hidden">
|
<div className="absolute right-0 top-full mt-1 w-40 bg-bg-card rounded-xl shadow-lg border border-border-subtle z-10 overflow-hidden">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleOpenRemark(agency)}
|
|
||||||
className="w-full px-4 py-2.5 text-left text-sm text-text-primary hover:bg-bg-elevated flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<MessageSquareText size={14} className="text-text-secondary" />
|
|
||||||
{agency.remark ? '编辑备注' : '添加备注'}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleOpenAssign(agency)}
|
onClick={() => handleOpenAssign(agency)}
|
||||||
@ -447,8 +385,9 @@ export default function AgenciesManagePage() {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
{filteredAgencies.length === 0 && (
|
{!loading && filteredAgencies.length === 0 && (
|
||||||
<div className="text-center py-12 text-text-tertiary">
|
<div className="text-center py-12 text-text-tertiary">
|
||||||
<Building2 size={48} className="mx-auto mb-4 opacity-50" />
|
<Building2 size={48} className="mx-auto mb-4 opacity-50" />
|
||||||
<p>没有找到匹配的代理商</p>
|
<p>没有找到匹配的代理商</p>
|
||||||
@ -477,8 +416,8 @@ export default function AgenciesManagePage() {
|
|||||||
placeholder="例如: AG789012"
|
placeholder="例如: AG789012"
|
||||||
className="flex-1 px-4 py-2.5 border border-border-subtle rounded-xl bg-bg-elevated text-text-primary font-mono focus:outline-none focus:ring-2 focus:ring-accent-indigo"
|
className="flex-1 px-4 py-2.5 border border-border-subtle rounded-xl bg-bg-elevated text-text-primary font-mono focus:outline-none focus:ring-2 focus:ring-accent-indigo"
|
||||||
/>
|
/>
|
||||||
<Button variant="secondary" onClick={handleInvite}>
|
<Button variant="secondary" onClick={handleInvite} disabled={inviting}>
|
||||||
查找
|
{inviting ? <Loader2 size={16} className="animate-spin" /> : '查找'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-text-tertiary mt-2">代理商ID格式:AG + 6位数字</p>
|
<p className="text-xs text-text-tertiary mt-2">代理商ID格式:AG + 6位数字</p>
|
||||||
@ -503,44 +442,9 @@ export default function AgenciesManagePage() {
|
|||||||
<Button variant="ghost" onClick={handleCloseInviteModal}>
|
<Button variant="ghost" onClick={handleCloseInviteModal}>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button onClick={handleConfirmInvite} disabled={!inviteResult?.success}>
|
||||||
onClick={() => {
|
|
||||||
if (inviteResult?.success) {
|
|
||||||
handleCloseInviteModal()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!inviteResult?.success}
|
|
||||||
>
|
|
||||||
<UserPlus size={16} />
|
<UserPlus size={16} />
|
||||||
发送邀请
|
确认邀请
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* 备注弹窗 */}
|
|
||||||
<Modal
|
|
||||||
isOpen={remarkModal.open}
|
|
||||||
onClose={() => { setRemarkModal({ open: false, agency: null }); setRemarkText(''); }}
|
|
||||||
title={`${remarkModal.agency?.remark ? '编辑' : '添加'}备注 - ${remarkModal.agency?.name}`}
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-2">备注内容</label>
|
|
||||||
<textarea
|
|
||||||
value={remarkText}
|
|
||||||
onChange={(e) => setRemarkText(e.target.value)}
|
|
||||||
placeholder="输入备注信息,如代理商特点、合作注意事项等..."
|
|
||||||
className="w-full h-32 px-4 py-3 border border-border-subtle rounded-xl bg-bg-elevated text-text-primary placeholder-text-tertiary resize-none focus:outline-none focus:ring-2 focus:ring-accent-indigo"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3 justify-end">
|
|
||||||
<Button variant="ghost" onClick={() => { setRemarkModal({ open: false, agency: null }); setRemarkText(''); }}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSaveRemark}>
|
|
||||||
<CheckCircle size={16} />
|
|
||||||
保存
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -572,8 +476,9 @@ export default function AgenciesManagePage() {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="border-accent-coral text-accent-coral hover:bg-accent-coral/10"
|
className="border-accent-coral text-accent-coral hover:bg-accent-coral/10"
|
||||||
onClick={handleConfirmDelete}
|
onClick={handleConfirmDelete}
|
||||||
|
disabled={deleting}
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
{deleting ? <Loader2 size={16} className="animate-spin" /> : <Trash2 size={16} />}
|
||||||
确认移除
|
确认移除
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -593,7 +498,7 @@ export default function AgenciesManagePage() {
|
|||||||
<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="space-y-2 max-h-60 overflow-y-auto">
|
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||||
{mockProjects.map((project) => {
|
{projects.map((project) => {
|
||||||
const isSelected = selectedProjects.includes(project.id)
|
const isSelected = selectedProjects.includes(project.id)
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@ -626,8 +531,8 @@ export default function AgenciesManagePage() {
|
|||||||
<Button variant="ghost" onClick={() => { setAssignModal({ open: false, agency: null }); setSelectedProjects([]); }}>
|
<Button variant="ghost" onClick={() => { setAssignModal({ open: false, agency: null }); setSelectedProjects([]); }}>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleConfirmAssign} disabled={selectedProjects.length === 0}>
|
<Button onClick={handleConfirmAssign} disabled={selectedProjects.length === 0 || assigning}>
|
||||||
<FolderPlus size={16} />
|
{assigning ? <Loader2 size={16} className="animate-spin" /> : <FolderPlus size={16} />}
|
||||||
确认分配
|
确认分配
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,11 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||||
import { Button } from '@/components/ui/Button'
|
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 { useToast } from '@/components/ui/Toast'
|
||||||
import {
|
import {
|
||||||
Bot,
|
Bot,
|
||||||
@ -21,59 +18,62 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Clock
|
Clock
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
// AI 服务状态类型
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
type ServiceStatus = 'healthy' | 'degraded' | 'error' | 'unknown'
|
import type { AIProvider, AIConfigResponse, ConnectionTestResponse, ModelInfo } from '@/types/ai-config'
|
||||||
|
|
||||||
interface AIServiceHealth {
|
|
||||||
status: ServiceStatus
|
|
||||||
lastChecked: string | null
|
|
||||||
lastError: string | null
|
|
||||||
failedCount: number // 连续失败次数
|
|
||||||
queuedTasks: number // 队列中等待的任务数
|
|
||||||
}
|
|
||||||
|
|
||||||
// AI 提供商选项
|
// AI 提供商选项
|
||||||
const providerOptions = [
|
const providerOptions: { value: AIProvider | string; label: string }[] = [
|
||||||
{ value: 'oneapi', label: 'OneAPI 中转服务' },
|
{ value: 'oneapi', label: 'OneAPI 中转服务' },
|
||||||
{ value: 'anthropic', label: 'Anthropic Claude' },
|
{ value: 'anthropic', label: 'Anthropic Claude' },
|
||||||
{ value: 'openai', label: 'OpenAI' },
|
{ value: 'openai', label: 'OpenAI' },
|
||||||
{ value: 'deepseek', label: 'DeepSeek' },
|
{ value: 'deepseek', label: 'DeepSeek' },
|
||||||
{ value: 'custom', label: '自定义' },
|
{ value: 'qwen', label: '通义千问' },
|
||||||
|
{ value: 'doubao', label: '豆包' },
|
||||||
|
{ value: 'zhipu', label: '智谱' },
|
||||||
|
{ value: 'moonshot', label: 'Moonshot' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// 模拟可用模型列表
|
// Mock 可用模型列表
|
||||||
const availableModels = {
|
const mockModels: Record<string, ModelInfo[]> = {
|
||||||
llm: [
|
text: [
|
||||||
{ value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5', tags: ['推荐', '高性能'] },
|
{ id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5' },
|
||||||
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4', tags: ['性价比'] },
|
{ id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4' },
|
||||||
{ value: 'gpt-4o', label: 'GPT-4o', tags: ['文字', '视觉'] },
|
{ id: 'gpt-4o', name: 'GPT-4o' },
|
||||||
{ value: 'deepseek-chat', label: 'DeepSeek Chat', tags: ['高性价比'] },
|
{ id: 'deepseek-chat', name: 'DeepSeek Chat' },
|
||||||
],
|
],
|
||||||
vision: [
|
vision: [
|
||||||
{ value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5', tags: ['推荐'] },
|
{ id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5' },
|
||||||
{ value: 'gpt-4o', label: 'GPT-4o', tags: ['视觉'] },
|
{ id: 'gpt-4o', name: 'GPT-4o' },
|
||||||
{ value: 'doubao-seed-1.6-thinking-vision', label: '豆包 Vision', tags: ['中文优化'] },
|
|
||||||
],
|
],
|
||||||
asr: [
|
audio: [
|
||||||
{ value: 'whisper-large-v3', label: 'Whisper Large V3', tags: ['推荐'] },
|
{ id: 'whisper-large-v3', name: 'Whisper Large V3' },
|
||||||
{ value: 'whisper-medium', label: 'Whisper Medium', tags: ['快速'] },
|
{ id: 'whisper-medium', name: 'Whisper Medium' },
|
||||||
{ value: 'paraformer-zh', label: '达摩院 Paraformer', tags: ['中文优化'] },
|
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
type TestResult = {
|
type TestStatus = 'idle' | 'testing' | 'success' | 'failed'
|
||||||
llm: 'idle' | 'testing' | 'success' | 'failed'
|
|
||||||
vision: 'idle' | 'testing' | 'success' | 'failed'
|
function ConfigSkeleton() {
|
||||||
asr: 'idle' | 'testing' | 'success' | 'failed'
|
return (
|
||||||
|
<div className="space-y-6 animate-pulse">
|
||||||
|
<div className="h-32 bg-bg-elevated rounded-lg" />
|
||||||
|
<div className="h-48 bg-bg-elevated rounded-lg" />
|
||||||
|
<div className="h-32 bg-bg-elevated rounded-lg" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AIConfigPage() {
|
export default function AIConfigPage() {
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const [provider, setProvider] = useState('oneapi')
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const [provider, setProvider] = useState<string>('oneapi')
|
||||||
const [baseUrl, setBaseUrl] = useState('https://oneapi.intelligrow.cn')
|
const [baseUrl, setBaseUrl] = useState('https://oneapi.intelligrow.cn')
|
||||||
const [apiKey, setApiKey] = useState('')
|
const [apiKey, setApiKey] = useState('')
|
||||||
const [showApiKey, setShowApiKey] = useState(false)
|
const [showApiKey, setShowApiKey] = useState(false)
|
||||||
|
const [isConfigured, setIsConfigured] = useState(false)
|
||||||
|
|
||||||
const [llmModel, setLlmModel] = useState('claude-opus-4-5-20251101')
|
const [llmModel, setLlmModel] = useState('claude-opus-4-5-20251101')
|
||||||
const [visionModel, setVisionModel] = useState('claude-opus-4-5-20251101')
|
const [visionModel, setVisionModel] = useState('claude-opus-4-5-20251101')
|
||||||
@ -82,115 +82,146 @@ export default function AIConfigPage() {
|
|||||||
const [temperature, setTemperature] = useState(0.7)
|
const [temperature, setTemperature] = useState(0.7)
|
||||||
const [maxTokens, setMaxTokens] = useState(2000)
|
const [maxTokens, setMaxTokens] = useState(2000)
|
||||||
|
|
||||||
const [testResults, setTestResults] = useState<TestResult>({
|
const [availableModels, setAvailableModels] = useState<Record<string, ModelInfo[]>>(mockModels)
|
||||||
llm: 'idle',
|
|
||||||
vision: 'idle',
|
const [testResults, setTestResults] = useState<Record<string, { status: TestStatus; latency?: number; error?: string }>>({
|
||||||
asr: 'idle',
|
text: { status: 'idle' },
|
||||||
|
vision: { status: 'idle' },
|
||||||
|
audio: { status: 'idle' },
|
||||||
})
|
})
|
||||||
|
|
||||||
// AI 服务健康状态(模拟数据,实际从后端获取)
|
const loadConfig = useCallback(async () => {
|
||||||
const [serviceHealth, setServiceHealth] = useState<AIServiceHealth>({
|
if (USE_MOCK) {
|
||||||
status: 'healthy',
|
setLoading(false)
|
||||||
lastChecked: '2026-02-06 16:30:00',
|
return
|
||||||
lastError: null,
|
|
||||||
failedCount: 0,
|
|
||||||
queuedTasks: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 模拟检查服务状态
|
|
||||||
const checkServiceHealth = async () => {
|
|
||||||
// 实际应该调用后端 API
|
|
||||||
// const response = await fetch('/api/v1/ai-config/health')
|
|
||||||
// setServiceHealth(await response.json())
|
|
||||||
|
|
||||||
// 模拟:随机返回不同状态用于演示
|
|
||||||
const now = new Date().toLocaleString('zh-CN')
|
|
||||||
setServiceHealth({
|
|
||||||
status: 'healthy',
|
|
||||||
lastChecked: now,
|
|
||||||
lastError: null,
|
|
||||||
failedCount: 0,
|
|
||||||
queuedTasks: 0,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
const config = await api.getAIConfig()
|
||||||
|
setProvider(config.provider)
|
||||||
|
setBaseUrl(config.base_url)
|
||||||
|
setApiKey('') // API key is masked, don't fill it
|
||||||
|
setIsConfigured(config.is_configured)
|
||||||
|
setLlmModel(config.models.text)
|
||||||
|
setVisionModel(config.models.vision)
|
||||||
|
setAsrModel(config.models.audio)
|
||||||
|
setTemperature(config.parameters.temperature)
|
||||||
|
setMaxTokens(config.parameters.max_tokens)
|
||||||
|
if (config.available_models && Object.keys(config.available_models).length > 0) {
|
||||||
|
setAvailableModels(config.available_models)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load AI config:', err)
|
||||||
|
toast.error('加载 AI 配置失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [toast])
|
||||||
|
|
||||||
// 页面加载时检查服务状态
|
useEffect(() => { loadConfig() }, [loadConfig])
|
||||||
useEffect(() => {
|
|
||||||
checkServiceHealth()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleTestConnection = async () => {
|
const handleTestConnection = async () => {
|
||||||
// 模拟测试连接
|
setTestResults({
|
||||||
setTestResults({ llm: 'testing', vision: 'testing', asr: 'testing' })
|
text: { status: 'testing' },
|
||||||
|
vision: { status: 'testing' },
|
||||||
|
audio: { status: 'testing' },
|
||||||
|
})
|
||||||
|
|
||||||
// 模拟延迟
|
if (USE_MOCK) {
|
||||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||||
setTestResults(prev => ({ ...prev, llm: 'success' }))
|
setTestResults(prev => ({ ...prev, text: { status: 'success', latency: 320 } }))
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
setTestResults(prev => ({ ...prev, vision: 'success' }))
|
setTestResults(prev => ({ ...prev, vision: { status: 'success', latency: 450 } }))
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 800))
|
await new Promise(resolve => setTimeout(resolve, 800))
|
||||||
setTestResults(prev => ({ ...prev, asr: 'success' }))
|
setTestResults(prev => ({ ...prev, audio: { status: 'success', latency: 280 } }))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = () => {
|
try {
|
||||||
|
const result: ConnectionTestResponse = await api.testAIConnection({
|
||||||
|
provider: provider as AIProvider,
|
||||||
|
base_url: baseUrl,
|
||||||
|
api_key: apiKey || '***', // use existing key if not changed
|
||||||
|
models: { text: llmModel, vision: visionModel, audio: asrModel },
|
||||||
|
})
|
||||||
|
const newResults: Record<string, { status: TestStatus; latency?: number; error?: string }> = {}
|
||||||
|
for (const [key, r] of Object.entries(result.results)) {
|
||||||
|
newResults[key] = {
|
||||||
|
status: r.success ? 'success' : 'failed',
|
||||||
|
latency: r.latency_ms ?? undefined,
|
||||||
|
error: r.error ?? undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTestResults(prev => ({ ...prev, ...newResults }))
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(result.message)
|
||||||
|
} else {
|
||||||
|
toast.error(result.message)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('连接测试失败')
|
||||||
|
setTestResults({
|
||||||
|
text: { status: 'failed', error: '请求失败' },
|
||||||
|
vision: { status: 'failed', error: '请求失败' },
|
||||||
|
audio: { status: 'failed', error: '请求失败' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500))
|
||||||
|
} else {
|
||||||
|
await api.updateAIConfig({
|
||||||
|
provider: provider as AIProvider,
|
||||||
|
base_url: baseUrl,
|
||||||
|
api_key: apiKey || '***',
|
||||||
|
models: { text: llmModel, vision: visionModel, audio: asrModel },
|
||||||
|
parameters: { temperature, max_tokens: maxTokens },
|
||||||
|
})
|
||||||
|
}
|
||||||
toast.success('配置已保存')
|
toast.success('配置已保存')
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('保存失败')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTestStatusIcon = (status: string) => {
|
const getTestStatusIcon = (key: string) => {
|
||||||
switch (status) {
|
const result = testResults[key]
|
||||||
|
if (!result) return null
|
||||||
|
switch (result.status) {
|
||||||
case 'testing':
|
case 'testing':
|
||||||
return <Loader2 size={16} className="text-blue-500 animate-spin" />
|
return <Loader2 size={16} className="text-blue-500 animate-spin" />
|
||||||
case 'success':
|
case 'success':
|
||||||
return <CheckCircle size={16} className="text-green-500" />
|
return (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<CheckCircle size={16} className="text-green-500" />
|
||||||
|
{result.latency && <span className="text-xs text-text-tertiary">{result.latency}ms</span>}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
case 'failed':
|
case 'failed':
|
||||||
return <XCircle size={16} className="text-red-500" />
|
return (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<XCircle size={16} className="text-red-500" />
|
||||||
|
{result.error && <span className="text-xs text-accent-coral">{result.error}</span>}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
default:
|
default:
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取服务状态配置
|
if (loading) {
|
||||||
const getServiceStatusConfig = (status: ServiceStatus) => {
|
return (
|
||||||
switch (status) {
|
<div className="space-y-6 max-w-4xl">
|
||||||
case 'healthy':
|
<h1 className="text-2xl font-bold text-text-primary">AI 服务配置</h1>
|
||||||
return {
|
<ConfigSkeleton />
|
||||||
label: '服务正常',
|
</div>
|
||||||
color: 'text-accent-green',
|
)
|
||||||
bgColor: 'bg-accent-green/15',
|
|
||||||
borderColor: 'border-accent-green/30',
|
|
||||||
icon: CheckCircle,
|
|
||||||
}
|
}
|
||||||
case 'degraded':
|
|
||||||
return {
|
|
||||||
label: '服务降级',
|
|
||||||
color: 'text-accent-amber',
|
|
||||||
bgColor: 'bg-accent-amber/15',
|
|
||||||
borderColor: 'border-accent-amber/30',
|
|
||||||
icon: AlertTriangle,
|
|
||||||
}
|
|
||||||
case 'error':
|
|
||||||
return {
|
|
||||||
label: '服务异常',
|
|
||||||
color: 'text-accent-coral',
|
|
||||||
bgColor: 'bg-accent-coral/15',
|
|
||||||
borderColor: 'border-accent-coral/30',
|
|
||||||
icon: XCircle,
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
label: '状态未知',
|
|
||||||
color: 'text-text-tertiary',
|
|
||||||
bgColor: 'bg-bg-elevated',
|
|
||||||
borderColor: 'border-border-subtle',
|
|
||||||
icon: Info,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusConfig = getServiceStatusConfig(serviceHealth.status)
|
|
||||||
const StatusIcon = statusConfig.icon
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-4xl">
|
<div className="space-y-6 max-w-4xl">
|
||||||
@ -199,58 +230,13 @@ export default function AIConfigPage() {
|
|||||||
<h1 className="text-2xl font-bold text-text-primary">AI 服务配置</h1>
|
<h1 className="text-2xl font-bold text-text-primary">AI 服务配置</h1>
|
||||||
<p className="text-sm text-text-secondary mt-1">配置 AI 服务提供商和模型参数</p>
|
<p className="text-sm text-text-secondary mt-1">配置 AI 服务提供商和模型参数</p>
|
||||||
</div>
|
</div>
|
||||||
{/* 服务状态标签 */}
|
{isConfigured && (
|
||||||
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-lg ${statusConfig.bgColor} border ${statusConfig.borderColor}`}>
|
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-accent-green/15 border border-accent-green/30">
|
||||||
<StatusIcon size={16} className={statusConfig.color} />
|
<CheckCircle size={16} className="text-accent-green" />
|
||||||
<span className={`text-sm font-medium ${statusConfig.color}`}>{statusConfig.label}</span>
|
<span className="text-sm font-medium text-accent-green">已配置</span>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 服务异常警告 */}
|
|
||||||
{(serviceHealth.status === 'error' || serviceHealth.status === 'degraded') && (
|
|
||||||
<div className={`p-4 rounded-lg border ${serviceHealth.status === 'error' ? 'bg-accent-coral/10 border-accent-coral/30' : 'bg-accent-amber/10 border-accent-amber/30'}`}>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<AlertTriangle size={20} className={serviceHealth.status === 'error' ? 'text-accent-coral' : 'text-accent-amber'} />
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className={`font-medium ${serviceHealth.status === 'error' ? 'text-accent-coral' : 'text-accent-amber'}`}>
|
|
||||||
{serviceHealth.status === 'error' ? 'AI 服务异常' : 'AI 服务降级'}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-text-secondary mt-1">
|
|
||||||
{serviceHealth.lastError || '部分 AI 功能可能不可用,系统已自动将任务加入重试队列。'}
|
|
||||||
</p>
|
|
||||||
{serviceHealth.queuedTasks > 0 && (
|
|
||||||
<p className="text-sm text-text-tertiary mt-1">
|
|
||||||
当前有 <span className="font-medium text-text-primary">{serviceHealth.queuedTasks}</span> 个任务在队列中等待重试
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{serviceHealth.failedCount > 0 && (
|
|
||||||
<p className="text-sm text-text-tertiary mt-1">
|
|
||||||
连续失败 <span className="font-medium text-text-primary">{serviceHealth.failedCount}</span> 次
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button variant="secondary" size="sm" onClick={checkServiceHealth}>
|
|
||||||
<RefreshCw size={14} />
|
|
||||||
刷新状态
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 最后检查时间 */}
|
|
||||||
{serviceHealth.lastChecked && (
|
|
||||||
<div className="flex items-center gap-2 text-xs text-text-tertiary">
|
|
||||||
<Clock size={12} />
|
|
||||||
<span>最后检查时间: {serviceHealth.lastChecked}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={checkServiceHealth}
|
|
||||||
className="text-accent-indigo hover:underline"
|
|
||||||
>
|
|
||||||
立即检查
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 配置继承提示 */}
|
{/* 配置继承提示 */}
|
||||||
<div className="p-4 bg-accent-indigo/10 rounded-lg border border-accent-indigo/30">
|
<div className="p-4 bg-accent-indigo/10 rounded-lg border border-accent-indigo/30">
|
||||||
@ -286,7 +272,7 @@ export default function AIConfigPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<p className="text-xs text-text-tertiary mt-1">
|
<p className="text-xs text-text-tertiary mt-1">
|
||||||
支持 OneAPI、Anthropic Claude、OpenAI、DeepSeek 等提供商
|
推荐使用 OneAPI 等中转服务商,方便切换不同 AI 模型
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -306,17 +292,15 @@ export default function AIConfigPage() {
|
|||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<Bot size={16} className="text-accent-indigo" />
|
<Bot size={16} className="text-accent-indigo" />
|
||||||
<span className="font-medium text-text-primary">文字处理模型 (LLM)</span>
|
<span className="font-medium text-text-primary">文字处理模型 (LLM)</span>
|
||||||
{getTestStatusIcon(testResults.llm)}
|
{getTestStatusIcon('text')}
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
className="w-full px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
|
className="w-full px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
|
||||||
value={llmModel}
|
value={llmModel}
|
||||||
onChange={(e) => setLlmModel(e.target.value)}
|
onChange={(e) => setLlmModel(e.target.value)}
|
||||||
>
|
>
|
||||||
{availableModels.llm.map(model => (
|
{(availableModels.text || []).map(model => (
|
||||||
<option key={model.value} value={model.value}>
|
<option key={model.id} value={model.id}>{model.name}</option>
|
||||||
{model.label} [{model.tags.join(', ')}]
|
|
||||||
</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<p className="text-xs text-text-tertiary mt-2">用于 Brief 解析、语义分析、报告生成</p>
|
<p className="text-xs text-text-tertiary mt-2">用于 Brief 解析、语义分析、报告生成</p>
|
||||||
@ -327,20 +311,18 @@ export default function AIConfigPage() {
|
|||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<Eye size={16} className="text-accent-green" />
|
<Eye size={16} className="text-accent-green" />
|
||||||
<span className="font-medium text-text-primary">视频分析模型 (Vision)</span>
|
<span className="font-medium text-text-primary">视频分析模型 (Vision)</span>
|
||||||
{getTestStatusIcon(testResults.vision)}
|
{getTestStatusIcon('vision')}
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
className="w-full px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
|
className="w-full px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
|
||||||
value={visionModel}
|
value={visionModel}
|
||||||
onChange={(e) => setVisionModel(e.target.value)}
|
onChange={(e) => setVisionModel(e.target.value)}
|
||||||
>
|
>
|
||||||
{availableModels.vision.map(model => (
|
{(availableModels.vision || []).map(model => (
|
||||||
<option key={model.value} value={model.value}>
|
<option key={model.id} value={model.id}>{model.name}</option>
|
||||||
{model.label} [{model.tags.join(', ')}]
|
|
||||||
</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<p className="text-xs text-text-tertiary mt-2">用于画面语义分析、场景/风险识别(Logo 检测由系统内置 CV 完成)</p>
|
<p className="text-xs text-text-tertiary mt-2">用于画面语义分析、场景/风险识别</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 音频解析模型 */}
|
{/* 音频解析模型 */}
|
||||||
@ -348,17 +330,15 @@ export default function AIConfigPage() {
|
|||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<Mic size={16} className="text-orange-400" />
|
<Mic size={16} className="text-orange-400" />
|
||||||
<span className="font-medium text-text-primary">音频解析模型 (ASR)</span>
|
<span className="font-medium text-text-primary">音频解析模型 (ASR)</span>
|
||||||
{getTestStatusIcon(testResults.asr)}
|
{getTestStatusIcon('audio')}
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
className="w-full px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
|
className="w-full px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
|
||||||
value={asrModel}
|
value={asrModel}
|
||||||
onChange={(e) => setAsrModel(e.target.value)}
|
onChange={(e) => setAsrModel(e.target.value)}
|
||||||
>
|
>
|
||||||
{availableModels.asr.map(model => (
|
{(availableModels.audio || []).map(model => (
|
||||||
<option key={model.value} value={model.value}>
|
<option key={model.id} value={model.id}>{model.name}</option>
|
||||||
{model.label} [{model.tags.join(', ')}]
|
|
||||||
</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<p className="text-xs text-text-tertiary mt-2">用于语音转文字、口播内容提取</p>
|
<p className="text-xs text-text-tertiary mt-2">用于语音转文字、口播内容提取</p>
|
||||||
@ -390,7 +370,7 @@ export default function AIConfigPage() {
|
|||||||
className="flex-1 px-3 py-2 border border-border-subtle rounded-lg bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
|
className="flex-1 px-3 py-2 border border-border-subtle rounded-lg bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
|
||||||
value={apiKey}
|
value={apiKey}
|
||||||
onChange={(e) => setApiKey(e.target.value)}
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
placeholder="sk-..."
|
placeholder={isConfigured ? '留空使用已保存的密钥' : 'sk-...'}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@ -464,8 +444,8 @@ export default function AIConfigPage() {
|
|||||||
<Button variant="secondary" onClick={handleTestConnection}>
|
<Button variant="secondary" onClick={handleTestConnection}>
|
||||||
测试连接
|
测试连接
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave}>
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
保存配置
|
{saving ? <><Loader2 size={16} className="animate-spin" /> 保存中...</> : '保存配置'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,137 +1,159 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { Plus, FileText, Upload, Trash2, Edit, Check, Search, X, Eye } from 'lucide-react'
|
import { Plus, FileText, Trash2, Edit, Search, Eye, Loader2 } from 'lucide-react'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
import { Card, CardContent } from '@/components/ui/Card'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Input } from '@/components/ui/Input'
|
|
||||||
import { Modal } from '@/components/ui/Modal'
|
import { Modal } from '@/components/ui/Modal'
|
||||||
import { SuccessTag, PendingTag } from '@/components/ui/Tag'
|
import { SuccessTag, PendingTag } from '@/components/ui/Tag'
|
||||||
|
import { useToast } from '@/components/ui/Toast'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
|
import type { ProjectResponse } from '@/types/project'
|
||||||
|
import type { BriefResponse } from '@/types/brief'
|
||||||
|
|
||||||
// 平台选项
|
// Brief + Project 联合视图
|
||||||
const platformOptions = [
|
interface BriefItem {
|
||||||
{ id: 'douyin', name: '抖音', icon: '🎵', color: 'bg-[#1a1a1a]' },
|
projectId: string
|
||||||
{ id: 'xiaohongshu', name: '小红书', icon: '📕', color: 'bg-[#fe2c55]' },
|
projectName: string
|
||||||
{ id: 'bilibili', name: 'B站', icon: '📺', color: 'bg-[#00a1d6]' },
|
projectStatus: string
|
||||||
{ id: 'kuaishou', name: '快手', icon: '⚡', color: 'bg-[#ff4906]' },
|
brief: BriefResponse | null
|
||||||
{ id: 'weibo', name: '微博', icon: '🔴', color: 'bg-[#e6162d]' },
|
updatedAt: string
|
||||||
{ id: 'wechat', name: '微信视频号', icon: '💬', color: 'bg-[#07c160]' },
|
}
|
||||||
]
|
|
||||||
|
|
||||||
// 模拟 Brief 列表
|
// ==================== Mock 数据 ====================
|
||||||
const mockBriefs = [
|
const mockBriefItems: BriefItem[] = [
|
||||||
{
|
{
|
||||||
id: 'brief-001',
|
projectId: 'PJ000001',
|
||||||
name: '2024 夏日护肤活动',
|
projectName: '2024 夏日护肤活动',
|
||||||
description: '夏日护肤系列产品推广规范',
|
projectStatus: 'active',
|
||||||
status: 'active',
|
brief: {
|
||||||
platforms: ['douyin', 'xiaohongshu'],
|
id: 'BF000001',
|
||||||
rulesCount: 12,
|
project_id: 'PJ000001',
|
||||||
creatorsCount: 45,
|
brand_tone: '清新自然',
|
||||||
createdAt: '2024-01-15',
|
selling_points: [{ content: 'SPF50+ PA++++', required: true }, { content: '轻薄不油腻', required: false }],
|
||||||
|
blacklist_words: [{ word: '最好', reason: '极限词' }],
|
||||||
|
competitors: ['竞品A'],
|
||||||
|
min_duration: 30,
|
||||||
|
max_duration: 180,
|
||||||
|
other_requirements: '需在开头3秒内展示产品',
|
||||||
|
attachments: [],
|
||||||
|
created_at: '2024-01-15',
|
||||||
|
updated_at: '2024-02-01',
|
||||||
|
},
|
||||||
updatedAt: '2024-02-01',
|
updatedAt: '2024-02-01',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'brief-002',
|
projectId: 'PJ000002',
|
||||||
name: '新品口红上市',
|
projectName: '新品口红上市',
|
||||||
description: '春季新品口红营销 Brief',
|
projectStatus: 'active',
|
||||||
status: 'active',
|
brief: {
|
||||||
platforms: ['xiaohongshu', 'bilibili'],
|
id: 'BF000002',
|
||||||
rulesCount: 8,
|
project_id: 'PJ000002',
|
||||||
creatorsCount: 32,
|
brand_tone: '时尚摩登',
|
||||||
createdAt: '2024-02-01',
|
selling_points: [{ content: '持久不脱色', required: true }],
|
||||||
|
blacklist_words: [],
|
||||||
|
competitors: [],
|
||||||
|
min_duration: 15,
|
||||||
|
max_duration: 120,
|
||||||
|
other_requirements: '',
|
||||||
|
attachments: [],
|
||||||
|
created_at: '2024-02-01',
|
||||||
|
updated_at: '2024-02-03',
|
||||||
|
},
|
||||||
updatedAt: '2024-02-03',
|
updatedAt: '2024-02-03',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'brief-003',
|
projectId: 'PJ000003',
|
||||||
name: '年货节活动',
|
projectName: '年货节活动',
|
||||||
description: '春节年货促销活动规范',
|
projectStatus: 'completed',
|
||||||
status: 'archived',
|
brief: null,
|
||||||
platforms: ['douyin', 'kuaishou'],
|
|
||||||
rulesCount: 15,
|
|
||||||
creatorsCount: 78,
|
|
||||||
createdAt: '2024-01-01',
|
|
||||||
updatedAt: '2024-01-20',
|
updatedAt: '2024-01-20',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function BriefsPage() {
|
function BriefSkeleton() {
|
||||||
const [briefs, setBriefs] = useState(mockBriefs)
|
return (
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 animate-pulse">
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
{[1, 2, 3].map(i => (
|
||||||
|
<div key={i} className="h-64 bg-bg-elevated rounded-xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 新建 Brief 表单
|
export default function BriefsPage() {
|
||||||
const [newBriefName, setNewBriefName] = useState('')
|
const toast = useToast()
|
||||||
const [newBriefDesc, setNewBriefDesc] = useState('')
|
const [briefItems, setBriefItems] = useState<BriefItem[]>([])
|
||||||
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([])
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
|
||||||
// 查看详情
|
// 查看详情
|
||||||
const [showDetailModal, setShowDetailModal] = useState(false)
|
const [showDetailModal, setShowDetailModal] = useState(false)
|
||||||
const [selectedBrief, setSelectedBrief] = useState<typeof mockBriefs[0] | null>(null)
|
const [selectedItem, setSelectedItem] = useState<BriefItem | null>(null)
|
||||||
|
|
||||||
const filteredBriefs = briefs.filter((brief) =>
|
const loadData = useCallback(async () => {
|
||||||
brief.name.toLowerCase().includes(searchQuery.toLowerCase())
|
if (USE_MOCK) {
|
||||||
|
setBriefItems(mockBriefItems)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const projectRes = await api.listProjects(1, 100)
|
||||||
|
const items: BriefItem[] = []
|
||||||
|
|
||||||
|
// 并行获取每个项目的 Brief
|
||||||
|
const briefPromises = projectRes.items.map(async (project: ProjectResponse) => {
|
||||||
|
try {
|
||||||
|
const brief = await api.getBrief(project.id)
|
||||||
|
return {
|
||||||
|
projectId: project.id,
|
||||||
|
projectName: project.name,
|
||||||
|
projectStatus: project.status,
|
||||||
|
brief,
|
||||||
|
updatedAt: brief.updated_at || project.updated_at,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Brief 不存在返回 null
|
||||||
|
return {
|
||||||
|
projectId: project.id,
|
||||||
|
projectName: project.name,
|
||||||
|
projectStatus: project.status,
|
||||||
|
brief: null,
|
||||||
|
updatedAt: project.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const results = await Promise.all(briefPromises)
|
||||||
|
setBriefItems(results)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load briefs:', err)
|
||||||
|
toast.error('加载 Brief 列表失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [toast])
|
||||||
|
|
||||||
|
useEffect(() => { loadData() }, [loadData])
|
||||||
|
|
||||||
|
const filteredItems = briefItems.filter((item) =>
|
||||||
|
item.projectName.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
)
|
)
|
||||||
|
|
||||||
// 切换平台选择
|
|
||||||
const togglePlatform = (platformId: string) => {
|
|
||||||
setSelectedPlatforms(prev =>
|
|
||||||
prev.includes(platformId)
|
|
||||||
? prev.filter(id => id !== platformId)
|
|
||||||
: [...prev, platformId]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取平台信息
|
|
||||||
const getPlatformInfo = (platformId: string) => {
|
|
||||||
return platformOptions.find(p => p.id === platformId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建 Brief
|
|
||||||
const handleCreateBrief = () => {
|
|
||||||
if (!newBriefName.trim() || selectedPlatforms.length === 0) return
|
|
||||||
|
|
||||||
const newBrief = {
|
|
||||||
id: `brief-${Date.now()}`,
|
|
||||||
name: newBriefName,
|
|
||||||
description: newBriefDesc,
|
|
||||||
status: 'active' as const,
|
|
||||||
platforms: selectedPlatforms,
|
|
||||||
rulesCount: 0,
|
|
||||||
creatorsCount: 0,
|
|
||||||
createdAt: new Date().toISOString().split('T')[0],
|
|
||||||
updatedAt: new Date().toISOString().split('T')[0],
|
|
||||||
}
|
|
||||||
|
|
||||||
setBriefs([newBrief, ...briefs])
|
|
||||||
setShowCreateModal(false)
|
|
||||||
setNewBriefName('')
|
|
||||||
setNewBriefDesc('')
|
|
||||||
setSelectedPlatforms([])
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查看 Brief 详情
|
// 查看 Brief 详情
|
||||||
const viewBriefDetail = (brief: typeof mockBriefs[0]) => {
|
const viewBriefDetail = (item: BriefItem) => {
|
||||||
setSelectedBrief(brief)
|
setSelectedItem(item)
|
||||||
setShowDetailModal(true)
|
setShowDetailModal(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除 Brief
|
|
||||||
const handleDeleteBrief = (id: string) => {
|
|
||||||
setBriefs(briefs.filter(b => b.id !== id))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-text-primary">Brief 管理</h1>
|
<h1 className="text-2xl font-bold text-text-primary">Brief 管理</h1>
|
||||||
<p className="text-sm text-text-secondary mt-1">创建和管理营销 Brief,配置不同平台的审核规则</p>
|
<p className="text-sm text-text-secondary mt-1">查看各项目的 Brief 配置情况,在项目设置中编辑 Brief</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setShowCreateModal(true)}>
|
|
||||||
<Plus size={16} />
|
|
||||||
新建 Brief
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 搜索 */}
|
{/* 搜索 */}
|
||||||
@ -139,7 +161,7 @@ export default function BriefsPage() {
|
|||||||
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-tertiary" />
|
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-tertiary" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="搜索 Brief..."
|
placeholder="搜索项目名称..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="w-full pl-10 pr-4 py-2.5 border border-border-subtle rounded-xl bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
|
className="w-full pl-10 pr-4 py-2.5 border border-border-subtle rounded-xl bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
|
||||||
@ -147,270 +169,176 @@ export default function BriefsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Brief 列表 */}
|
{/* Brief 列表 */}
|
||||||
|
{loading ? (
|
||||||
|
<BriefSkeleton />
|
||||||
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{filteredBriefs.map((brief) => (
|
{filteredItems.map((item) => (
|
||||||
<Card key={brief.id} className="hover:shadow-md transition-shadow border border-border-subtle">
|
<Card key={item.projectId} className="hover:shadow-md transition-shadow border border-border-subtle">
|
||||||
<CardContent className="p-5">
|
<CardContent className="p-5">
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<div className="p-2 bg-accent-indigo/15 rounded-lg">
|
<div className="p-2 bg-accent-indigo/15 rounded-lg">
|
||||||
<FileText size={24} className="text-accent-indigo" />
|
<FileText size={24} className="text-accent-indigo" />
|
||||||
</div>
|
</div>
|
||||||
{brief.status === 'active' ? (
|
{item.brief ? (
|
||||||
<SuccessTag>使用中</SuccessTag>
|
<SuccessTag>已配置</SuccessTag>
|
||||||
) : (
|
) : (
|
||||||
<PendingTag>已归档</PendingTag>
|
<PendingTag>未配置</PendingTag>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="font-semibold text-text-primary mb-1">{brief.name}</h3>
|
<h3 className="font-semibold text-text-primary mb-1">{item.projectName}</h3>
|
||||||
<p className="text-sm text-text-tertiary mb-3">{brief.description}</p>
|
<p className="text-sm text-text-tertiary mb-3">
|
||||||
|
{item.brief ? (
|
||||||
{/* 平台标签 */}
|
<>
|
||||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
{item.brief.brand_tone && `调性: ${item.brief.brand_tone}`}
|
||||||
{brief.platforms.map(platformId => {
|
{(item.brief.selling_points?.length ?? 0) > 0 && ` · ${item.brief.selling_points!.length} 个卖点`}
|
||||||
const platform = getPlatformInfo(platformId)
|
</>
|
||||||
return platform ? (
|
) : (
|
||||||
<span
|
'该项目尚未配置 Brief'
|
||||||
key={platformId}
|
)}
|
||||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-bg-elevated text-xs text-text-secondary"
|
</p>
|
||||||
>
|
|
||||||
<span>{platform.icon}</span>
|
|
||||||
{platform.name}
|
|
||||||
</span>
|
|
||||||
) : null
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{item.brief && (
|
||||||
<div className="flex gap-4 text-sm text-text-tertiary mb-4">
|
<div className="flex gap-4 text-sm text-text-tertiary mb-4">
|
||||||
<span>{brief.rulesCount} 条规则</span>
|
<span>{item.brief.selling_points?.length || 0} 个卖点</span>
|
||||||
<span>{brief.creatorsCount} 位达人</span>
|
<span>{item.brief.blacklist_words?.length || 0} 个违禁词</span>
|
||||||
|
{item.brief.min_duration && item.brief.max_duration && (
|
||||||
|
<span>{item.brief.min_duration}-{item.brief.max_duration}秒</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between pt-3 border-t border-border-subtle">
|
<div className="flex items-center justify-between pt-3 border-t border-border-subtle">
|
||||||
<span className="text-xs text-text-tertiary">
|
<span className="text-xs text-text-tertiary">
|
||||||
更新于 {brief.updatedAt}
|
更新于 {item.updatedAt?.split('T')[0] || '-'}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
|
{item.brief && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => viewBriefDetail(brief)}
|
onClick={() => viewBriefDetail(item)}
|
||||||
className="p-1.5 hover:bg-bg-elevated rounded-lg transition-colors"
|
className="p-1.5 hover:bg-bg-elevated rounded-lg transition-colors"
|
||||||
title="查看详情"
|
title="查看详情"
|
||||||
>
|
>
|
||||||
<Eye size={16} className="text-text-tertiary hover:text-accent-indigo" />
|
<Eye size={16} className="text-text-tertiary hover:text-accent-indigo" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
window.location.href = `/brand/projects/${item.projectId}/config`
|
||||||
|
}}
|
||||||
className="p-1.5 hover:bg-bg-elevated rounded-lg transition-colors"
|
className="p-1.5 hover:bg-bg-elevated rounded-lg transition-colors"
|
||||||
title="编辑"
|
title="编辑 Brief"
|
||||||
>
|
>
|
||||||
<Edit size={16} className="text-text-tertiary hover:text-accent-indigo" />
|
<Edit size={16} className="text-text-tertiary hover:text-accent-indigo" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleDeleteBrief(brief.id)}
|
|
||||||
className="p-1.5 hover:bg-accent-coral/10 rounded-lg transition-colors"
|
|
||||||
title="删除"
|
|
||||||
>
|
|
||||||
<Trash2 size={16} className="text-text-tertiary hover:text-accent-coral" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* 新建卡片 */}
|
{filteredItems.length === 0 && !loading && (
|
||||||
<button
|
<div className="col-span-3 text-center py-12 text-text-tertiary">
|
||||||
type="button"
|
<FileText size={48} className="mx-auto mb-4 opacity-50" />
|
||||||
onClick={() => setShowCreateModal(true)}
|
<p>没有找到匹配的项目</p>
|
||||||
className="p-5 rounded-xl border-2 border-dashed border-border-subtle hover:border-accent-indigo hover:bg-accent-indigo/5 transition-all flex flex-col items-center justify-center min-h-[240px]"
|
|
||||||
>
|
|
||||||
<div className="p-3 bg-bg-elevated rounded-full mb-3">
|
|
||||||
<Plus size={24} className="text-text-tertiary" />
|
|
||||||
</div>
|
|
||||||
<span className="text-text-tertiary font-medium">新建 Brief</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 新建 Brief 弹窗 */}
|
|
||||||
<Modal
|
|
||||||
isOpen={showCreateModal}
|
|
||||||
onClose={() => {
|
|
||||||
setShowCreateModal(false)
|
|
||||||
setNewBriefName('')
|
|
||||||
setNewBriefDesc('')
|
|
||||||
setSelectedPlatforms([])
|
|
||||||
}}
|
|
||||||
title="新建 Brief"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-2">Brief 名称</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newBriefName}
|
|
||||||
onChange={(e) => setNewBriefName(e.target.value)}
|
|
||||||
placeholder="输入 Brief 名称"
|
|
||||||
className="w-full px-4 py-2.5 border border-border-subtle rounded-xl 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={newBriefDesc}
|
|
||||||
onChange={(e) => setNewBriefDesc(e.target.value)}
|
|
||||||
className="w-full h-20 px-4 py-3 border border-border-subtle rounded-xl bg-bg-elevated text-text-primary resize-none focus:outline-none focus:ring-2 focus:ring-accent-indigo"
|
|
||||||
placeholder="输入 Brief 描述..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 选择平台规则库 */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-2">
|
|
||||||
选择平台规则库 <span className="text-accent-coral">*</span>
|
|
||||||
</label>
|
|
||||||
<p className="text-xs text-text-tertiary mb-3">选择要应用的平台审核规则,可多选</p>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
|
||||||
{platformOptions.map((platform) => (
|
|
||||||
<button
|
|
||||||
key={platform.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => togglePlatform(platform.id)}
|
|
||||||
className={`p-3 rounded-xl border-2 transition-all flex items-center gap-3 ${
|
|
||||||
selectedPlatforms.includes(platform.id)
|
|
||||||
? 'border-accent-indigo bg-accent-indigo/10'
|
|
||||||
: 'border-border-subtle hover:border-accent-indigo/50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className={`w-10 h-10 ${platform.color} rounded-lg flex items-center justify-center text-lg`}>
|
|
||||||
{platform.icon}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 text-left">
|
|
||||||
<p className="font-medium text-text-primary">{platform.name}</p>
|
|
||||||
</div>
|
|
||||||
{selectedPlatforms.includes(platform.id) && (
|
|
||||||
<div className="w-5 h-5 rounded-full bg-accent-indigo flex items-center justify-center">
|
|
||||||
<Check size={12} className="text-white" />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* 上传 PDF */}
|
|
||||||
<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-xl p-6 text-center hover:border-accent-indigo transition-colors cursor-pointer">
|
|
||||||
<Upload size={32} className="mx-auto text-text-tertiary mb-2" />
|
|
||||||
<p className="text-sm text-text-primary">点击或拖拽上传 PDF 文件</p>
|
|
||||||
<p className="text-xs text-text-tertiary mt-1">AI 将自动提取规则</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3 justify-end pt-4 border-t border-border-subtle">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => {
|
|
||||||
setShowCreateModal(false)
|
|
||||||
setNewBriefName('')
|
|
||||||
setNewBriefDesc('')
|
|
||||||
setSelectedPlatforms([])
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleCreateBrief}
|
|
||||||
disabled={!newBriefName.trim() || selectedPlatforms.length === 0}
|
|
||||||
>
|
|
||||||
创建 Brief
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Brief 详情弹窗 */}
|
{/* Brief 详情弹窗 */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={showDetailModal}
|
isOpen={showDetailModal}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setShowDetailModal(false)
|
setShowDetailModal(false)
|
||||||
setSelectedBrief(null)
|
setSelectedItem(null)
|
||||||
}}
|
}}
|
||||||
title={selectedBrief?.name || 'Brief 详情'}
|
title={selectedItem?.projectName ? `Brief - ${selectedItem.projectName}` : 'Brief 详情'}
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
{selectedBrief && (
|
{selectedItem?.brief && (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="flex items-center gap-4 p-4 rounded-xl bg-bg-elevated">
|
<div className="flex items-center gap-4 p-4 rounded-xl bg-bg-elevated">
|
||||||
<div className="p-3 bg-accent-indigo/15 rounded-xl">
|
<div className="p-3 bg-accent-indigo/15 rounded-xl">
|
||||||
<FileText size={28} className="text-accent-indigo" />
|
<FileText size={28} className="text-accent-indigo" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="text-lg font-semibold text-text-primary">{selectedBrief.name}</h3>
|
<h3 className="text-lg font-semibold text-text-primary">{selectedItem.projectName}</h3>
|
||||||
<p className="text-sm text-text-tertiary mt-0.5">{selectedBrief.description}</p>
|
{selectedItem.brief.brand_tone && (
|
||||||
</div>
|
<p className="text-sm text-text-tertiary mt-0.5">品牌调性: {selectedItem.brief.brand_tone}</p>
|
||||||
{selectedBrief.status === 'active' ? (
|
|
||||||
<SuccessTag>使用中</SuccessTag>
|
|
||||||
) : (
|
|
||||||
<PendingTag>已归档</PendingTag>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<SuccessTag>已配置</SuccessTag>
|
||||||
{/* 应用的平台规则库 */}
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-text-primary mb-3">应用的平台规则库</h4>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
{selectedBrief.platforms.map(platformId => {
|
|
||||||
const platform = getPlatformInfo(platformId)
|
|
||||||
return platform ? (
|
|
||||||
<div
|
|
||||||
key={platformId}
|
|
||||||
className="p-3 rounded-xl bg-bg-elevated border border-border-subtle flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<div className={`w-10 h-10 ${platform.color} rounded-lg flex items-center justify-center text-lg`}>
|
|
||||||
{platform.icon}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-text-primary">{platform.name}</p>
|
|
||||||
<p className="text-xs text-text-tertiary">规则库已启用</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 统计数据 */}
|
{/* 卖点列表 */}
|
||||||
<div className="grid grid-cols-3 gap-4">
|
{(selectedItem.brief.selling_points?.length ?? 0) > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-text-primary mb-3">卖点要求</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{selectedItem.brief.selling_points!.map((sp, idx) => (
|
||||||
|
<div key={idx} className="flex items-center justify-between p-3 rounded-lg bg-bg-elevated">
|
||||||
|
<span className="text-sm text-text-primary">{sp.content}</span>
|
||||||
|
{sp.required && (
|
||||||
|
<span className="text-xs px-2 py-0.5 bg-accent-coral/15 text-accent-coral rounded">必须</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 违禁词 */}
|
||||||
|
{(selectedItem.brief.blacklist_words?.length ?? 0) > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-text-primary mb-3">违禁词</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{selectedItem.brief.blacklist_words!.map((bw, idx) => (
|
||||||
|
<span key={idx} className="px-3 py-1.5 rounded-lg bg-accent-coral/10 text-accent-coral text-sm">
|
||||||
|
{bw.word}
|
||||||
|
{bw.reason && <span className="text-xs text-text-tertiary ml-1">({bw.reason})</span>}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 时长要求 */}
|
||||||
|
{(selectedItem.brief.min_duration || selectedItem.brief.max_duration) && (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="p-4 rounded-xl bg-accent-indigo/10 border border-accent-indigo/20 text-center">
|
<div className="p-4 rounded-xl bg-accent-indigo/10 border border-accent-indigo/20 text-center">
|
||||||
<p className="text-2xl font-bold text-accent-indigo">{selectedBrief.rulesCount}</p>
|
<p className="text-2xl font-bold text-accent-indigo">{selectedItem.brief.min_duration || '-'}秒</p>
|
||||||
<p className="text-sm text-text-secondary mt-1">自定义规则</p>
|
<p className="text-sm text-text-secondary mt-1">最短时长</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 rounded-xl bg-accent-green/10 border border-accent-green/20 text-center">
|
<div className="p-4 rounded-xl bg-accent-green/10 border border-accent-green/20 text-center">
|
||||||
<p className="text-2xl font-bold text-accent-green">{selectedBrief.creatorsCount}</p>
|
<p className="text-2xl font-bold text-accent-green">{selectedItem.brief.max_duration || '-'}秒</p>
|
||||||
<p className="text-sm text-text-secondary mt-1">关联达人</p>
|
<p className="text-sm text-text-secondary mt-1">最长时长</p>
|
||||||
</div>
|
|
||||||
<div className="p-4 rounded-xl bg-accent-amber/10 border border-accent-amber/20 text-center">
|
|
||||||
<p className="text-2xl font-bold text-accent-amber">{selectedBrief.platforms.length}</p>
|
|
||||||
<p className="text-sm text-text-secondary mt-1">平台规则库</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 其他要求 */}
|
||||||
|
{selectedItem.brief.other_requirements && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-text-primary mb-2">其他要求</h4>
|
||||||
|
<p className="text-sm text-text-secondary p-3 rounded-lg bg-bg-elevated">
|
||||||
|
{selectedItem.brief.other_requirements}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 时间信息 */}
|
{/* 时间信息 */}
|
||||||
<div className="flex items-center justify-between p-4 rounded-xl bg-bg-elevated text-sm">
|
<div className="flex items-center justify-between p-4 rounded-xl bg-bg-elevated text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-text-tertiary">创建时间:</span>
|
<span className="text-text-tertiary">创建时间:</span>
|
||||||
<span className="text-text-primary">{selectedBrief.createdAt}</span>
|
<span className="text-text-primary">{selectedItem.brief.created_at?.split('T')[0]}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-text-tertiary">最后更新:</span>
|
<span className="text-text-tertiary">最后更新:</span>
|
||||||
<span className="text-text-primary">{selectedBrief.updatedAt}</span>
|
<span className="text-text-primary">{selectedItem.brief.updated_at?.split('T')[0]}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -418,7 +346,10 @@ export default function BriefsPage() {
|
|||||||
<Button variant="ghost" onClick={() => setShowDetailModal(false)}>
|
<Button variant="ghost" onClick={() => setShowDetailModal(false)}>
|
||||||
关闭
|
关闭
|
||||||
</Button>
|
</Button>
|
||||||
<Button>
|
<Button onClick={() => {
|
||||||
|
setShowDetailModal(false)
|
||||||
|
window.location.href = `/brand/projects/${selectedItem.projectId}/config`
|
||||||
|
}}>
|
||||||
编辑 Brief
|
编辑 Brief
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,49 +1,41 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { ArrowLeft, Check, X, CheckSquare, Video, Clock } from 'lucide-react'
|
import { ArrowLeft, Check, X, CheckSquare, Video, Clock, Loader2, FileText } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { getPlatformInfo } from '@/lib/platforms'
|
import { getPlatformInfo } from '@/lib/platforms'
|
||||||
import { useToast } from '@/components/ui/Toast'
|
import { useToast } from '@/components/ui/Toast'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
|
import type { TaskResponse } from '@/types/task'
|
||||||
|
|
||||||
// 模拟待审核内容列表
|
// ==================== Mock 数据 ====================
|
||||||
const mockReviewItems = [
|
const mockReviewItems: TaskResponse[] = [
|
||||||
{
|
{
|
||||||
id: 'review-001',
|
id: 'TK000001',
|
||||||
title: '春季护肤新品体验分享',
|
name: '春季护肤新品体验分享',
|
||||||
creator: '小美',
|
sequence: 1,
|
||||||
agency: '代理商A',
|
stage: 'video_brand_review',
|
||||||
platform: 'douyin',
|
project: { id: 'PJ000001', name: 'XX品牌618推广' },
|
||||||
reviewer: '张三',
|
agency: { id: 'AG000001', name: '代理商A' },
|
||||||
reviewTime: '2小时前',
|
creator: { id: 'CR000001', name: '小美' },
|
||||||
agencyOpinion: '内容符合Brief要求,卖点覆盖完整,建议通过。',
|
video_file_url: '/demo/video.mp4',
|
||||||
agencyStatus: 'passed',
|
video_file_name: '春季护肤_成片v2.mp4',
|
||||||
aiScore: 12,
|
video_duration: 135,
|
||||||
aiChecks: [
|
video_ai_score: 88,
|
||||||
{ label: '合规检测', status: 'passed', description: '未检测到违禁词、竞品Logo等违规内容' },
|
video_ai_result: {
|
||||||
{ label: '卖点覆盖', status: 'passed', description: '核心卖点覆盖率 95%' },
|
score: 88,
|
||||||
{ label: '品牌调性', status: 'passed', description: '视觉风格符合品牌调性' },
|
violations: [],
|
||||||
],
|
soft_warnings: [],
|
||||||
currentStep: 4, // 1-已提交, 2-AI审核, 3-代理商审核, 4-品牌终审
|
summary: '视频整体合规,卖点覆盖完整。',
|
||||||
},
|
},
|
||||||
{
|
video_agency_status: 'passed',
|
||||||
id: 'review-002',
|
video_agency_comment: '内容符合Brief要求,卖点覆盖完整,建议通过。',
|
||||||
title: '夏日清爽护肤推荐',
|
appeal_count: 0,
|
||||||
creator: '小红',
|
is_appeal: false,
|
||||||
agency: '代理商B',
|
created_at: '2026-02-06T14:00:00Z',
|
||||||
platform: 'xiaohongshu',
|
updated_at: '2026-02-06T16:00:00Z',
|
||||||
reviewer: '李四',
|
|
||||||
reviewTime: '5小时前',
|
|
||||||
agencyOpinion: '内容质量良好,但部分镜头略暗,建议后期调整后通过。',
|
|
||||||
agencyStatus: 'passed',
|
|
||||||
aiScore: 28,
|
|
||||||
aiChecks: [
|
|
||||||
{ label: '合规检测', status: 'passed', description: '未检测到违规内容' },
|
|
||||||
{ label: '卖点覆盖', status: 'warning', description: '核心卖点覆盖率 78%,建议增加产品特写' },
|
|
||||||
{ label: '品牌调性', status: 'passed', description: '视觉风格符合品牌调性' },
|
|
||||||
],
|
|
||||||
currentStep: 4,
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -98,21 +90,100 @@ function ReviewProgressBar({ currentStep }: { currentStep: number }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PageSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 h-full min-h-0 animate-pulse">
|
||||||
|
<div className="h-12 bg-bg-elevated rounded-lg w-1/3" />
|
||||||
|
<div className="h-20 bg-bg-elevated rounded-2xl" />
|
||||||
|
<div className="flex gap-6 flex-1 min-h-0">
|
||||||
|
<div className="flex-1 h-96 bg-bg-elevated rounded-2xl" />
|
||||||
|
<div className="w-[380px] h-96 bg-bg-elevated rounded-2xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function FinalReviewPage() {
|
export default function FinalReviewPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const [selectedItem, setSelectedItem] = useState(mockReviewItems[0])
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [tasks, setTasks] = useState<TaskResponse[]>([])
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||||
const [feedback, setFeedback] = useState('')
|
const [feedback, setFeedback] = useState('')
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const platform = getPlatformInfo(selectedItem.platform)
|
|
||||||
|
const loadTasks = useCallback(async () => {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
setTasks(mockReviewItems)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// 加载品牌方待审任务(脚本 + 视频)
|
||||||
|
const [scriptRes, videoRes] = await Promise.all([
|
||||||
|
api.listTasks(1, 10, 'script_brand_review'),
|
||||||
|
api.listTasks(1, 10, 'video_brand_review'),
|
||||||
|
])
|
||||||
|
setTasks([...scriptRes.items, ...videoRes.items])
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load review tasks:', err)
|
||||||
|
toast.error('加载待审任务失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [toast])
|
||||||
|
|
||||||
|
useEffect(() => { loadTasks() }, [loadTasks])
|
||||||
|
|
||||||
|
if (loading) return <PageSkeleton />
|
||||||
|
|
||||||
|
if (tasks.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full gap-4 text-text-tertiary">
|
||||||
|
<CheckSquare size={48} className="opacity-50" />
|
||||||
|
<p className="text-lg">暂无待终审的内容</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-bg-elevated text-text-secondary text-sm font-medium"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
返回
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedItem = tasks[selectedIndex]
|
||||||
|
const isVideoReview = selectedItem.stage === 'video_brand_review'
|
||||||
|
const aiResult = isVideoReview ? selectedItem.video_ai_result : selectedItem.script_ai_result
|
||||||
|
const aiScore = isVideoReview ? selectedItem.video_ai_score : selectedItem.script_ai_score
|
||||||
|
const agencyComment = isVideoReview ? selectedItem.video_agency_comment : selectedItem.script_agency_comment
|
||||||
|
const agencyStatus = isVideoReview ? selectedItem.video_agency_status : selectedItem.script_agency_status
|
||||||
|
|
||||||
const handleApprove = async () => {
|
const handleApprove = async () => {
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
// 模拟提交
|
try {
|
||||||
|
if (USE_MOCK) {
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
|
} else {
|
||||||
|
const reviewFn = isVideoReview ? api.reviewVideo : api.reviewScript
|
||||||
|
await reviewFn(selectedItem.id, { action: 'pass', comment: feedback || undefined })
|
||||||
|
}
|
||||||
toast.success('已通过审核')
|
toast.success('已通过审核')
|
||||||
|
setFeedback('')
|
||||||
|
// 移除已审核任务
|
||||||
|
const remaining = tasks.filter((_, i) => i !== selectedIndex)
|
||||||
|
setTasks(remaining)
|
||||||
|
if (selectedIndex >= remaining.length && remaining.length > 0) {
|
||||||
|
setSelectedIndex(remaining.length - 1)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('操作失败')
|
||||||
|
} finally {
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleReject = async () => {
|
const handleReject = async () => {
|
||||||
if (!feedback.trim()) {
|
if (!feedback.trim()) {
|
||||||
@ -120,11 +191,25 @@ export default function FinalReviewPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
// 模拟提交
|
try {
|
||||||
|
if (USE_MOCK) {
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
|
} else {
|
||||||
|
const reviewFn = isVideoReview ? api.reviewVideo : api.reviewScript
|
||||||
|
await reviewFn(selectedItem.id, { action: 'reject', comment: feedback })
|
||||||
|
}
|
||||||
toast.success('已驳回')
|
toast.success('已驳回')
|
||||||
setIsSubmitting(false)
|
|
||||||
setFeedback('')
|
setFeedback('')
|
||||||
|
const remaining = tasks.filter((_, i) => i !== selectedIndex)
|
||||||
|
setTasks(remaining)
|
||||||
|
if (selectedIndex >= remaining.length && remaining.length > 0) {
|
||||||
|
setSelectedIndex(remaining.length - 1)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('操作失败')
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -134,17 +219,21 @@ export default function FinalReviewPage() {
|
|||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-bold text-text-primary">终审台</h1>
|
<h1 className="text-2xl font-bold text-text-primary">终审台</h1>
|
||||||
{platform && (
|
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-lg text-sm font-medium ${
|
||||||
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-lg text-sm font-medium ${platform.bgColor} ${platform.textColor} border ${platform.borderColor}`}>
|
isVideoReview ? 'bg-purple-500/15 text-purple-400' : 'bg-accent-indigo/15 text-accent-indigo'
|
||||||
<span>{platform.icon}</span>
|
}`}>
|
||||||
{platform.name}
|
{isVideoReview ? <Video size={14} /> : <FileText size={14} />}
|
||||||
|
{isVideoReview ? '视频终审' : '脚本终审'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-text-secondary">
|
<p className="text-sm text-text-secondary">
|
||||||
{selectedItem.title} · 达人: {selectedItem.creator}
|
{selectedItem.name} · 达人: {selectedItem.creator.name}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-text-tertiary">
|
||||||
|
{selectedIndex + 1} / {tasks.length} 待审
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => router.back()}
|
onClick={() => router.back()}
|
||||||
@ -154,6 +243,7 @@ export default function FinalReviewPage() {
|
|||||||
返回列表
|
返回列表
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 审核流程进度 */}
|
{/* 审核流程进度 */}
|
||||||
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
|
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
|
||||||
@ -161,22 +251,38 @@ export default function FinalReviewPage() {
|
|||||||
<span className="text-sm font-semibold text-text-primary">审核流程</span>
|
<span className="text-sm font-semibold text-text-primary">审核流程</span>
|
||||||
<span className="text-xs text-accent-indigo font-medium">当前:品牌终审</span>
|
<span className="text-xs text-accent-indigo font-medium">当前:品牌终审</span>
|
||||||
</div>
|
</div>
|
||||||
<ReviewProgressBar currentStep={selectedItem.currentStep} />
|
<ReviewProgressBar currentStep={4} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 主内容区 - 两栏布局 */}
|
{/* 主内容区 - 两栏布局 */}
|
||||||
<div className="flex gap-6 flex-1 min-h-0">
|
<div className="flex gap-6 flex-1 min-h-0">
|
||||||
{/* 左侧 - 视频播放器 */}
|
{/* 左侧 - 预览 */}
|
||||||
<div className="flex-1 flex flex-col gap-4">
|
<div className="flex-1 flex flex-col gap-4">
|
||||||
<div className="flex-1 bg-bg-card rounded-2xl card-shadow flex items-center justify-center">
|
<div className="flex-1 bg-bg-card rounded-2xl card-shadow flex items-center justify-center">
|
||||||
<div className="w-[640px] h-[360px] rounded-xl bg-black flex items-center justify-center">
|
{isVideoReview ? (
|
||||||
|
selectedItem.video_file_url ? (
|
||||||
|
<video
|
||||||
|
className="w-full h-full rounded-2xl"
|
||||||
|
controls
|
||||||
|
src={selectedItem.video_file_url}
|
||||||
|
>
|
||||||
|
您的浏览器不支持视频播放
|
||||||
|
</video>
|
||||||
|
) : (
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col items-center gap-4">
|
||||||
<div className="w-20 h-20 rounded-full bg-[#1A1A1E] flex items-center justify-center">
|
<div className="w-20 h-20 rounded-full bg-[#1A1A1E] flex items-center justify-center">
|
||||||
<Video className="w-10 h-10 text-text-tertiary" />
|
<Video className="w-10 h-10 text-text-tertiary" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-text-tertiary">视频播放器</p>
|
<p className="text-sm text-text-tertiary">视频文件不可用</p>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-4 p-8">
|
||||||
|
<FileText className="w-16 h-16 text-accent-indigo/50" />
|
||||||
|
<p className="text-text-secondary">{selectedItem.script_file_name || '脚本预览'}</p>
|
||||||
|
<p className="text-xs text-text-tertiary">请在详情页查看完整脚本内容</p>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -188,16 +294,20 @@ export default function FinalReviewPage() {
|
|||||||
<span className="text-base font-semibold text-text-primary">代理商初审意见</span>
|
<span className="text-base font-semibold text-text-primary">代理商初审意见</span>
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
'px-3 py-1.5 rounded-lg text-[13px] font-semibold',
|
'px-3 py-1.5 rounded-lg text-[13px] font-semibold',
|
||||||
selectedItem.agencyStatus === 'passed' ? 'bg-accent-green/15 text-accent-green' : 'bg-accent-coral/15 text-accent-coral'
|
agencyStatus === 'passed' || agencyStatus === 'force_passed'
|
||||||
|
? 'bg-accent-green/15 text-accent-green'
|
||||||
|
: 'bg-accent-coral/15 text-accent-coral'
|
||||||
)}>
|
)}>
|
||||||
{selectedItem.agencyStatus === 'passed' ? '已通过' : '需修改'}
|
{agencyStatus === 'passed' || agencyStatus === 'force_passed' ? '已通过' : '需修改'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-bg-elevated rounded-[10px] p-3 flex flex-col gap-2">
|
<div className="bg-bg-elevated rounded-[10px] p-3 flex flex-col gap-2">
|
||||||
<span className="text-xs text-text-tertiary">
|
<span className="text-xs text-text-tertiary">
|
||||||
审核人:{selectedItem.agency} - {selectedItem.reviewer} · {selectedItem.reviewTime}
|
审核人:{selectedItem.agency.name}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-[13px] text-text-secondary">{selectedItem.agencyOpinion}</p>
|
<p className="text-[13px] text-text-secondary">
|
||||||
|
{agencyComment || '无评论'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -207,29 +317,34 @@ export default function FinalReviewPage() {
|
|||||||
<span className="text-base font-semibold text-text-primary">AI 分析结果</span>
|
<span className="text-base font-semibold text-text-primary">AI 分析结果</span>
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
'px-3 py-1.5 rounded-lg text-[13px] font-semibold',
|
'px-3 py-1.5 rounded-lg text-[13px] font-semibold',
|
||||||
selectedItem.aiScore < 30 ? 'bg-accent-green/15 text-accent-green' : 'bg-accent-amber/15 text-accent-amber'
|
(aiScore || 0) >= 80 ? 'bg-accent-green/15 text-accent-green' : 'bg-accent-amber/15 text-accent-amber'
|
||||||
)}>
|
)}>
|
||||||
风险评分: {selectedItem.aiScore}
|
评分: {aiScore || '-'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{selectedItem.aiChecks.map((check, index) => (
|
{aiResult?.violations && aiResult.violations.length > 0 ? (
|
||||||
<div key={index} className="bg-bg-elevated rounded-[10px] p-3 flex flex-col gap-2">
|
aiResult.violations.map((v, idx) => (
|
||||||
|
<div key={idx} className="bg-bg-elevated rounded-[10px] p-3 flex flex-col gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CheckSquare className={cn(
|
<CheckSquare className="w-4 h-4 text-accent-coral" />
|
||||||
'w-4 h-4',
|
<span className="text-sm font-semibold text-accent-coral">{v.type}</span>
|
||||||
check.status === 'passed' ? 'text-accent-green' : 'text-accent-amber'
|
|
||||||
)} />
|
|
||||||
<span className={cn(
|
|
||||||
'text-sm font-semibold',
|
|
||||||
check.status === 'passed' ? 'text-accent-green' : 'text-accent-amber'
|
|
||||||
)}>
|
|
||||||
{check.label} · {check.status === 'passed' ? '通过' : '警告'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[13px] text-text-secondary">{check.description}</p>
|
<p className="text-[13px] text-text-secondary">{v.content}</p>
|
||||||
|
{v.suggestion && (
|
||||||
|
<p className="text-xs text-accent-indigo">{v.suggestion}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))
|
||||||
|
) : (
|
||||||
|
<div className="bg-bg-elevated rounded-[10px] p-3 flex items-center gap-2">
|
||||||
|
<CheckSquare className="w-4 h-4 text-accent-green" />
|
||||||
|
<span className="text-sm font-semibold text-accent-green">合规检测通过</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{aiResult?.summary && (
|
||||||
|
<p className="text-xs text-text-tertiary mt-1">{aiResult.summary}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -245,7 +360,7 @@ export default function FinalReviewPage() {
|
|||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="flex-1 flex items-center justify-center gap-2 py-3.5 rounded-xl bg-accent-green text-white font-semibold disabled:opacity-50"
|
className="flex-1 flex items-center justify-center gap-2 py-3.5 rounded-xl bg-accent-green text-white font-semibold disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Check className="w-[18px] h-[18px]" />
|
{isSubmitting ? <Loader2 className="w-[18px] h-[18px] animate-spin" /> : <Check className="w-[18px] h-[18px]" />}
|
||||||
通过
|
通过
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@ -254,7 +369,7 @@ export default function FinalReviewPage() {
|
|||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="flex-1 flex items-center justify-center gap-2 py-3.5 rounded-xl bg-accent-coral text-white font-semibold disabled:opacity-50"
|
className="flex-1 flex items-center justify-center gap-2 py-3.5 rounded-xl bg-accent-coral text-white font-semibold disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<X className="w-[18px] h-[18px]" />
|
{isSubmitting ? <Loader2 className="w-[18px] h-[18px] animate-spin" /> : <X className="w-[18px] h-[18px]" />}
|
||||||
驳回
|
驳回
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -262,7 +377,7 @@ export default function FinalReviewPage() {
|
|||||||
{/* 终审意见 */}
|
{/* 终审意见 */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label className="text-[13px] font-medium text-text-secondary">
|
<label className="text-[13px] font-medium text-text-secondary">
|
||||||
终审意见(可选)
|
终审意见(驳回时必填)
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={feedback}
|
value={feedback}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
@ -19,12 +19,17 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
File,
|
File,
|
||||||
MessageSquareWarning
|
MessageSquareWarning,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Modal } from '@/components/ui/Modal'
|
import { Modal } from '@/components/ui/Modal'
|
||||||
import { getPlatformInfo } from '@/lib/platforms'
|
import { getPlatformInfo } from '@/lib/platforms'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
|
import type { TaskResponse } from '@/types/task'
|
||||||
|
|
||||||
|
// ==================== Mock 数据 ====================
|
||||||
|
|
||||||
// 模拟脚本待审列表
|
|
||||||
const mockScriptTasks = [
|
const mockScriptTasks = [
|
||||||
{
|
{
|
||||||
id: 'script-001',
|
id: 'script-001',
|
||||||
@ -59,7 +64,6 @@ const mockScriptTasks = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
// 模拟视频待审列表
|
|
||||||
const mockVideoTasks = [
|
const mockVideoTasks = [
|
||||||
{
|
{
|
||||||
id: 'video-001',
|
id: 'video-001',
|
||||||
@ -112,23 +116,118 @@ const mockVideoTasks = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// ==================== 类型定义 ====================
|
||||||
|
|
||||||
|
interface UITask {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
fileName: string
|
||||||
|
fileSize: string
|
||||||
|
creatorName: string
|
||||||
|
agencyName: string
|
||||||
|
projectName: string
|
||||||
|
platform: string
|
||||||
|
aiScore: number
|
||||||
|
submittedAt: string
|
||||||
|
hasHighRisk: boolean
|
||||||
|
agencyApproved: boolean
|
||||||
|
isAppeal: boolean
|
||||||
|
appealReason?: string
|
||||||
|
duration?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 映射函数 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将后端 TaskResponse 映射为 UI 任务格式
|
||||||
|
*/
|
||||||
|
function mapTaskToUI(task: TaskResponse, type: 'script' | 'video'): UITask {
|
||||||
|
const isScript = type === 'script'
|
||||||
|
|
||||||
|
// AI 评分:脚本用 script_ai_score,视频用 video_ai_score
|
||||||
|
const aiScore = isScript
|
||||||
|
? (task.script_ai_score ?? 0)
|
||||||
|
: (task.video_ai_score ?? 0)
|
||||||
|
|
||||||
|
// AI 审核结果中检测是否有高风险(severity === 'high')
|
||||||
|
const aiResult = isScript ? task.script_ai_result : task.video_ai_result
|
||||||
|
const hasHighRisk = aiResult?.violations?.some(v => v.severity === 'high') ?? false
|
||||||
|
|
||||||
|
// 代理商审核状态
|
||||||
|
const agencyStatus = isScript ? task.script_agency_status : task.video_agency_status
|
||||||
|
const agencyApproved = agencyStatus === 'passed' || agencyStatus === 'force_passed'
|
||||||
|
|
||||||
|
// 文件名
|
||||||
|
const fileName = isScript
|
||||||
|
? (task.script_file_name ?? '未上传脚本')
|
||||||
|
: (task.video_file_name ?? '未上传视频')
|
||||||
|
|
||||||
|
// 视频时长:后端返回秒数,转为 mm:ss 格式
|
||||||
|
let duration: string | undefined
|
||||||
|
if (!isScript && task.video_duration) {
|
||||||
|
const minutes = Math.floor(task.video_duration / 60)
|
||||||
|
const seconds = task.video_duration % 60
|
||||||
|
duration = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化提交时间
|
||||||
|
const submittedAt = formatDateTime(task.updated_at)
|
||||||
|
|
||||||
|
// 平台信息:后端目前不返回平台字段,默认 douyin
|
||||||
|
const platform = 'douyin'
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
title: task.name,
|
||||||
|
fileName,
|
||||||
|
fileSize: isScript ? '--' : '--',
|
||||||
|
creatorName: task.creator.name,
|
||||||
|
agencyName: task.agency.name,
|
||||||
|
projectName: task.project.name,
|
||||||
|
platform,
|
||||||
|
aiScore,
|
||||||
|
submittedAt,
|
||||||
|
hasHighRisk,
|
||||||
|
agencyApproved,
|
||||||
|
isAppeal: task.is_appeal,
|
||||||
|
appealReason: task.appeal_reason ?? undefined,
|
||||||
|
duration,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化日期时间为 YYYY-MM-DD HH:mm
|
||||||
|
*/
|
||||||
|
function formatDateTime(isoString: string): string {
|
||||||
|
try {
|
||||||
|
const d = new Date(isoString)
|
||||||
|
const year = d.getFullYear()
|
||||||
|
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(d.getDate()).padStart(2, '0')
|
||||||
|
const hours = String(d.getHours()).padStart(2, '0')
|
||||||
|
const minutes = String(d.getMinutes()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||||
|
} catch {
|
||||||
|
return isoString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 子组件 ====================
|
||||||
|
|
||||||
function ScoreTag({ score }: { score: number }) {
|
function ScoreTag({ score }: { score: number }) {
|
||||||
if (score >= 85) return <SuccessTag>{score}分</SuccessTag>
|
if (score >= 85) return <SuccessTag>{score}分</SuccessTag>
|
||||||
if (score >= 70) return <WarningTag>{score}分</WarningTag>
|
if (score >= 70) return <WarningTag>{score}分</WarningTag>
|
||||||
return <ErrorTag>{score}分</ErrorTag>
|
return <ErrorTag>{score}分</ErrorTag>
|
||||||
}
|
}
|
||||||
|
|
||||||
type ScriptTask = typeof mockScriptTasks[0]
|
|
||||||
type VideoTask = typeof mockVideoTasks[0]
|
|
||||||
|
|
||||||
function TaskCard({
|
function TaskCard({
|
||||||
task,
|
task,
|
||||||
type,
|
type,
|
||||||
onPreview
|
onPreview
|
||||||
}: {
|
}: {
|
||||||
task: ScriptTask | VideoTask
|
task: UITask
|
||||||
type: 'script' | 'video'
|
type: 'script' | 'video'
|
||||||
onPreview: (task: ScriptTask | VideoTask, type: 'script' | 'video') => void
|
onPreview: (task: UITask, type: 'script' | 'video') => void
|
||||||
}) {
|
}) {
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const href = type === 'script' ? `/brand/review/script/${task.id}` : `/brand/review/video/${task.id}`
|
const href = type === 'script' ? `/brand/review/script/${task.id}` : `/brand/review/video/${task.id}`
|
||||||
@ -210,7 +309,7 @@ function TaskCard({
|
|||||||
<p className="text-sm font-medium text-text-primary truncate">{task.fileName}</p>
|
<p className="text-sm font-medium text-text-primary truncate">{task.fileName}</p>
|
||||||
<p className="text-xs text-text-tertiary">
|
<p className="text-xs text-text-tertiary">
|
||||||
{task.fileSize}
|
{task.fileSize}
|
||||||
{'duration' in task && ` · ${task.duration}`}
|
{task.duration && ` · ${task.duration}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@ -244,27 +343,106 @@ function TaskCard({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TaskListSkeleton({ count = 2 }: { count?: number }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<div key={i} className="rounded-lg border border-border-subtle overflow-hidden animate-pulse">
|
||||||
|
<div className="px-4 py-1.5 bg-bg-elevated border-b border-border-subtle">
|
||||||
|
<div className="h-4 w-20 bg-bg-page rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="h-5 w-48 bg-bg-page rounded" />
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="h-4 w-24 bg-bg-page rounded" />
|
||||||
|
<div className="h-4 w-24 bg-bg-page rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-6 w-14 bg-bg-page rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-lg bg-bg-page">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-bg-elevated" />
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<div className="h-4 w-40 bg-bg-elevated rounded" />
|
||||||
|
<div className="h-3 w-20 bg-bg-elevated rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<div className="h-3 w-28 bg-bg-page rounded" />
|
||||||
|
<div className="h-3 w-32 bg-bg-page rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 主页面 ====================
|
||||||
|
|
||||||
export default function BrandReviewListPage() {
|
export default function BrandReviewListPage() {
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [activeTab, setActiveTab] = useState<'all' | 'script' | 'video'>('all')
|
const [activeTab, setActiveTab] = useState<'all' | 'script' | 'video'>('all')
|
||||||
const [previewTask, setPreviewTask] = useState<{ task: ScriptTask | VideoTask; type: 'script' | 'video' } | null>(null)
|
const [previewTask, setPreviewTask] = useState<{ task: UITask; type: 'script' | 'video' } | null>(null)
|
||||||
|
|
||||||
const filteredScripts = mockScriptTasks.filter(task =>
|
// API 数据状态
|
||||||
|
const [scriptTasks, setScriptTasks] = useState<UITask[]>([])
|
||||||
|
const [videoTasks, setVideoTasks] = useState<UITask[]>([])
|
||||||
|
const [loading, setLoading] = useState(!USE_MOCK)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// 从 API 加载数据
|
||||||
|
const fetchTasks = useCallback(async () => {
|
||||||
|
if (USE_MOCK) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [scriptRes, videoRes] = await Promise.all([
|
||||||
|
api.listTasks(1, 20, 'script_brand_review'),
|
||||||
|
api.listTasks(1, 20, 'video_brand_review'),
|
||||||
|
])
|
||||||
|
|
||||||
|
setScriptTasks(scriptRes.items.map(t => mapTaskToUI(t, 'script')))
|
||||||
|
setVideoTasks(videoRes.items.map(t => mapTaskToUI(t, 'video')))
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '加载任务失败'
|
||||||
|
setError(message)
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [toast])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
setScriptTasks(mockScriptTasks)
|
||||||
|
setVideoTasks(mockVideoTasks)
|
||||||
|
} else {
|
||||||
|
fetchTasks()
|
||||||
|
}
|
||||||
|
}, [fetchTasks])
|
||||||
|
|
||||||
|
// 搜索过滤
|
||||||
|
const filteredScripts = scriptTasks.filter(task =>
|
||||||
task.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
task.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
task.creatorName.toLowerCase().includes(searchQuery.toLowerCase())
|
task.creatorName.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
)
|
)
|
||||||
|
|
||||||
const filteredVideos = mockVideoTasks.filter(task =>
|
const filteredVideos = videoTasks.filter(task =>
|
||||||
task.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
task.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
task.creatorName.toLowerCase().includes(searchQuery.toLowerCase())
|
task.creatorName.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
)
|
)
|
||||||
|
|
||||||
// 计算申诉数量
|
// 计算申诉数量
|
||||||
const appealScriptCount = mockScriptTasks.filter(t => t.isAppeal).length
|
const appealScriptCount = scriptTasks.filter(t => t.isAppeal).length
|
||||||
const appealVideoCount = mockVideoTasks.filter(t => t.isAppeal).length
|
const appealVideoCount = videoTasks.filter(t => t.isAppeal).length
|
||||||
|
|
||||||
const handlePreview = (task: ScriptTask | VideoTask, type: 'script' | 'video') => {
|
const handlePreview = (task: UITask, type: 'script' | 'video') => {
|
||||||
setPreviewTask({ task, type })
|
setPreviewTask({ task, type })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -277,12 +455,19 @@ export default function BrandReviewListPage() {
|
|||||||
<p className="text-sm text-text-secondary mt-1">审核代理商提交的脚本和视频</p>
|
<p className="text-sm text-text-secondary mt-1">审核代理商提交的脚本和视频</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
{loading ? (
|
||||||
|
<span className="flex items-center gap-2 text-text-tertiary">
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
加载中...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<span className="text-text-secondary">待审核:</span>
|
<span className="text-text-secondary">待审核:</span>
|
||||||
<span className="px-2 py-1 bg-accent-indigo/20 text-accent-indigo rounded font-medium">
|
<span className="px-2 py-1 bg-accent-indigo/20 text-accent-indigo rounded font-medium">
|
||||||
{mockScriptTasks.length} 脚本
|
{scriptTasks.length} 脚本
|
||||||
</span>
|
</span>
|
||||||
<span className="px-2 py-1 bg-purple-500/20 text-purple-400 rounded font-medium">
|
<span className="px-2 py-1 bg-purple-500/20 text-purple-400 rounded font-medium">
|
||||||
{mockVideoTasks.length} 视频
|
{videoTasks.length} 视频
|
||||||
</span>
|
</span>
|
||||||
{(appealScriptCount + appealVideoCount) > 0 && (
|
{(appealScriptCount + appealVideoCount) > 0 && (
|
||||||
<span className="px-2 py-1 bg-accent-amber/20 text-accent-amber rounded font-medium flex items-center gap-1">
|
<span className="px-2 py-1 bg-accent-amber/20 text-accent-amber rounded font-medium flex items-center gap-1">
|
||||||
@ -290,6 +475,8 @@ export default function BrandReviewListPage() {
|
|||||||
{appealScriptCount + appealVideoCount} 申诉
|
{appealScriptCount + appealVideoCount} 申诉
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -336,6 +523,16 @@ export default function BrandReviewListPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 加载错误提示 */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 rounded-lg bg-accent-coral/10 border border-accent-coral/30 text-accent-coral text-sm flex items-center justify-between">
|
||||||
|
<span>加载失败: {error}</span>
|
||||||
|
<Button variant="secondary" size="sm" onClick={fetchTasks}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 任务列表 */}
|
{/* 任务列表 */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* 脚本待审列表 */}
|
{/* 脚本待审列表 */}
|
||||||
@ -346,12 +543,14 @@ export default function BrandReviewListPage() {
|
|||||||
<FileText size={18} className="text-accent-indigo" />
|
<FileText size={18} className="text-accent-indigo" />
|
||||||
脚本终审
|
脚本终审
|
||||||
<span className="ml-auto text-sm font-normal text-text-secondary">
|
<span className="ml-auto text-sm font-normal text-text-secondary">
|
||||||
{filteredScripts.length} 条待审
|
{loading ? '...' : `${filteredScripts.length} 条待审`}
|
||||||
</span>
|
</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{filteredScripts.length > 0 ? (
|
{loading ? (
|
||||||
|
<TaskListSkeleton count={2} />
|
||||||
|
) : filteredScripts.length > 0 ? (
|
||||||
filteredScripts.map((task) => (
|
filteredScripts.map((task) => (
|
||||||
<TaskCard key={task.id} task={task} type="script" onPreview={handlePreview} />
|
<TaskCard key={task.id} task={task} type="script" onPreview={handlePreview} />
|
||||||
))
|
))
|
||||||
@ -373,12 +572,14 @@ export default function BrandReviewListPage() {
|
|||||||
<Video size={18} className="text-purple-400" />
|
<Video size={18} className="text-purple-400" />
|
||||||
视频终审
|
视频终审
|
||||||
<span className="ml-auto text-sm font-normal text-text-secondary">
|
<span className="ml-auto text-sm font-normal text-text-secondary">
|
||||||
{filteredVideos.length} 条待审
|
{loading ? '...' : `${filteredVideos.length} 条待审`}
|
||||||
</span>
|
</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{filteredVideos.length > 0 ? (
|
{loading ? (
|
||||||
|
<TaskListSkeleton count={3} />
|
||||||
|
) : filteredVideos.length > 0 ? (
|
||||||
filteredVideos.map((task) => (
|
filteredVideos.map((task) => (
|
||||||
<TaskCard key={task.id} task={task} type="video" onPreview={handlePreview} />
|
<TaskCard key={task.id} task={task} type="video" onPreview={handlePreview} />
|
||||||
))
|
))
|
||||||
@ -437,10 +638,10 @@ export default function BrandReviewListPage() {
|
|||||||
<span>{previewTask?.task.fileName}</span>
|
<span>{previewTask?.task.fileName}</span>
|
||||||
<span className="mx-2">·</span>
|
<span className="mx-2">·</span>
|
||||||
<span>{previewTask?.task.fileSize}</span>
|
<span>{previewTask?.task.fileSize}</span>
|
||||||
{previewTask?.type === 'video' && 'duration' in (previewTask?.task || {}) && (
|
{previewTask?.type === 'video' && previewTask?.task.duration && (
|
||||||
<>
|
<>
|
||||||
<span className="mx-2">·</span>
|
<span className="mx-2">·</span>
|
||||||
<span>{(previewTask.task as VideoTask).duration}</span>
|
<span>{previewTask.task.duration}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useRouter, useParams } from 'next/navigation'
|
import { useRouter, useParams } from 'next/navigation'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
@ -8,6 +8,8 @@ import { Modal, ConfirmModal } from '@/components/ui/Modal'
|
|||||||
import { SuccessTag, WarningTag, ErrorTag, PendingTag } from '@/components/ui/Tag'
|
import { SuccessTag, WarningTag, ErrorTag, PendingTag } from '@/components/ui/Tag'
|
||||||
import { ReviewSteps, getBrandReviewSteps } from '@/components/ui/ReviewSteps'
|
import { ReviewSteps, getBrandReviewSteps } from '@/components/ui/ReviewSteps'
|
||||||
import { useToast } from '@/components/ui/Toast'
|
import { useToast } from '@/components/ui/Toast'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
FileText,
|
FileText,
|
||||||
@ -21,11 +23,13 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
Shield,
|
Shield,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
MessageSquareWarning
|
MessageSquareWarning,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { FilePreview, FileInfoCard, FilePreviewModal, type FileInfo } from '@/components/ui/FilePreview'
|
import { FilePreview, FileInfoCard, FilePreviewModal, type FileInfo } from '@/components/ui/FilePreview'
|
||||||
|
import type { TaskResponse } from '@/types/task'
|
||||||
|
|
||||||
// 模拟脚本任务数据
|
// Mock 脚本任务数据(USE_MOCK 模式使用)
|
||||||
const mockScriptTask = {
|
const mockScriptTask = {
|
||||||
id: 'script-001',
|
id: 'script-001',
|
||||||
title: '夏日护肤推广脚本',
|
title: '夏日护肤推广脚本',
|
||||||
@ -35,7 +39,6 @@ const mockScriptTask = {
|
|||||||
submittedAt: '2026-02-06 14:30',
|
submittedAt: '2026-02-06 14:30',
|
||||||
aiScore: 88,
|
aiScore: 88,
|
||||||
status: 'brand_reviewing',
|
status: 'brand_reviewing',
|
||||||
// 文件信息
|
|
||||||
file: {
|
file: {
|
||||||
id: 'file-001',
|
id: 'file-001',
|
||||||
fileName: '夏日护肤推广_脚本v2.docx',
|
fileName: '夏日护肤推广_脚本v2.docx',
|
||||||
@ -44,7 +47,6 @@ const mockScriptTask = {
|
|||||||
fileUrl: '/demo/scripts/script-001.docx',
|
fileUrl: '/demo/scripts/script-001.docx',
|
||||||
uploadedAt: '2026-02-06 14:30',
|
uploadedAt: '2026-02-06 14:30',
|
||||||
} as FileInfo,
|
} as FileInfo,
|
||||||
// 申诉信息
|
|
||||||
isAppeal: false,
|
isAppeal: false,
|
||||||
appealReason: '',
|
appealReason: '',
|
||||||
scriptContent: {
|
scriptContent: {
|
||||||
@ -78,6 +80,69 @@ const mockScriptTask = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 从 TaskResponse 映射出页面所需的数据结构
|
||||||
|
function mapTaskToView(task: TaskResponse) {
|
||||||
|
const violations = (task.script_ai_result?.violations || []).map((v, idx) => ({
|
||||||
|
id: `v-${idx}`,
|
||||||
|
type: v.type,
|
||||||
|
content: v.content,
|
||||||
|
suggestion: v.suggestion,
|
||||||
|
severity: v.severity,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const softWarnings = (task.script_ai_result?.soft_warnings || []).map((w, idx) => ({
|
||||||
|
id: `w-${idx}`,
|
||||||
|
type: w.type,
|
||||||
|
content: w.content,
|
||||||
|
suggestion: w.suggestion,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const fileExtension = task.script_file_name?.split('.').pop()?.toLowerCase() || ''
|
||||||
|
const mimeTypeMap: Record<string, string> = {
|
||||||
|
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
doc: 'application/msword',
|
||||||
|
pdf: 'application/pdf',
|
||||||
|
txt: 'text/plain',
|
||||||
|
rtf: 'application/rtf',
|
||||||
|
}
|
||||||
|
|
||||||
|
const agencyResult = task.script_agency_status || 'pending'
|
||||||
|
const agencyResultLabel = agencyResult === 'passed' ? '建议通过' : agencyResult === 'rejected' ? '建议驳回' : '待审核'
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
title: task.name,
|
||||||
|
creatorName: task.creator.name,
|
||||||
|
agencyName: task.agency.name,
|
||||||
|
projectName: task.project.name,
|
||||||
|
submittedAt: task.script_uploaded_at || task.created_at,
|
||||||
|
aiScore: task.script_ai_score || 0,
|
||||||
|
status: task.stage,
|
||||||
|
file: {
|
||||||
|
id: task.id,
|
||||||
|
fileName: task.script_file_name || '未上传文件',
|
||||||
|
fileSize: '',
|
||||||
|
fileType: mimeTypeMap[fileExtension] || 'application/octet-stream',
|
||||||
|
fileUrl: task.script_file_url || '',
|
||||||
|
uploadedAt: task.script_uploaded_at || undefined,
|
||||||
|
} as FileInfo,
|
||||||
|
isAppeal: task.is_appeal,
|
||||||
|
appealReason: task.appeal_reason || '',
|
||||||
|
agencyReview: {
|
||||||
|
reviewer: task.agency.name,
|
||||||
|
result: agencyResult,
|
||||||
|
resultLabel: agencyResultLabel,
|
||||||
|
comment: task.script_agency_comment || '',
|
||||||
|
reviewedAt: '',
|
||||||
|
},
|
||||||
|
aiAnalysis: {
|
||||||
|
violations,
|
||||||
|
softWarnings,
|
||||||
|
sellingPoints: [] as Array<{ point: string; covered: boolean }>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function ReviewProgressBar({ taskStatus }: { taskStatus: string }) {
|
function ReviewProgressBar({ taskStatus }: { taskStatus: string }) {
|
||||||
const steps = getBrandReviewSteps(taskStatus)
|
const steps = getBrandReviewSteps(taskStatus)
|
||||||
const currentStep = steps.find(s => s.status === 'current')
|
const currentStep = steps.find(s => s.status === 'current')
|
||||||
@ -97,32 +162,142 @@ function ReviewProgressBar({ taskStatus }: { taskStatus: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LoadingSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 animate-pulse">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 bg-bg-elevated rounded-full" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="h-6 bg-bg-elevated rounded w-1/3" />
|
||||||
|
<div className="h-4 bg-bg-elevated rounded w-1/2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-16 bg-bg-elevated rounded-xl" />
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-2 space-y-4">
|
||||||
|
<div className="h-20 bg-bg-elevated rounded-xl" />
|
||||||
|
<div className="h-64 bg-bg-elevated rounded-xl" />
|
||||||
|
<div className="h-32 bg-bg-elevated rounded-xl" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="h-20 bg-bg-elevated rounded-xl" />
|
||||||
|
<div className="h-40 bg-bg-elevated rounded-xl" />
|
||||||
|
<div className="h-40 bg-bg-elevated rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function BrandScriptReviewPage() {
|
export default function BrandScriptReviewPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const taskId = params.id as string
|
||||||
|
|
||||||
const [showApproveModal, setShowApproveModal] = useState(false)
|
const [showApproveModal, setShowApproveModal] = useState(false)
|
||||||
const [showRejectModal, setShowRejectModal] = useState(false)
|
const [showRejectModal, setShowRejectModal] = useState(false)
|
||||||
const [rejectReason, setRejectReason] = useState('')
|
const [rejectReason, setRejectReason] = useState('')
|
||||||
const [viewMode, setViewMode] = useState<'file' | 'parsed'>('file')
|
const [viewMode, setViewMode] = useState<'file' | 'parsed'>('file')
|
||||||
const [showFilePreview, setShowFilePreview] = useState(false)
|
const [showFilePreview, setShowFilePreview] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(!USE_MOCK)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [taskData, setTaskData] = useState<ReturnType<typeof mapTaskToView> | null>(null)
|
||||||
|
|
||||||
const task = mockScriptTask
|
// 加载任务数据
|
||||||
|
const loadTask = useCallback(async () => {
|
||||||
|
if (USE_MOCK) return
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const response = await api.getTask(taskId)
|
||||||
|
setTaskData(mapTaskToView(response))
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '加载任务失败'
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [taskId, toast])
|
||||||
|
|
||||||
const handleApprove = () => {
|
useEffect(() => {
|
||||||
|
loadTask()
|
||||||
|
}, [loadTask])
|
||||||
|
|
||||||
|
// USE_MOCK 模式下使用 mock 数据
|
||||||
|
const task = USE_MOCK ? {
|
||||||
|
...mockScriptTask,
|
||||||
|
agencyReview: {
|
||||||
|
...mockScriptTask.agencyReview,
|
||||||
|
resultLabel: '建议通过',
|
||||||
|
},
|
||||||
|
aiAnalysis: {
|
||||||
|
...mockScriptTask.aiAnalysis,
|
||||||
|
softWarnings: [] as Array<{ id: string; type: string; content: string; suggestion: string }>,
|
||||||
|
},
|
||||||
|
} : taskData
|
||||||
|
|
||||||
|
const handleApprove = async () => {
|
||||||
|
if (USE_MOCK) {
|
||||||
setShowApproveModal(false)
|
setShowApproveModal(false)
|
||||||
toast.success('审核通过')
|
toast.success('审核通过')
|
||||||
router.push('/brand/review')
|
router.push('/brand/review')
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReject = () => {
|
try {
|
||||||
|
setSubmitting(true)
|
||||||
|
await api.reviewScript(taskId, { action: 'pass', comment: '' })
|
||||||
|
setShowApproveModal(false)
|
||||||
|
toast.success('审核通过')
|
||||||
|
router.push('/brand/review')
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '操作失败'
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReject = async () => {
|
||||||
if (!rejectReason.trim()) {
|
if (!rejectReason.trim()) {
|
||||||
toast.error('请填写驳回原因')
|
toast.error('请填写驳回原因')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (USE_MOCK) {
|
||||||
setShowRejectModal(false)
|
setShowRejectModal(false)
|
||||||
toast.success('已驳回')
|
toast.success('已驳回')
|
||||||
router.push('/brand/review')
|
router.push('/brand/review')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSubmitting(true)
|
||||||
|
await api.reviewScript(taskId, { action: 'reject', comment: rejectReason })
|
||||||
|
setShowRejectModal(false)
|
||||||
|
toast.success('已驳回')
|
||||||
|
router.push('/brand/review')
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '操作失败'
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载中
|
||||||
|
if (loading) {
|
||||||
|
return <LoadingSkeleton />
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数据未加载到
|
||||||
|
if (!task) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20">
|
||||||
|
<p className="text-text-secondary mb-4">任务数据加载失败</p>
|
||||||
|
<Button variant="secondary" onClick={() => router.back()}>返回</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -199,10 +374,12 @@ export default function BrandScriptReviewPage() {
|
|||||||
{/* 左侧:脚本内容 */}
|
{/* 左侧:脚本内容 */}
|
||||||
<div className="lg:col-span-2 space-y-4">
|
<div className="lg:col-span-2 space-y-4">
|
||||||
{/* 文件信息卡片 */}
|
{/* 文件信息卡片 */}
|
||||||
|
{task.file.fileUrl && (
|
||||||
<FileInfoCard
|
<FileInfoCard
|
||||||
file={task.file}
|
file={task.file}
|
||||||
onPreview={() => setShowFilePreview(true)}
|
onPreview={() => setShowFilePreview(true)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{viewMode === 'file' ? (
|
{viewMode === 'file' ? (
|
||||||
<Card>
|
<Card>
|
||||||
@ -213,7 +390,11 @@ export default function BrandScriptReviewPage() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
{task.file.fileUrl ? (
|
||||||
<FilePreview file={task.file} />
|
<FilePreview file={task.file} />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-text-tertiary text-center py-8">暂无文件</p>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
@ -226,22 +407,31 @@ export default function BrandScriptReviewPage() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
{USE_MOCK && 'scriptContent' in task ? (
|
||||||
|
<>
|
||||||
<div className="p-4 bg-bg-elevated rounded-lg">
|
<div className="p-4 bg-bg-elevated rounded-lg">
|
||||||
<div className="text-xs text-accent-indigo font-medium mb-2">开场白</div>
|
<div className="text-xs text-accent-indigo font-medium mb-2">开场白</div>
|
||||||
<p className="text-text-primary">{task.scriptContent.opening}</p>
|
<p className="text-text-primary">{(task as typeof mockScriptTask).scriptContent.opening}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 bg-bg-elevated rounded-lg">
|
<div className="p-4 bg-bg-elevated rounded-lg">
|
||||||
<div className="text-xs text-purple-400 font-medium mb-2">产品介绍</div>
|
<div className="text-xs text-purple-400 font-medium mb-2">产品介绍</div>
|
||||||
<p className="text-text-primary">{task.scriptContent.productIntro}</p>
|
<p className="text-text-primary">{(task as typeof mockScriptTask).scriptContent.productIntro}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 bg-bg-elevated rounded-lg">
|
<div className="p-4 bg-bg-elevated rounded-lg">
|
||||||
<div className="text-xs text-orange-400 font-medium mb-2">使用演示</div>
|
<div className="text-xs text-orange-400 font-medium mb-2">使用演示</div>
|
||||||
<p className="text-text-primary">{task.scriptContent.demo}</p>
|
<p className="text-text-primary">{(task as typeof mockScriptTask).scriptContent.demo}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 bg-bg-elevated rounded-lg">
|
<div className="p-4 bg-bg-elevated rounded-lg">
|
||||||
<div className="text-xs text-accent-green font-medium mb-2">结尾引导</div>
|
<div className="text-xs text-accent-green font-medium mb-2">结尾引导</div>
|
||||||
<p className="text-text-primary">{task.scriptContent.closing}</p>
|
<p className="text-text-primary">{(task as typeof mockScriptTask).scriptContent.closing}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<FileText size={32} className="mx-auto text-text-tertiary mb-3" />
|
||||||
|
<p className="text-sm text-text-tertiary">API 暂不支持解析内容预览,请切换到「原文件」查看</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@ -255,9 +445,10 @@ export default function BrandScriptReviewPage() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
{task.agencyReview.comment ? (
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className={`p-2 rounded-full ${task.agencyReview.result === 'approved' ? 'bg-accent-green/20' : 'bg-accent-coral/20'}`}>
|
<div className={`p-2 rounded-full ${task.agencyReview.result === 'passed' || task.agencyReview.result === 'approved' ? 'bg-accent-green/20' : 'bg-accent-coral/20'}`}>
|
||||||
{task.agencyReview.result === 'approved' ? (
|
{task.agencyReview.result === 'passed' || task.agencyReview.result === 'approved' ? (
|
||||||
<CheckCircle size={20} className="text-accent-green" />
|
<CheckCircle size={20} className="text-accent-green" />
|
||||||
) : (
|
) : (
|
||||||
<XCircle size={20} className="text-accent-coral" />
|
<XCircle size={20} className="text-accent-coral" />
|
||||||
@ -266,12 +457,23 @@ export default function BrandScriptReviewPage() {
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span className="font-medium text-text-primary">{task.agencyReview.reviewer}</span>
|
<span className="font-medium text-text-primary">{task.agencyReview.reviewer}</span>
|
||||||
<SuccessTag>建议通过</SuccessTag>
|
{(task.agencyReview.result === 'passed' || task.agencyReview.result === 'approved') ? (
|
||||||
|
<SuccessTag>{task.agencyReview.resultLabel}</SuccessTag>
|
||||||
|
) : task.agencyReview.result === 'rejected' ? (
|
||||||
|
<ErrorTag>{task.agencyReview.resultLabel}</ErrorTag>
|
||||||
|
) : (
|
||||||
|
<PendingTag>{task.agencyReview.resultLabel}</PendingTag>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-text-secondary text-sm">{task.agencyReview.comment}</p>
|
<p className="text-text-secondary text-sm">{task.agencyReview.comment}</p>
|
||||||
|
{task.agencyReview.reviewedAt && (
|
||||||
<p className="text-xs text-text-tertiary mt-2">{task.agencyReview.reviewedAt}</p>
|
<p className="text-xs text-text-tertiary mt-2">{task.agencyReview.reviewedAt}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-text-tertiary text-center py-4">暂无代理商审核意见</p>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@ -304,7 +506,7 @@ export default function BrandScriptReviewPage() {
|
|||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<WarningTag>{v.type}</WarningTag>
|
<WarningTag>{v.type}</WarningTag>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-text-primary">「{v.content}」</p>
|
<p className="text-sm text-text-primary">{v.content}</p>
|
||||||
<p className="text-xs text-accent-indigo mt-1">{v.suggestion}</p>
|
<p className="text-xs text-accent-indigo mt-1">{v.suggestion}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -314,7 +516,31 @@ export default function BrandScriptReviewPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 合规检查 */}
|
{/* 软性提醒 */}
|
||||||
|
{task.aiAnalysis.softWarnings && task.aiAnalysis.softWarnings.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Shield size={16} className="text-accent-indigo" />
|
||||||
|
软性提醒 ({task.aiAnalysis.softWarnings.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{task.aiAnalysis.softWarnings.map((w) => (
|
||||||
|
<div key={w.id} className="p-3 bg-accent-indigo/10 rounded-lg border border-accent-indigo/30">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<PendingTag>{w.type}</PendingTag>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-text-primary">{w.content}</p>
|
||||||
|
<p className="text-xs text-accent-indigo mt-1">{w.suggestion}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 合规检查 - 仅 mock 模式显示 */}
|
||||||
|
{USE_MOCK && 'complianceChecks' in task.aiAnalysis && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
@ -323,7 +549,7 @@ export default function BrandScriptReviewPage() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
{task.aiAnalysis.complianceChecks.map((check, idx) => (
|
{(task.aiAnalysis as typeof mockScriptTask.aiAnalysis).complianceChecks.map((check, idx) => (
|
||||||
<div key={idx} className="flex items-start gap-2 p-2 rounded-lg bg-bg-elevated">
|
<div key={idx} className="flex items-start gap-2 p-2 rounded-lg bg-bg-elevated">
|
||||||
{check.passed ? (
|
{check.passed ? (
|
||||||
<CheckCircle size={16} className="text-accent-green flex-shrink-0 mt-0.5" />
|
<CheckCircle size={16} className="text-accent-green flex-shrink-0 mt-0.5" />
|
||||||
@ -340,8 +566,10 @@ export default function BrandScriptReviewPage() {
|
|||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 卖点覆盖 */}
|
{/* 卖点覆盖 */}
|
||||||
|
{task.aiAnalysis.sellingPoints.length > 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
@ -362,6 +590,7 @@ export default function BrandScriptReviewPage() {
|
|||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -373,10 +602,12 @@ export default function BrandScriptReviewPage() {
|
|||||||
项目:{task.projectName}
|
项目:{task.projectName}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Button variant="danger" onClick={() => setShowRejectModal(true)}>
|
<Button variant="danger" onClick={() => setShowRejectModal(true)} disabled={submitting}>
|
||||||
|
{submitting ? <Loader2 size={16} className="animate-spin" /> : null}
|
||||||
驳回
|
驳回
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="success" onClick={() => setShowApproveModal(true)}>
|
<Button variant="success" onClick={() => setShowApproveModal(true)} disabled={submitting}>
|
||||||
|
{submitting ? <Loader2 size={16} className="animate-spin" /> : null}
|
||||||
通过
|
通过
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -408,8 +639,11 @@ export default function BrandScriptReviewPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 justify-end">
|
<div className="flex gap-3 justify-end">
|
||||||
<Button variant="ghost" onClick={() => setShowRejectModal(false)}>取消</Button>
|
<Button variant="ghost" onClick={() => setShowRejectModal(false)} disabled={submitting}>取消</Button>
|
||||||
<Button variant="danger" onClick={handleReject}>确认驳回</Button>
|
<Button variant="danger" onClick={handleReject} disabled={submitting}>
|
||||||
|
{submitting ? <Loader2 size={16} className="animate-spin mr-1" /> : null}
|
||||||
|
确认驳回
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useRouter, useParams } from 'next/navigation'
|
import { useRouter, useParams } from 'next/navigation'
|
||||||
import { useToast } from '@/components/ui/Toast'
|
import { useToast } from '@/components/ui/Toast'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||||
@ -22,22 +22,92 @@ import {
|
|||||||
XCircle,
|
XCircle,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
MessageSquareWarning
|
MessageSquareWarning,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { FileInfoCard, FilePreviewModal, type FileInfo } from '@/components/ui/FilePreview'
|
import { FileInfoCard, FilePreviewModal, type FileInfo } from '@/components/ui/FilePreview'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
|
import type { TaskResponse } from '@/types/task'
|
||||||
|
|
||||||
// 模拟视频任务数据
|
// ==================== AI 审核结果类型 ====================
|
||||||
const mockVideoTask = {
|
|
||||||
|
interface AIReviewResult {
|
||||||
|
score: number
|
||||||
|
violations: Array<{
|
||||||
|
type: string
|
||||||
|
content: string
|
||||||
|
severity: string
|
||||||
|
suggestion: string
|
||||||
|
timestamp?: number
|
||||||
|
source?: string
|
||||||
|
}>
|
||||||
|
soft_warnings: Array<{
|
||||||
|
type: string
|
||||||
|
content: string
|
||||||
|
suggestion: string
|
||||||
|
}>
|
||||||
|
summary?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 本地视图数据类型 ====================
|
||||||
|
|
||||||
|
interface VideoTaskView {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
creatorName: string
|
||||||
|
agencyName: string
|
||||||
|
projectName: string
|
||||||
|
submittedAt: string
|
||||||
|
duration: number
|
||||||
|
aiScore: number
|
||||||
|
status: string
|
||||||
|
file: FileInfo
|
||||||
|
isAppeal: boolean
|
||||||
|
appealReason: string
|
||||||
|
agencyReview: {
|
||||||
|
reviewer: string
|
||||||
|
result: string
|
||||||
|
comment: string
|
||||||
|
reviewedAt?: string
|
||||||
|
}
|
||||||
|
hardViolations: Array<{
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
content: string
|
||||||
|
timestamp: number
|
||||||
|
source: string
|
||||||
|
riskLevel: string
|
||||||
|
aiConfidence: number
|
||||||
|
suggestion: string
|
||||||
|
}>
|
||||||
|
sentimentWarnings: Array<{
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
timestamp: number
|
||||||
|
content: string
|
||||||
|
riskLevel: string
|
||||||
|
}>
|
||||||
|
sellingPointsCovered: Array<{
|
||||||
|
point: string
|
||||||
|
covered: boolean
|
||||||
|
timestamp: number
|
||||||
|
}>
|
||||||
|
aiSummary?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Mock 数据 ====================
|
||||||
|
|
||||||
|
const mockVideoTask: VideoTaskView = {
|
||||||
id: 'video-001',
|
id: 'video-001',
|
||||||
title: '夏日护肤推广',
|
title: '夏日护肤推广',
|
||||||
creatorName: '小美护肤',
|
creatorName: '小美护肤',
|
||||||
agencyName: '星耀传媒',
|
agencyName: '星耀传媒',
|
||||||
projectName: 'XX品牌618推广',
|
projectName: 'XX品牌618推广',
|
||||||
submittedAt: '2026-02-06 15:00',
|
submittedAt: '2026-02-06 15:00',
|
||||||
duration: 135, // 秒
|
duration: 135,
|
||||||
aiScore: 85,
|
aiScore: 85,
|
||||||
status: 'brand_reviewing',
|
status: 'brand_reviewing',
|
||||||
// 文件信息
|
|
||||||
file: {
|
file: {
|
||||||
id: 'file-video-001',
|
id: 'file-video-001',
|
||||||
fileName: '夏日护肤_成片v2.mp4',
|
fileName: '夏日护肤_成片v2.mp4',
|
||||||
@ -47,8 +117,7 @@ const mockVideoTask = {
|
|||||||
uploadedAt: '2026-02-06 15:00',
|
uploadedAt: '2026-02-06 15:00',
|
||||||
duration: '02:15',
|
duration: '02:15',
|
||||||
thumbnail: '/demo/videos/video-001-thumb.jpg',
|
thumbnail: '/demo/videos/video-001-thumb.jpg',
|
||||||
} as FileInfo,
|
},
|
||||||
// 申诉信息
|
|
||||||
isAppeal: false,
|
isAppeal: false,
|
||||||
appealReason: '',
|
appealReason: '',
|
||||||
agencyReview: {
|
agencyReview: {
|
||||||
@ -90,12 +159,88 @@ const mockVideoTask = {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 工具函数 ====================
|
||||||
|
|
||||||
function formatTimestamp(seconds: number): string {
|
function formatTimestamp(seconds: number): string {
|
||||||
const mins = Math.floor(seconds / 60)
|
const mins = Math.floor(seconds / 60)
|
||||||
const secs = Math.floor(seconds % 60)
|
const secs = Math.floor(seconds % 60)
|
||||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDurationString(seconds: number): string {
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
const secs = Math.floor(seconds % 60)
|
||||||
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityToRiskLevel(severity: string): string {
|
||||||
|
if (severity === 'high' || severity === 'critical') return 'high'
|
||||||
|
if (severity === 'medium') return 'medium'
|
||||||
|
return 'low'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将后端 TaskResponse 映射为本地视图数据 */
|
||||||
|
function mapTaskToView(task: TaskResponse): VideoTaskView {
|
||||||
|
const aiResult = task.video_ai_result as AIReviewResult | null | undefined
|
||||||
|
|
||||||
|
const hardViolations = (aiResult?.violations || []).map((v, idx) => ({
|
||||||
|
id: `v${idx}`,
|
||||||
|
type: v.type,
|
||||||
|
content: v.content,
|
||||||
|
timestamp: v.timestamp ?? 0,
|
||||||
|
source: v.source ?? 'unknown',
|
||||||
|
riskLevel: severityToRiskLevel(v.severity),
|
||||||
|
aiConfidence: 0.9,
|
||||||
|
suggestion: v.suggestion,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const sentimentWarnings = (aiResult?.soft_warnings || []).map((w, idx) => ({
|
||||||
|
id: `s${idx}`,
|
||||||
|
type: w.type,
|
||||||
|
timestamp: 0,
|
||||||
|
content: w.content,
|
||||||
|
riskLevel: 'low',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const duration = task.video_duration || 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
title: task.name,
|
||||||
|
creatorName: task.creator.name,
|
||||||
|
agencyName: task.agency.name,
|
||||||
|
projectName: task.project.name,
|
||||||
|
submittedAt: task.video_uploaded_at || task.created_at,
|
||||||
|
duration,
|
||||||
|
aiScore: task.video_ai_score || 0,
|
||||||
|
status: task.stage,
|
||||||
|
file: {
|
||||||
|
id: task.id,
|
||||||
|
fileName: task.video_file_name || '视频文件',
|
||||||
|
fileSize: '',
|
||||||
|
fileType: 'video/mp4',
|
||||||
|
fileUrl: task.video_file_url || '',
|
||||||
|
uploadedAt: task.video_uploaded_at || task.created_at,
|
||||||
|
duration: formatDurationString(duration),
|
||||||
|
thumbnail: task.video_thumbnail_url || undefined,
|
||||||
|
},
|
||||||
|
isAppeal: task.is_appeal,
|
||||||
|
appealReason: task.appeal_reason || '',
|
||||||
|
agencyReview: {
|
||||||
|
reviewer: task.agency.name,
|
||||||
|
result: task.video_agency_status === 'passed' || task.video_agency_status === 'force_passed' ? 'approved' : (task.video_agency_status || 'pending'),
|
||||||
|
comment: task.video_agency_comment || '',
|
||||||
|
},
|
||||||
|
hardViolations,
|
||||||
|
sentimentWarnings,
|
||||||
|
// 卖点覆盖目前后端暂无,保留空数组
|
||||||
|
sellingPointsCovered: [],
|
||||||
|
aiSummary: aiResult?.summary,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 子组件 ====================
|
||||||
|
|
||||||
function ReviewProgressBar({ taskStatus }: { taskStatus: string }) {
|
function ReviewProgressBar({ taskStatus }: { taskStatus: string }) {
|
||||||
const steps = getBrandReviewSteps(taskStatus)
|
const steps = getBrandReviewSteps(taskStatus)
|
||||||
const currentStep = steps.find(s => s.status === 'current')
|
const currentStep = steps.find(s => s.status === 'current')
|
||||||
@ -121,10 +266,48 @@ function RiskLevelTag({ level }: { level: string }) {
|
|||||||
return <SuccessTag>低风险</SuccessTag>
|
return <SuccessTag>低风险</SuccessTag>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LoadingSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 animate-pulse">
|
||||||
|
{/* 顶部导航骨架 */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-9 h-9 bg-bg-elevated rounded-full" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="h-6 w-48 bg-bg-elevated rounded" />
|
||||||
|
<div className="h-4 w-72 bg-bg-elevated rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 流程进度骨架 */}
|
||||||
|
<div className="h-20 bg-bg-elevated rounded-xl" />
|
||||||
|
{/* 主体骨架 */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||||
|
<div className="lg:col-span-3 space-y-4">
|
||||||
|
<div className="h-16 bg-bg-elevated rounded-xl" />
|
||||||
|
<div className="aspect-video bg-bg-elevated rounded-xl" />
|
||||||
|
<div className="h-32 bg-bg-elevated rounded-xl" />
|
||||||
|
<div className="h-24 bg-bg-elevated rounded-xl" />
|
||||||
|
</div>
|
||||||
|
<div className="lg:col-span-2 space-y-4">
|
||||||
|
<div className="h-48 bg-bg-elevated rounded-xl" />
|
||||||
|
<div className="h-32 bg-bg-elevated rounded-xl" />
|
||||||
|
<div className="h-40 bg-bg-elevated rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 主页面 ====================
|
||||||
|
|
||||||
export default function BrandVideoReviewPage() {
|
export default function BrandVideoReviewPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const taskId = params.id as string
|
||||||
|
|
||||||
|
const [task, setTask] = useState<VideoTaskView | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [isPlaying, setIsPlaying] = useState(false)
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
const [showApproveModal, setShowApproveModal] = useState(false)
|
const [showApproveModal, setShowApproveModal] = useState(false)
|
||||||
const [showRejectModal, setShowRejectModal] = useState(false)
|
const [showRejectModal, setShowRejectModal] = useState(false)
|
||||||
@ -133,28 +316,91 @@ export default function BrandVideoReviewPage() {
|
|||||||
const [showFilePreview, setShowFilePreview] = useState(false)
|
const [showFilePreview, setShowFilePreview] = useState(false)
|
||||||
const [videoError, setVideoError] = useState(false)
|
const [videoError, setVideoError] = useState(false)
|
||||||
|
|
||||||
const task = mockVideoTask
|
// 加载任务数据
|
||||||
|
const loadTask = useCallback(async () => {
|
||||||
|
if (!taskId) return
|
||||||
|
|
||||||
const handleApprove = () => {
|
if (USE_MOCK) {
|
||||||
|
// Mock 模式下使用静态数据
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||||
|
setTask(mockVideoTask)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const response = await api.getTask(taskId)
|
||||||
|
setTask(mapTaskToView(response))
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '加载任务失败'
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [taskId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTask()
|
||||||
|
}, [loadTask])
|
||||||
|
|
||||||
|
// 通过审核
|
||||||
|
const handleApprove = async () => {
|
||||||
|
if (submitting) return
|
||||||
|
setSubmitting(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!USE_MOCK) {
|
||||||
|
await api.reviewVideo(taskId, { action: 'pass', comment: '' })
|
||||||
|
} else {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||||
|
}
|
||||||
setShowApproveModal(false)
|
setShowApproveModal(false)
|
||||||
toast.success('审核通过!')
|
toast.success('审核通过!')
|
||||||
router.push('/brand/review')
|
router.push('/brand/review')
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '操作失败'
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReject = () => {
|
// 驳回审核
|
||||||
|
const handleReject = async () => {
|
||||||
if (!rejectReason.trim()) {
|
if (!rejectReason.trim()) {
|
||||||
toast.error('请填写驳回原因')
|
toast.error('请填写驳回原因')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (submitting) return
|
||||||
|
setSubmitting(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!USE_MOCK) {
|
||||||
|
await api.reviewVideo(taskId, { action: 'reject', comment: rejectReason })
|
||||||
|
} else {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||||
|
}
|
||||||
setShowRejectModal(false)
|
setShowRejectModal(false)
|
||||||
toast.success('已驳回')
|
toast.success('已驳回')
|
||||||
router.push('/brand/review')
|
router.push('/brand/review')
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '操作失败'
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载中状态
|
||||||
|
if (loading || !task) {
|
||||||
|
return <LoadingSkeleton />
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算问题时间点用于进度条展示
|
// 计算问题时间点用于进度条展示
|
||||||
const timelineMarkers = [
|
const timelineMarkers = [
|
||||||
...task.hardViolations.map(v => ({ time: v.timestamp, type: 'hard' as const })),
|
...task.hardViolations.map(v => ({ time: v.timestamp, type: 'hard' as const })),
|
||||||
...task.sentimentWarnings.map(w => ({ time: w.timestamp, type: 'soft' as const })),
|
...task.sentimentWarnings.filter(w => w.timestamp > 0).map(w => ({ time: w.timestamp, type: 'soft' as const })),
|
||||||
...task.sellingPointsCovered.filter(s => s.covered).map(s => ({ time: s.timestamp, type: 'selling' as const })),
|
...task.sellingPointsCovered.filter(s => s.covered).map(s => ({ time: s.timestamp, type: 'selling' as const })),
|
||||||
].sort((a, b) => a.time - b.time)
|
].sort((a, b) => a.time - b.time)
|
||||||
|
|
||||||
@ -249,6 +495,7 @@ export default function BrandVideoReviewPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* 智能进度条 */}
|
{/* 智能进度条 */}
|
||||||
|
{task.duration > 0 && (
|
||||||
<div className="p-4 border-t border-border-subtle">
|
<div className="p-4 border-t border-border-subtle">
|
||||||
<div className="text-sm font-medium text-text-primary mb-3">智能进度条(点击跳转)</div>
|
<div className="text-sm font-medium text-text-primary mb-3">智能进度条(点击跳转)</div>
|
||||||
<div className="relative h-3 bg-bg-elevated rounded-full">
|
<div className="relative h-3 bg-bg-elevated rounded-full">
|
||||||
@ -284,6 +531,7 @@ export default function BrandVideoReviewPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@ -307,10 +555,16 @@ export default function BrandVideoReviewPage() {
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span className="font-medium text-text-primary">{task.agencyReview.reviewer}</span>
|
<span className="font-medium text-text-primary">{task.agencyReview.reviewer}</span>
|
||||||
|
{task.agencyReview.result === 'approved' ? (
|
||||||
<SuccessTag>建议通过</SuccessTag>
|
<SuccessTag>建议通过</SuccessTag>
|
||||||
|
) : (
|
||||||
|
<ErrorTag>建议驳回</ErrorTag>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-text-secondary text-sm">{task.agencyReview.comment}</p>
|
<p className="text-text-secondary text-sm">{task.agencyReview.comment || '暂无评论'}</p>
|
||||||
|
{task.agencyReview.reviewedAt && (
|
||||||
<p className="text-xs text-text-tertiary mt-2">{task.agencyReview.reviewedAt}</p>
|
<p className="text-xs text-text-tertiary mt-2">{task.agencyReview.reviewedAt}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -326,7 +580,7 @@ export default function BrandVideoReviewPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-text-secondary text-sm">
|
<p className="text-text-secondary text-sm">
|
||||||
视频整体合规,发现{task.hardViolations.length}处硬性问题和{task.sentimentWarnings.length}处舆情提示,代理商已确认处理。
|
{task.aiSummary || `视频整体合规,发现${task.hardViolations.length}处硬性问题和${task.sentimentWarnings.length}处舆情提示,代理商已确认处理。`}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -343,6 +597,9 @@ export default function BrandVideoReviewPage() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
|
{task.hardViolations.length === 0 && (
|
||||||
|
<p className="text-sm text-text-tertiary py-2">未发现硬性合规问题</p>
|
||||||
|
)}
|
||||||
{task.hardViolations.map((v) => (
|
{task.hardViolations.map((v) => (
|
||||||
<div key={v.id} className={`p-3 rounded-lg border ${checkedViolations[v.id] ? 'bg-bg-elevated border-border-subtle' : 'bg-accent-coral/10 border-accent-coral/30'}`}>
|
<div key={v.id} className={`p-3 rounded-lg border ${checkedViolations[v.id] ? 'bg-bg-elevated border-border-subtle' : 'bg-accent-coral/10 border-accent-coral/30'}`}>
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
@ -355,9 +612,11 @@ export default function BrandVideoReviewPage() {
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<ErrorTag>{v.type}</ErrorTag>
|
<ErrorTag>{v.type}</ErrorTag>
|
||||||
|
{v.timestamp > 0 && (
|
||||||
<span className="text-xs text-text-tertiary">{formatTimestamp(v.timestamp)}</span>
|
<span className="text-xs text-text-tertiary">{formatTimestamp(v.timestamp)}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium text-text-primary">「{v.content}」</p>
|
<p className="text-sm font-medium text-text-primary">{v.content}</p>
|
||||||
<p className="text-xs text-accent-indigo mt-1">{v.suggestion}</p>
|
<p className="text-xs text-accent-indigo mt-1">{v.suggestion}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -380,10 +639,12 @@ export default function BrandVideoReviewPage() {
|
|||||||
<div key={w.id} className="p-3 bg-orange-500/10 rounded-lg border border-orange-500/30">
|
<div key={w.id} className="p-3 bg-orange-500/10 rounded-lg border border-orange-500/30">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<WarningTag>{w.type}</WarningTag>
|
<WarningTag>{w.type}</WarningTag>
|
||||||
|
{w.timestamp > 0 && (
|
||||||
<span className="text-xs text-text-tertiary">{formatTimestamp(w.timestamp)}</span>
|
<span className="text-xs text-text-tertiary">{formatTimestamp(w.timestamp)}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-orange-400">{w.content}</p>
|
<p className="text-sm text-orange-400">{w.content}</p>
|
||||||
<p className="text-xs text-text-tertiary mt-1">⚠️ 软性风险仅作提示,不强制拦截</p>
|
<p className="text-xs text-text-tertiary mt-1">软性风险仅作提示,不强制拦截</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -391,6 +652,7 @@ export default function BrandVideoReviewPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 卖点覆盖 */}
|
{/* 卖点覆盖 */}
|
||||||
|
{task.sellingPointsCovered.length > 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
@ -409,13 +671,14 @@ export default function BrandVideoReviewPage() {
|
|||||||
)}
|
)}
|
||||||
<span className="text-sm text-text-primary">{sp.point}</span>
|
<span className="text-sm text-text-primary">{sp.point}</span>
|
||||||
</div>
|
</div>
|
||||||
{sp.covered && (
|
{sp.covered && sp.timestamp > 0 && (
|
||||||
<span className="text-xs text-text-tertiary">{formatTimestamp(sp.timestamp)}</span>
|
<span className="text-xs text-text-tertiary">{formatTimestamp(sp.timestamp)}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -427,10 +690,12 @@ export default function BrandVideoReviewPage() {
|
|||||||
已检查 {Object.values(checkedViolations).filter(Boolean).length}/{task.hardViolations.length} 个问题
|
已检查 {Object.values(checkedViolations).filter(Boolean).length}/{task.hardViolations.length} 个问题
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Button variant="danger" onClick={() => setShowRejectModal(true)}>
|
<Button variant="danger" onClick={() => setShowRejectModal(true)} disabled={submitting}>
|
||||||
|
{submitting ? <Loader2 size={16} className="animate-spin mr-1" /> : null}
|
||||||
驳回
|
驳回
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="success" onClick={() => setShowApproveModal(true)}>
|
<Button variant="success" onClick={() => setShowApproveModal(true)} disabled={submitting}>
|
||||||
|
{submitting ? <Loader2 size={16} className="animate-spin mr-1" /> : null}
|
||||||
通过
|
通过
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -445,7 +710,7 @@ export default function BrandVideoReviewPage() {
|
|||||||
onConfirm={handleApprove}
|
onConfirm={handleApprove}
|
||||||
title="确认通过"
|
title="确认通过"
|
||||||
message="确定要通过此视频的审核吗?通过后达人将收到通知。"
|
message="确定要通过此视频的审核吗?通过后达人将收到通知。"
|
||||||
confirmText="确认通过"
|
confirmText={submitting ? '提交中...' : '确认通过'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 驳回弹窗 */}
|
{/* 驳回弹窗 */}
|
||||||
@ -455,7 +720,7 @@ export default function BrandVideoReviewPage() {
|
|||||||
<div className="p-3 bg-bg-elevated rounded-lg">
|
<div className="p-3 bg-bg-elevated rounded-lg">
|
||||||
<p className="text-sm font-medium text-text-primary mb-2">已选问题 ({Object.values(checkedViolations).filter(Boolean).length})</p>
|
<p className="text-sm font-medium text-text-primary mb-2">已选问题 ({Object.values(checkedViolations).filter(Boolean).length})</p>
|
||||||
{task.hardViolations.filter(v => checkedViolations[v.id]).map(v => (
|
{task.hardViolations.filter(v => checkedViolations[v.id]).map(v => (
|
||||||
<div key={v.id} className="text-sm text-text-secondary">• {v.type}: {v.content}</div>
|
<div key={v.id} className="text-sm text-text-secondary">- {v.type}: {v.content}</div>
|
||||||
))}
|
))}
|
||||||
{Object.values(checkedViolations).filter(Boolean).length === 0 && (
|
{Object.values(checkedViolations).filter(Boolean).length === 0 && (
|
||||||
<div className="text-sm text-text-tertiary">未选择任何问题</div>
|
<div className="text-sm text-text-tertiary">未选择任何问题</div>
|
||||||
@ -471,8 +736,11 @@ export default function BrandVideoReviewPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 justify-end">
|
<div className="flex gap-3 justify-end">
|
||||||
<Button variant="ghost" onClick={() => setShowRejectModal(false)}>取消</Button>
|
<Button variant="ghost" onClick={() => setShowRejectModal(false)} disabled={submitting}>取消</Button>
|
||||||
<Button variant="danger" onClick={handleReject}>确认驳回</Button>
|
<Button variant="danger" onClick={handleReject} disabled={submitting}>
|
||||||
|
{submitting ? <Loader2 size={16} className="animate-spin mr-1" /> : null}
|
||||||
|
确认驳回
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@ -1,13 +1,33 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { Plus, Shield, Ban, Building2, Search, X, Upload, Trash2, FileText, Check, Download, Eye } from 'lucide-react'
|
import { Plus, Shield, Ban, Building2, Search, X, Upload, Trash2, FileText, Download, Eye, Loader2 } from 'lucide-react'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Modal } from '@/components/ui/Modal'
|
import { Modal } from '@/components/ui/Modal'
|
||||||
|
import { useToast } from '@/components/ui/Toast'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
|
import type {
|
||||||
|
ForbiddenWordResponse,
|
||||||
|
CompetitorResponse,
|
||||||
|
WhitelistResponse,
|
||||||
|
PlatformRuleResponse,
|
||||||
|
} from '@/types/rules'
|
||||||
|
|
||||||
// 平台规则库数据
|
// ===== 平台规则库 mock 数据 (USE_MOCK 模式) =====
|
||||||
const platformRuleLibraries = [
|
|
||||||
|
interface PlatformRuleDisplay {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
icon: string
|
||||||
|
color: string
|
||||||
|
rules: { forbiddenWords: number; competitors: number; whitelist: number }
|
||||||
|
version: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformRuleLibraries: PlatformRuleDisplay[] = [
|
||||||
{
|
{
|
||||||
id: 'douyin',
|
id: 'douyin',
|
||||||
name: '抖音',
|
name: '抖音',
|
||||||
@ -55,27 +75,39 @@ const platformRuleLibraries = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
// 模拟规则数据
|
// ===== Mock 数据 (USE_MOCK 模式) =====
|
||||||
const initialRules = {
|
|
||||||
forbiddenWords: [
|
const mockForbiddenWords: ForbiddenWordResponse[] = [
|
||||||
{ id: '1', word: '最好', category: '极限词' },
|
{ id: '1', word: '最好', category: '极限词', severity: 'high' },
|
||||||
{ id: '2', word: '第一', category: '极限词' },
|
{ id: '2', word: '第一', category: '极限词', severity: 'high' },
|
||||||
{ id: '3', word: '最佳', category: '极限词' },
|
{ id: '3', word: '最佳', category: '极限词', severity: 'high' },
|
||||||
{ id: '4', word: '100%有效', category: '虚假宣称' },
|
{ id: '4', word: '100%有效', category: '虚假宣称', severity: 'critical' },
|
||||||
{ id: '5', word: '立即见效', category: '虚假宣称' },
|
{ id: '5', word: '立即见效', category: '虚假宣称', severity: 'critical' },
|
||||||
{ id: '6', word: '永久', category: '极限词' },
|
{ id: '6', word: '永久', category: '极限词', severity: 'medium' },
|
||||||
{ id: '7', word: '绝对', category: '极限词' },
|
{ id: '7', word: '绝对', category: '极限词', severity: 'medium' },
|
||||||
{ id: '8', word: '最低价', category: '价格欺诈' },
|
{ id: '8', word: '最低价', category: '价格欺诈', severity: 'high' },
|
||||||
],
|
]
|
||||||
competitors: [
|
|
||||||
{ id: '1', name: '竞品A' },
|
const mockCompetitors: CompetitorResponse[] = [
|
||||||
{ id: '2', name: '竞品B' },
|
{ id: '1', name: '竞品A', brand_id: '', keywords: ['竞品A', '品牌A'] },
|
||||||
{ id: '3', name: '竞品C' },
|
{ id: '2', name: '竞品B', brand_id: '', keywords: ['竞品B', '品牌B'] },
|
||||||
],
|
{ id: '3', name: '竞品C', brand_id: '', keywords: ['竞品C', '品牌C'] },
|
||||||
whitelist: [
|
]
|
||||||
{ id: '1', term: '品牌专属术语1', reason: '品牌授权使用' },
|
|
||||||
{ id: '2', term: '特定产品名', reason: '官方产品名称' },
|
const mockWhitelist: WhitelistResponse[] = [
|
||||||
],
|
{ id: '1', term: '品牌专属术语1', reason: '品牌授权使用', brand_id: '' },
|
||||||
|
{ id: '2', term: '特定产品名', reason: '官方产品名称', brand_id: '' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// ===== 平台图标映射 (用于 API 模式下的平台展示) =====
|
||||||
|
|
||||||
|
const platformDisplayMap: Record<string, { icon: string; color: string; name: string }> = {
|
||||||
|
douyin: { icon: '🎵', color: 'bg-[#25F4EE]', name: '抖音' },
|
||||||
|
xiaohongshu: { icon: '📕', color: 'bg-[#fe2c55]', name: '小红书' },
|
||||||
|
bilibili: { icon: '📺', color: 'bg-[#00a1d6]', name: 'B站' },
|
||||||
|
kuaishou: { icon: '⚡', color: 'bg-[#ff4906]', name: '快手' },
|
||||||
|
weibo: { icon: '🔴', color: 'bg-[#e6162d]', name: '微博' },
|
||||||
|
wechat: { icon: '📱', color: 'bg-[#07c160]', name: '微信视频号' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoryOptions = [
|
const categoryOptions = [
|
||||||
@ -86,11 +118,109 @@ const categoryOptions = [
|
|||||||
{ value: '自定义', label: '自定义' },
|
{ value: '自定义', label: '自定义' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// ===== 将 PlatformRuleResponse 转换为 PlatformRuleDisplay =====
|
||||||
|
|
||||||
|
function toPlatformDisplay(rule: PlatformRuleResponse): PlatformRuleDisplay {
|
||||||
|
const display = platformDisplayMap[rule.platform] || {
|
||||||
|
icon: '📋',
|
||||||
|
color: 'bg-gray-400',
|
||||||
|
name: rule.platform,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: rule.platform,
|
||||||
|
name: display.name,
|
||||||
|
icon: display.icon,
|
||||||
|
color: display.color,
|
||||||
|
rules: {
|
||||||
|
forbiddenWords: Array.isArray(rule.rules) ? rule.rules.length : 0,
|
||||||
|
competitors: 0,
|
||||||
|
whitelist: 0,
|
||||||
|
},
|
||||||
|
version: rule.version,
|
||||||
|
updatedAt: rule.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Loading Skeleton 组件 =====
|
||||||
|
|
||||||
|
function CardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="animate-pulse space-y-4">
|
||||||
|
<div className="h-6 bg-bg-elevated rounded w-1/4" />
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="p-4 rounded-xl border border-border-subtle">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="w-10 h-10 bg-bg-elevated rounded-xl" />
|
||||||
|
<div className="space-y-2 flex-1">
|
||||||
|
<div className="h-4 bg-bg-elevated rounded w-1/2" />
|
||||||
|
<div className="h-3 bg-bg-elevated rounded w-1/3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-3 bg-bg-elevated rounded w-2/3 mb-3" />
|
||||||
|
<div className="h-3 bg-bg-elevated rounded w-1/2" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function WordsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="animate-pulse space-y-4">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="h-10 bg-bg-elevated rounded-xl flex-1 max-w-md" />
|
||||||
|
<div className="h-10 bg-bg-elevated rounded-xl w-32" />
|
||||||
|
</div>
|
||||||
|
{[1, 2].map((group) => (
|
||||||
|
<div key={group} className="space-y-2">
|
||||||
|
<div className="h-4 bg-bg-elevated rounded w-20" />
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<div key={i} className="h-8 bg-bg-elevated rounded-lg w-20" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListSkeleton({ count = 3 }: { count?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="animate-pulse space-y-3">
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between p-4 rounded-xl border border-border-subtle">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-bg-elevated rounded-lg" />
|
||||||
|
<div className="h-4 bg-bg-elevated rounded w-24" />
|
||||||
|
</div>
|
||||||
|
<div className="w-8 h-8 bg-bg-elevated rounded-lg" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 主组件 =====
|
||||||
|
|
||||||
export default function RulesPage() {
|
export default function RulesPage() {
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
// Tab 选择
|
||||||
const [activeTab, setActiveTab] = useState<'platforms' | 'forbidden' | 'competitors' | 'whitelist'>('platforms')
|
const [activeTab, setActiveTab] = useState<'platforms' | 'forbidden' | 'competitors' | 'whitelist'>('platforms')
|
||||||
const [rules, setRules] = useState(initialRules)
|
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [platforms, setPlatforms] = useState(platformRuleLibraries)
|
|
||||||
|
// 数据状态
|
||||||
|
const [forbiddenWords, setForbiddenWords] = useState<ForbiddenWordResponse[]>([])
|
||||||
|
const [competitors, setCompetitors] = useState<CompetitorResponse[]>([])
|
||||||
|
const [whitelist, setWhitelist] = useState<WhitelistResponse[]>([])
|
||||||
|
const [platforms, setPlatforms] = useState<PlatformRuleDisplay[]>([])
|
||||||
|
|
||||||
|
// 加载状态
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
// 上传规则库
|
// 上传规则库
|
||||||
const [showUploadModal, setShowUploadModal] = useState(false)
|
const [showUploadModal, setShowUploadModal] = useState(false)
|
||||||
@ -99,15 +229,15 @@ export default function RulesPage() {
|
|||||||
|
|
||||||
// 重新上传规则库
|
// 重新上传规则库
|
||||||
const [showReuploadModal, setShowReuploadModal] = useState(false)
|
const [showReuploadModal, setShowReuploadModal] = useState(false)
|
||||||
const [reuploadPlatform, setReuploadPlatform] = useState<typeof platformRuleLibraries[0] | null>(null)
|
const [reuploadPlatform, setReuploadPlatform] = useState<PlatformRuleDisplay | null>(null)
|
||||||
|
|
||||||
// 下载确认
|
// 下载确认
|
||||||
const [showDownloadModal, setShowDownloadModal] = useState(false)
|
const [showDownloadModal, setShowDownloadModal] = useState(false)
|
||||||
const [downloadPlatform, setDownloadPlatform] = useState<typeof platformRuleLibraries[0] | null>(null)
|
const [downloadPlatform, setDownloadPlatform] = useState<PlatformRuleDisplay | null>(null)
|
||||||
|
|
||||||
// 查看规则库详情
|
// 查看规则库详情
|
||||||
const [showDetailModal, setShowDetailModal] = useState(false)
|
const [showDetailModal, setShowDetailModal] = useState(false)
|
||||||
const [selectedPlatform, setSelectedPlatform] = useState<typeof platformRuleLibraries[0] | null>(null)
|
const [selectedPlatform, setSelectedPlatform] = useState<PlatformRuleDisplay | null>(null)
|
||||||
|
|
||||||
// 添加违禁词
|
// 添加违禁词
|
||||||
const [showAddWordModal, setShowAddWordModal] = useState(false)
|
const [showAddWordModal, setShowAddWordModal] = useState(false)
|
||||||
@ -124,113 +254,263 @@ export default function RulesPage() {
|
|||||||
const [newWhitelistTerm, setNewWhitelistTerm] = useState('')
|
const [newWhitelistTerm, setNewWhitelistTerm] = useState('')
|
||||||
const [newWhitelistReason, setNewWhitelistReason] = useState('')
|
const [newWhitelistReason, setNewWhitelistReason] = useState('')
|
||||||
|
|
||||||
// 过滤违禁词
|
// ===== 数据加载 =====
|
||||||
const filteredWords = rules.forbiddenWords.filter(w =>
|
|
||||||
|
const loadForbiddenWords = useCallback(async () => {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
setForbiddenWords(mockForbiddenWords)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await api.listForbiddenWords()
|
||||||
|
setForbiddenWords(res.items)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('加载违禁词失败:' + (err instanceof Error ? err.message : '未知错误'))
|
||||||
|
}
|
||||||
|
}, [toast])
|
||||||
|
|
||||||
|
const loadCompetitors = useCallback(async () => {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
setCompetitors(mockCompetitors)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await api.listCompetitors()
|
||||||
|
setCompetitors(res.items)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('加载竞品列表失败:' + (err instanceof Error ? err.message : '未知错误'))
|
||||||
|
}
|
||||||
|
}, [toast])
|
||||||
|
|
||||||
|
const loadWhitelist = useCallback(async () => {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
setWhitelist(mockWhitelist)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await api.listWhitelist()
|
||||||
|
setWhitelist(res.items)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('加载白名单失败:' + (err instanceof Error ? err.message : '未知错误'))
|
||||||
|
}
|
||||||
|
}, [toast])
|
||||||
|
|
||||||
|
const loadPlatformRules = useCallback(async () => {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
setPlatforms(platformRuleLibraries)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await api.listPlatformRules()
|
||||||
|
setPlatforms(res.items.map(toPlatformDisplay))
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('加载平台规则失败:' + (err instanceof Error ? err.message : '未知错误'))
|
||||||
|
}
|
||||||
|
}, [toast])
|
||||||
|
|
||||||
|
const loadAllData = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
await Promise.all([
|
||||||
|
loadForbiddenWords(),
|
||||||
|
loadCompetitors(),
|
||||||
|
loadWhitelist(),
|
||||||
|
loadPlatformRules(),
|
||||||
|
])
|
||||||
|
setLoading(false)
|
||||||
|
}, [loadForbiddenWords, loadCompetitors, loadWhitelist, loadPlatformRules])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAllData()
|
||||||
|
}, [loadAllData])
|
||||||
|
|
||||||
|
// ===== 过滤违禁词 =====
|
||||||
|
|
||||||
|
const filteredWords = forbiddenWords.filter(w =>
|
||||||
searchQuery === '' ||
|
searchQuery === '' ||
|
||||||
w.word.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
w.word.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
w.category.toLowerCase().includes(searchQuery.toLowerCase())
|
w.category.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
)
|
)
|
||||||
|
|
||||||
// 查看平台详情
|
// ===== 平台操作 =====
|
||||||
const viewPlatformDetail = (platform: typeof platformRuleLibraries[0]) => {
|
|
||||||
|
const viewPlatformDetail = (platform: PlatformRuleDisplay) => {
|
||||||
setSelectedPlatform(platform)
|
setSelectedPlatform(platform)
|
||||||
setShowDetailModal(true)
|
setShowDetailModal(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重新上传
|
const handleReupload = (platform: PlatformRuleDisplay) => {
|
||||||
const handleReupload = (platform: typeof platformRuleLibraries[0]) => {
|
|
||||||
setReuploadPlatform(platform)
|
setReuploadPlatform(platform)
|
||||||
setShowReuploadModal(true)
|
setShowReuploadModal(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 下载规则
|
const handleDownload = (platform: PlatformRuleDisplay) => {
|
||||||
const handleDownload = (platform: typeof platformRuleLibraries[0]) => {
|
|
||||||
setDownloadPlatform(platform)
|
setDownloadPlatform(platform)
|
||||||
setShowDownloadModal(true)
|
setShowDownloadModal(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加单个违禁词
|
// ===== 违禁词操作 =====
|
||||||
const handleAddWord = () => {
|
|
||||||
|
const handleAddWord = async () => {
|
||||||
if (!newWord.trim()) return
|
if (!newWord.trim()) return
|
||||||
setRules({
|
setSubmitting(true)
|
||||||
...rules,
|
try {
|
||||||
forbiddenWords: [
|
if (USE_MOCK) {
|
||||||
...rules.forbiddenWords,
|
const newItem: ForbiddenWordResponse = {
|
||||||
{ id: Date.now().toString(), word: newWord.trim(), category: newCategory }
|
id: Date.now().toString(),
|
||||||
]
|
word: newWord.trim(),
|
||||||
})
|
category: newCategory,
|
||||||
|
severity: 'medium',
|
||||||
|
}
|
||||||
|
setForbiddenWords(prev => [...prev, newItem])
|
||||||
|
} else {
|
||||||
|
await api.addForbiddenWord({ word: newWord.trim(), category: newCategory, severity: 'medium' })
|
||||||
|
await loadForbiddenWords()
|
||||||
|
}
|
||||||
|
toast.success('违禁词添加成功')
|
||||||
setNewWord('')
|
setNewWord('')
|
||||||
setShowAddWordModal(false)
|
setShowAddWordModal(false)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('添加违禁词失败:' + (err instanceof Error ? err.message : '未知错误'))
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 批量添加违禁词
|
const handleBatchAdd = async () => {
|
||||||
const handleBatchAdd = () => {
|
|
||||||
const words = batchWords.split('\n').filter(w => w.trim())
|
const words = batchWords.split('\n').filter(w => w.trim())
|
||||||
if (words.length === 0) return
|
if (words.length === 0) return
|
||||||
const newWords = words.map((word, i) => ({
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
const newWords: ForbiddenWordResponse[] = words.map((word, i) => ({
|
||||||
id: `${Date.now()}-${i}`,
|
id: `${Date.now()}-${i}`,
|
||||||
word: word.trim(),
|
word: word.trim(),
|
||||||
category: newCategory
|
category: newCategory,
|
||||||
|
severity: 'medium',
|
||||||
}))
|
}))
|
||||||
setRules({
|
setForbiddenWords(prev => [...prev, ...newWords])
|
||||||
...rules,
|
} else {
|
||||||
forbiddenWords: [...rules.forbiddenWords, ...newWords]
|
for (const word of words) {
|
||||||
})
|
await api.addForbiddenWord({ word: word.trim(), category: newCategory, severity: 'medium' })
|
||||||
|
}
|
||||||
|
await loadForbiddenWords()
|
||||||
|
}
|
||||||
|
toast.success(`成功添加 ${words.length} 个违禁词`)
|
||||||
setBatchWords('')
|
setBatchWords('')
|
||||||
setShowAddWordModal(false)
|
setShowAddWordModal(false)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('批量添加违禁词失败:' + (err instanceof Error ? err.message : '未知错误'))
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除违禁词
|
const handleDeleteWord = async (id: string) => {
|
||||||
const handleDeleteWord = (id: string) => {
|
setSubmitting(true)
|
||||||
setRules({
|
try {
|
||||||
...rules,
|
if (USE_MOCK) {
|
||||||
forbiddenWords: rules.forbiddenWords.filter(w => w.id !== id)
|
setForbiddenWords(prev => prev.filter(w => w.id !== id))
|
||||||
})
|
} else {
|
||||||
|
await api.deleteForbiddenWord(id)
|
||||||
|
await loadForbiddenWords()
|
||||||
|
}
|
||||||
|
toast.success('违禁词已删除')
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('删除违禁词失败:' + (err instanceof Error ? err.message : '未知错误'))
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加竞品
|
// ===== 竞品操作 =====
|
||||||
const handleAddCompetitor = () => {
|
|
||||||
|
const handleAddCompetitor = async () => {
|
||||||
if (!newCompetitor.trim()) return
|
if (!newCompetitor.trim()) return
|
||||||
setRules({
|
setSubmitting(true)
|
||||||
...rules,
|
try {
|
||||||
competitors: [
|
if (USE_MOCK) {
|
||||||
...rules.competitors,
|
const newItem: CompetitorResponse = {
|
||||||
{ id: Date.now().toString(), name: newCompetitor.trim() }
|
id: Date.now().toString(),
|
||||||
]
|
name: newCompetitor.trim(),
|
||||||
})
|
brand_id: '',
|
||||||
|
keywords: [newCompetitor.trim()],
|
||||||
|
}
|
||||||
|
setCompetitors(prev => [...prev, newItem])
|
||||||
|
} else {
|
||||||
|
await api.addCompetitor({ name: newCompetitor.trim(), brand_id: '', keywords: [newCompetitor.trim()] })
|
||||||
|
await loadCompetitors()
|
||||||
|
}
|
||||||
|
toast.success('竞品添加成功')
|
||||||
setNewCompetitor('')
|
setNewCompetitor('')
|
||||||
setShowAddCompetitorModal(false)
|
setShowAddCompetitorModal(false)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('添加竞品失败:' + (err instanceof Error ? err.message : '未知错误'))
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除竞品
|
const handleDeleteCompetitor = async (id: string) => {
|
||||||
const handleDeleteCompetitor = (id: string) => {
|
setSubmitting(true)
|
||||||
setRules({
|
try {
|
||||||
...rules,
|
if (USE_MOCK) {
|
||||||
competitors: rules.competitors.filter(c => c.id !== id)
|
setCompetitors(prev => prev.filter(c => c.id !== id))
|
||||||
})
|
} else {
|
||||||
|
await api.deleteCompetitor(id)
|
||||||
|
await loadCompetitors()
|
||||||
|
}
|
||||||
|
toast.success('竞品已删除')
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('删除竞品失败:' + (err instanceof Error ? err.message : '未知错误'))
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加白名单
|
// ===== 白名单操作 =====
|
||||||
const handleAddWhitelist = () => {
|
|
||||||
|
const handleAddWhitelist = async () => {
|
||||||
if (!newWhitelistTerm.trim()) return
|
if (!newWhitelistTerm.trim()) return
|
||||||
setRules({
|
setSubmitting(true)
|
||||||
...rules,
|
try {
|
||||||
whitelist: [
|
if (USE_MOCK) {
|
||||||
...rules.whitelist,
|
const newItem: WhitelistResponse = {
|
||||||
{ id: Date.now().toString(), term: newWhitelistTerm.trim(), reason: newWhitelistReason.trim() }
|
id: Date.now().toString(),
|
||||||
]
|
term: newWhitelistTerm.trim(),
|
||||||
})
|
reason: newWhitelistReason.trim(),
|
||||||
|
brand_id: '',
|
||||||
|
}
|
||||||
|
setWhitelist(prev => [...prev, newItem])
|
||||||
|
} else {
|
||||||
|
await api.addToWhitelist({ term: newWhitelistTerm.trim(), reason: newWhitelistReason.trim(), brand_id: '' })
|
||||||
|
await loadWhitelist()
|
||||||
|
}
|
||||||
|
toast.success('白名单添加成功')
|
||||||
setNewWhitelistTerm('')
|
setNewWhitelistTerm('')
|
||||||
setNewWhitelistReason('')
|
setNewWhitelistReason('')
|
||||||
setShowAddWhitelistModal(false)
|
setShowAddWhitelistModal(false)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('添加白名单失败:' + (err instanceof Error ? err.message : '未知错误'))
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除白名单
|
const handleDeleteWhitelist = async (id: string) => {
|
||||||
const handleDeleteWhitelist = (id: string) => {
|
setSubmitting(true)
|
||||||
setRules({
|
try {
|
||||||
...rules,
|
if (USE_MOCK) {
|
||||||
whitelist: rules.whitelist.filter(w => w.id !== id)
|
setWhitelist(prev => prev.filter(w => w.id !== id))
|
||||||
})
|
} else {
|
||||||
|
// 白名单目前没有 delete API,本地移除
|
||||||
|
setWhitelist(prev => prev.filter(w => w.id !== id))
|
||||||
|
}
|
||||||
|
toast.success('白名单已删除')
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('删除白名单失败:' + (err instanceof Error ? err.message : '未知错误'))
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -269,7 +549,7 @@ export default function RulesPage() {
|
|||||||
<Ban size={16} />
|
<Ban size={16} />
|
||||||
自定义违禁词
|
自定义违禁词
|
||||||
<span className="px-2 py-0.5 rounded-full bg-accent-coral/15 text-accent-coral text-xs">
|
<span className="px-2 py-0.5 rounded-full bg-accent-coral/15 text-accent-coral text-xs">
|
||||||
{rules.forbiddenWords.length}
|
{forbiddenWords.length}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@ -284,7 +564,7 @@ export default function RulesPage() {
|
|||||||
<Building2 size={16} />
|
<Building2 size={16} />
|
||||||
竞品列表
|
竞品列表
|
||||||
<span className="px-2 py-0.5 rounded-full bg-accent-amber/15 text-accent-amber text-xs">
|
<span className="px-2 py-0.5 rounded-full bg-accent-amber/15 text-accent-amber text-xs">
|
||||||
{rules.competitors.length}
|
{competitors.length}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@ -299,7 +579,7 @@ export default function RulesPage() {
|
|||||||
<Shield size={16} />
|
<Shield size={16} />
|
||||||
白名单
|
白名单
|
||||||
<span className="px-2 py-0.5 rounded-full bg-accent-green/15 text-accent-green text-xs">
|
<span className="px-2 py-0.5 rounded-full bg-accent-green/15 text-accent-green text-xs">
|
||||||
{rules.whitelist.length}
|
{whitelist.length}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -314,6 +594,9 @@ export default function RulesPage() {
|
|||||||
</p>
|
</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<CardSkeleton />
|
||||||
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{platforms.map((platform) => (
|
{platforms.map((platform) => (
|
||||||
<div
|
<div
|
||||||
@ -378,6 +661,7 @@ export default function RulesPage() {
|
|||||||
<span className="text-xs">支持 JSON / Excel 格式</span>
|
<span className="text-xs">支持 JSON / Excel 格式</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@ -390,6 +674,10 @@ export default function RulesPage() {
|
|||||||
<p className="text-sm text-text-tertiary mt-1">在平台规则库基础上,添加品牌专属的违禁词规则</p>
|
<p className="text-sm text-text-tertiary mt-1">在平台规则库基础上,添加品牌专属的违禁词规则</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
{loading ? (
|
||||||
|
<WordsSkeleton />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{/* 搜索框和添加按钮 */}
|
{/* 搜索框和添加按钮 */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="relative flex-1 max-w-md">
|
<div className="relative flex-1 max-w-md">
|
||||||
@ -436,9 +724,10 @@ export default function RulesPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleDeleteWord(word.id)}
|
onClick={() => handleDeleteWord(word.id)}
|
||||||
className="text-text-tertiary hover:text-accent-coral transition-colors"
|
disabled={submitting}
|
||||||
|
className="text-text-tertiary hover:text-accent-coral transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<X size={14} />
|
{submitting ? <Loader2 size={14} className="animate-spin" /> : <X size={14} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -453,6 +742,8 @@ export default function RulesPage() {
|
|||||||
<p>暂无自定义违禁词</p>
|
<p>暂无自定义违禁词</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@ -465,8 +756,11 @@ export default function RulesPage() {
|
|||||||
<p className="text-sm text-text-tertiary mt-1">系统将在视频中检测以下竞品的 Logo 或品牌名称</p>
|
<p className="text-sm text-text-tertiary mt-1">系统将在视频中检测以下竞品的 Logo 或品牌名称</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<ListSkeleton count={3} />
|
||||||
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
{rules.competitors.map((competitor) => (
|
{competitors.map((competitor) => (
|
||||||
<div key={competitor.id} className="p-4 rounded-xl bg-bg-elevated border border-border-subtle flex items-center justify-between group hover:border-accent-amber/50">
|
<div key={competitor.id} className="p-4 rounded-xl bg-bg-elevated border border-border-subtle flex items-center justify-between group hover:border-accent-amber/50">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 bg-accent-amber/15 rounded-lg flex items-center justify-center">
|
<div className="w-10 h-10 bg-accent-amber/15 rounded-lg flex items-center justify-center">
|
||||||
@ -477,9 +771,10 @@ export default function RulesPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleDeleteCompetitor(competitor.id)}
|
onClick={() => handleDeleteCompetitor(competitor.id)}
|
||||||
className="p-2 rounded-lg text-text-tertiary hover:text-accent-coral hover:bg-accent-coral/10 transition-colors"
|
disabled={submitting}
|
||||||
|
className="p-2 rounded-lg text-text-tertiary hover:text-accent-coral hover:bg-accent-coral/10 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
{submitting ? <Loader2 size={16} className="animate-spin" /> : <Trash2 size={16} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -494,6 +789,7 @@ export default function RulesPage() {
|
|||||||
<span className="font-medium">添加竞品</span>
|
<span className="font-medium">添加竞品</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@ -506,8 +802,11 @@ export default function RulesPage() {
|
|||||||
<p className="text-sm text-text-tertiary mt-1">白名单中的词汇即使命中违禁词也不会触发告警</p>
|
<p className="text-sm text-text-tertiary mt-1">白名单中的词汇即使命中违禁词也不会触发告警</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<ListSkeleton count={2} />
|
||||||
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{rules.whitelist.map((item) => (
|
{whitelist.map((item) => (
|
||||||
<div key={item.id} className="flex items-center justify-between p-4 rounded-xl bg-bg-elevated border border-border-subtle hover:border-accent-green/50">
|
<div key={item.id} className="flex items-center justify-between p-4 rounded-xl bg-bg-elevated border border-border-subtle hover:border-accent-green/50">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-text-primary">{item.term}</p>
|
<p className="font-medium text-text-primary">{item.term}</p>
|
||||||
@ -516,9 +815,10 @@ export default function RulesPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleDeleteWhitelist(item.id)}
|
onClick={() => handleDeleteWhitelist(item.id)}
|
||||||
className="p-2 rounded-lg text-text-tertiary hover:text-accent-coral hover:bg-accent-coral/10 transition-colors"
|
disabled={submitting}
|
||||||
|
className="p-2 rounded-lg text-text-tertiary hover:text-accent-coral hover:bg-accent-coral/10 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
{submitting ? <Loader2 size={16} className="animate-spin" /> : <Trash2 size={16} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -533,6 +833,7 @@ export default function RulesPage() {
|
|||||||
<span className="font-medium">添加白名单</span>
|
<span className="font-medium">添加白名单</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@ -574,9 +875,9 @@ export default function RulesPage() {
|
|||||||
<div className="p-4 rounded-xl bg-accent-indigo/10 border border-accent-indigo/20">
|
<div className="p-4 rounded-xl bg-accent-indigo/10 border border-accent-indigo/20">
|
||||||
<h4 className="text-sm font-medium text-accent-indigo mb-2">文件格式说明</h4>
|
<h4 className="text-sm font-medium text-accent-indigo mb-2">文件格式说明</h4>
|
||||||
<ul className="text-xs text-text-secondary space-y-1">
|
<ul className="text-xs text-text-secondary space-y-1">
|
||||||
<li>• JSON 格式:包含 forbiddenWords、whitelist 字段的对象</li>
|
<li>JSON 格式:包含 forbiddenWords、whitelist 字段的对象</li>
|
||||||
<li>• Excel 格式:第一列为词汇,第二列为分类(可选)</li>
|
<li>Excel 格式:第一列为词汇,第二列为分类(可选)</li>
|
||||||
<li>• Word / PDF:AI 将自动识别并提取规则内容</li>
|
<li>Word / PDF:AI 将自动识别并提取规则内容</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -687,7 +988,7 @@ export default function RulesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 rounded-xl bg-accent-amber/10 border border-accent-amber/20">
|
<div className="p-4 rounded-xl bg-accent-amber/10 border border-accent-amber/20">
|
||||||
<p className="text-sm text-accent-amber font-medium mb-1">⚠️ 注意</p>
|
<p className="text-sm text-accent-amber font-medium mb-1">注意</p>
|
||||||
<p className="text-xs text-text-secondary">重新上传将覆盖当前规则库,此操作不可撤销</p>
|
<p className="text-xs text-text-secondary">重新上传将覆盖当前规则库,此操作不可撤销</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -847,8 +1148,8 @@ export default function RulesPage() {
|
|||||||
placeholder="输入违禁词"
|
placeholder="输入违禁词"
|
||||||
className="flex-1 px-4 py-2.5 border border-border-subtle rounded-xl bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
|
className="flex-1 px-4 py-2.5 border border-border-subtle rounded-xl bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
|
||||||
/>
|
/>
|
||||||
<Button onClick={handleAddWord} disabled={!newWord.trim()}>
|
<Button onClick={handleAddWord} disabled={!newWord.trim() || submitting}>
|
||||||
添加
|
{submitting ? <Loader2 size={16} className="animate-spin" /> : '添加'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -875,8 +1176,13 @@ export default function RulesPage() {
|
|||||||
<span className="text-xs text-text-tertiary">
|
<span className="text-xs text-text-tertiary">
|
||||||
{batchWords.split('\n').filter(w => w.trim()).length} 个词汇待添加
|
{batchWords.split('\n').filter(w => w.trim()).length} 个词汇待添加
|
||||||
</span>
|
</span>
|
||||||
<Button onClick={handleBatchAdd} disabled={!batchWords.trim()}>
|
<Button onClick={handleBatchAdd} disabled={!batchWords.trim() || submitting}>
|
||||||
批量添加
|
{submitting ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
添加中...
|
||||||
|
</span>
|
||||||
|
) : '批量添加'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -907,8 +1213,13 @@ export default function RulesPage() {
|
|||||||
<Button variant="ghost" onClick={() => { setShowAddCompetitorModal(false); setNewCompetitor(''); }}>
|
<Button variant="ghost" onClick={() => { setShowAddCompetitorModal(false); setNewCompetitor(''); }}>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleAddCompetitor} disabled={!newCompetitor.trim()}>
|
<Button onClick={handleAddCompetitor} disabled={!newCompetitor.trim() || submitting}>
|
||||||
添加
|
{submitting ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
添加中...
|
||||||
|
</span>
|
||||||
|
) : '添加'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -945,8 +1256,13 @@ export default function RulesPage() {
|
|||||||
<Button variant="ghost" onClick={() => { setShowAddWhitelistModal(false); setNewWhitelistTerm(''); setNewWhitelistReason(''); }}>
|
<Button variant="ghost" onClick={() => { setShowAddWhitelistModal(false); setNewWhitelistTerm(''); setNewWhitelistReason(''); }}>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleAddWhitelist} disabled={!newWhitelistTerm.trim()}>
|
<Button onClick={handleAddWhitelist} disabled={!newWhitelistTerm.trim() || submitting}>
|
||||||
添加
|
{submitting ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
添加中...
|
||||||
|
</span>
|
||||||
|
) : '添加'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, { useState } from 'react'
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@ -10,10 +10,15 @@ import {
|
|||||||
XCircle,
|
XCircle,
|
||||||
Send,
|
Send,
|
||||||
Info,
|
Info,
|
||||||
|
Loader2
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
|
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
|
import { useToast } from '@/components/ui/Toast'
|
||||||
|
import type { TaskResponse } from '@/types/task'
|
||||||
|
|
||||||
// 申请状态类型
|
// 申请状态类型
|
||||||
type RequestStatus = 'none' | 'pending' | 'approved' | 'rejected'
|
type RequestStatus = 'none' | 'pending' | 'approved' | 'rejected'
|
||||||
@ -68,6 +73,28 @@ const mockTaskQuotas: TaskAppealQuota[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// 将 TaskResponse 映射为 TaskAppealQuota
|
||||||
|
function mapTaskToQuota(task: TaskResponse): TaskAppealQuota {
|
||||||
|
// Default quota is 1 per task
|
||||||
|
const defaultQuota = 1
|
||||||
|
const remaining = Math.max(0, defaultQuota - task.appeal_count)
|
||||||
|
|
||||||
|
// Determine request status based on task state
|
||||||
|
let requestStatus: RequestStatus = 'none'
|
||||||
|
if (task.is_appeal && task.appeal_count > 0) {
|
||||||
|
requestStatus = 'pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
taskName: task.name,
|
||||||
|
agencyName: task.agency?.name || '未知代理商',
|
||||||
|
remaining,
|
||||||
|
used: task.appeal_count,
|
||||||
|
requestStatus,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 状态标签组件
|
// 状态标签组件
|
||||||
function StatusBadge({ status }: { status: RequestStatus }) {
|
function StatusBadge({ status }: { status: RequestStatus }) {
|
||||||
const config = {
|
const config = {
|
||||||
@ -101,13 +128,43 @@ function StatusBadge({ status }: { status: RequestStatus }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 骨架屏组件
|
||||||
|
function QuotaSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="bg-bg-card rounded-xl p-5 card-shadow flex flex-col gap-4 animate-pulse">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="h-4 w-32 bg-bg-elevated rounded" />
|
||||||
|
<div className="h-3 w-20 bg-bg-elevated rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="h-5 w-14 bg-bg-elevated rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="h-7 w-8 bg-bg-elevated rounded" />
|
||||||
|
<div className="h-3 w-14 bg-bg-elevated rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="h-7 w-8 bg-bg-elevated rounded" />
|
||||||
|
<div className="h-3 w-14 bg-bg-elevated rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pt-3 border-t border-border-subtle">
|
||||||
|
<div className="h-8 w-24 bg-bg-elevated rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 任务卡片组件
|
// 任务卡片组件
|
||||||
function TaskQuotaCard({
|
function TaskQuotaCard({
|
||||||
task,
|
task,
|
||||||
onRequestIncrease,
|
onRequestIncrease,
|
||||||
|
requesting,
|
||||||
}: {
|
}: {
|
||||||
task: TaskAppealQuota
|
task: TaskAppealQuota
|
||||||
onRequestIncrease: (taskId: string) => void
|
onRequestIncrease: (taskId: string) => void
|
||||||
|
requesting: boolean
|
||||||
}) {
|
}) {
|
||||||
const canRequest = task.requestStatus === 'none' || task.requestStatus === 'rejected'
|
const canRequest = task.requestStatus === 'none' || task.requestStatus === 'rejected'
|
||||||
|
|
||||||
@ -149,10 +206,11 @@ function TaskQuotaCard({
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onRequestIncrease(task.id)}
|
onClick={() => onRequestIncrease(task.id)}
|
||||||
|
disabled={requesting}
|
||||||
className="gap-1.5"
|
className="gap-1.5"
|
||||||
>
|
>
|
||||||
<Send size={14} />
|
{requesting ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
|
||||||
申请增加
|
{requesting ? '申请中...' : '申请增加'}
|
||||||
</Button>
|
</Button>
|
||||||
) : task.requestStatus === 'pending' ? (
|
) : task.requestStatus === 'pending' ? (
|
||||||
<span className="text-xs text-accent-amber">等待代理商处理...</span>
|
<span className="text-xs text-accent-amber">等待代理商处理...</span>
|
||||||
@ -164,11 +222,38 @@ function TaskQuotaCard({
|
|||||||
|
|
||||||
export default function AppealQuotaPage() {
|
export default function AppealQuotaPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [tasks, setTasks] = useState(mockTaskQuotas)
|
const toast = useToast()
|
||||||
const [showSuccessToast, setShowSuccessToast] = useState(false)
|
const [tasks, setTasks] = useState<TaskAppealQuota[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [requestingTaskId, setRequestingTaskId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const loadQuotas = useCallback(async () => {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
setTasks(mockTaskQuotas)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const response = await api.listTasks(1, 100)
|
||||||
|
const mapped = response.items.map(mapTaskToQuota)
|
||||||
|
setTasks(mapped)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('加载申诉次数失败:', err)
|
||||||
|
toast.error('加载申诉次数信息失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [toast])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadQuotas()
|
||||||
|
}, [loadQuotas])
|
||||||
|
|
||||||
// 申请增加申诉次数
|
// 申请增加申诉次数
|
||||||
const handleRequestIncrease = (taskId: string) => {
|
const handleRequestIncrease = async (taskId: string) => {
|
||||||
|
if (USE_MOCK) {
|
||||||
setTasks(prev =>
|
setTasks(prev =>
|
||||||
prev.map(task =>
|
prev.map(task =>
|
||||||
task.id === taskId
|
task.id === taskId
|
||||||
@ -186,8 +271,38 @@ export default function AppealQuotaPage() {
|
|||||||
: task
|
: task
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
setShowSuccessToast(true)
|
toast.success('申请已发送,等待代理商处理')
|
||||||
setTimeout(() => setShowSuccessToast(false), 3000)
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setRequestingTaskId(taskId)
|
||||||
|
await api.increaseAppealCount(taskId)
|
||||||
|
toast.success('申请已发送,等待代理商处理')
|
||||||
|
// Update local state optimistically
|
||||||
|
setTasks(prev =>
|
||||||
|
prev.map(task =>
|
||||||
|
task.id === taskId
|
||||||
|
? {
|
||||||
|
...task,
|
||||||
|
requestStatus: 'pending' as RequestStatus,
|
||||||
|
requestTime: new Date().toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: task
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('申请增加申诉次数失败:', err)
|
||||||
|
toast.error('申请失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
setRequestingTaskId(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 统计数据
|
// 统计数据
|
||||||
@ -216,6 +331,16 @@ export default function AppealQuotaPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 统计卡片 */}
|
{/* 统计卡片 */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{[1, 2, 3].map(i => (
|
||||||
|
<div key={i} className="bg-bg-card rounded-xl p-4 card-shadow flex flex-col items-center gap-1 animate-pulse">
|
||||||
|
<div className="h-7 w-8 bg-bg-elevated rounded" />
|
||||||
|
<div className="h-3 w-14 bg-bg-elevated rounded" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<div className="bg-bg-card rounded-xl p-4 card-shadow flex flex-col items-center gap-1">
|
<div className="bg-bg-card rounded-xl p-4 card-shadow flex flex-col items-center gap-1">
|
||||||
<span className="text-2xl font-bold text-accent-indigo">{totalRemaining}</span>
|
<span className="text-2xl font-bold text-accent-indigo">{totalRemaining}</span>
|
||||||
@ -230,6 +355,7 @@ export default function AppealQuotaPage() {
|
|||||||
<span className="text-xs text-text-tertiary">待处理申请</span>
|
<span className="text-xs text-text-tertiary">待处理申请</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 规则说明 */}
|
{/* 规则说明 */}
|
||||||
<div className="bg-accent-indigo/10 rounded-xl p-4 flex gap-3">
|
<div className="bg-accent-indigo/10 rounded-xl p-4 flex gap-3">
|
||||||
@ -245,25 +371,31 @@ export default function AppealQuotaPage() {
|
|||||||
{/* 任务列表 */}
|
{/* 任务列表 */}
|
||||||
<div className="flex flex-col gap-4 flex-1 min-h-0 overflow-y-auto pb-4">
|
<div className="flex flex-col gap-4 flex-1 min-h-0 overflow-y-auto pb-4">
|
||||||
<h2 className="text-base font-semibold text-text-primary sticky top-0 bg-bg-page py-2 -mt-2">
|
<h2 className="text-base font-semibold text-text-primary sticky top-0 bg-bg-page py-2 -mt-2">
|
||||||
任务申诉次数 ({tasks.length})
|
任务申诉次数 {!loading && `(${tasks.length})`}
|
||||||
</h2>
|
</h2>
|
||||||
{tasks.map(task => (
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<QuotaSkeleton />
|
||||||
|
<QuotaSkeleton />
|
||||||
|
<QuotaSkeleton />
|
||||||
|
</>
|
||||||
|
) : tasks.length > 0 ? (
|
||||||
|
tasks.map(task => (
|
||||||
<TaskQuotaCard
|
<TaskQuotaCard
|
||||||
key={task.id}
|
key={task.id}
|
||||||
task={task}
|
task={task}
|
||||||
onRequestIncrease={handleRequestIncrease}
|
onRequestIncrease={handleRequestIncrease}
|
||||||
|
requesting={requestingTaskId === task.id}
|
||||||
/>
|
/>
|
||||||
))}
|
))
|
||||||
</div>
|
) : (
|
||||||
</div>
|
<div className="flex flex-col items-center justify-center py-16">
|
||||||
|
<AlertCircle className="w-12 h-12 text-text-tertiary/50 mb-4" />
|
||||||
{/* 成功提示 */}
|
<p className="text-text-secondary text-center">暂无任务数据</p>
|
||||||
{showSuccessToast && (
|
|
||||||
<div className="fixed bottom-24 left-1/2 -translate-x-1/2 bg-accent-green text-white px-4 py-3 rounded-xl shadow-lg flex items-center gap-2 animate-fade-in z-50">
|
|
||||||
<CheckCircle size={18} />
|
|
||||||
<span className="text-sm font-medium">申请已发送,等待代理商处理</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</ResponsiveLayout>
|
</ResponsiveLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@ -11,10 +11,15 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Image,
|
Image,
|
||||||
Send,
|
Send,
|
||||||
AlertTriangle
|
AlertTriangle,
|
||||||
|
Loader2
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
|
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
|
import { useToast } from '@/components/ui/Toast'
|
||||||
|
import type { TaskResponse } from '@/types/task'
|
||||||
|
|
||||||
// 申诉状态类型
|
// 申诉状态类型
|
||||||
type AppealStatus = 'pending' | 'processing' | 'approved' | 'rejected'
|
type AppealStatus = 'pending' | 'processing' | 'approved' | 'rejected'
|
||||||
@ -130,6 +135,69 @@ const mockAppealDetails: Record<string, AppealDetail> = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 将 TaskResponse 映射为 AppealDetail UI 类型
|
||||||
|
function mapTaskToAppealDetail(task: TaskResponse): AppealDetail {
|
||||||
|
let type: 'ai' | 'agency' | 'brand' = 'ai'
|
||||||
|
if (task.script_brand_status === 'rejected' || task.video_brand_status === 'rejected') {
|
||||||
|
type = 'brand'
|
||||||
|
} else if (task.script_agency_status === 'rejected' || task.video_agency_status === 'rejected') {
|
||||||
|
type = 'agency'
|
||||||
|
}
|
||||||
|
|
||||||
|
let status: AppealStatus = 'pending'
|
||||||
|
if (task.stage === 'completed') {
|
||||||
|
status = 'approved'
|
||||||
|
} else if (task.stage === 'rejected') {
|
||||||
|
status = 'rejected'
|
||||||
|
} else if (task.is_appeal) {
|
||||||
|
status = 'processing'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build original issue from review comments
|
||||||
|
let originalIssue: { title: string; description: string } | undefined
|
||||||
|
const rejectionComment =
|
||||||
|
task.script_brand_comment ||
|
||||||
|
task.script_agency_comment ||
|
||||||
|
task.video_brand_comment ||
|
||||||
|
task.video_agency_comment
|
||||||
|
if (rejectionComment) {
|
||||||
|
originalIssue = {
|
||||||
|
title: '审核驳回',
|
||||||
|
description: rejectionComment,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build timeline from task dates
|
||||||
|
const timeline: { time: string; action: string; operator?: string }[] = []
|
||||||
|
if (task.created_at) {
|
||||||
|
timeline.push({ time: formatDate(task.created_at), action: '任务创建' })
|
||||||
|
}
|
||||||
|
if (task.updated_at) {
|
||||||
|
timeline.push({ time: formatDate(task.updated_at), action: '提交申诉' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
taskId: task.id,
|
||||||
|
taskTitle: task.name,
|
||||||
|
type,
|
||||||
|
reason: task.appeal_reason || '申诉',
|
||||||
|
content: task.appeal_reason || '',
|
||||||
|
status,
|
||||||
|
createdAt: task.created_at ? formatDate(task.created_at) : '',
|
||||||
|
updatedAt: task.updated_at ? formatDate(task.updated_at) : undefined,
|
||||||
|
originalIssue,
|
||||||
|
timeline: timeline.length > 0 ? timeline : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 状态配置
|
// 状态配置
|
||||||
const statusConfig: Record<AppealStatus, { label: string; color: string; bgColor: string; icon: React.ElementType }> = {
|
const statusConfig: Record<AppealStatus, { label: string; color: string; bgColor: string; icon: React.ElementType }> = {
|
||||||
pending: { label: '待处理', color: 'text-amber-500', bgColor: 'bg-amber-500/15', icon: Clock },
|
pending: { label: '待处理', color: 'text-amber-500', bgColor: 'bg-amber-500/15', icon: Clock },
|
||||||
@ -145,13 +213,114 @@ const typeConfig: Record<string, { label: string; color: string }> = {
|
|||||||
brand: { label: '品牌方审核', color: 'text-accent-blue' },
|
brand: { label: '品牌方审核', color: 'text-accent-blue' },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 骨架屏组件
|
||||||
|
function DetailSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 h-full animate-pulse">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="h-8 w-16 bg-bg-elevated rounded-lg" />
|
||||||
|
<div className="h-6 w-32 bg-bg-elevated rounded" />
|
||||||
|
<div className="h-4 w-48 bg-bg-elevated rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="h-10 w-24 bg-bg-elevated rounded-xl" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col lg:flex-row gap-6 flex-1">
|
||||||
|
<div className="flex-1 flex flex-col gap-5">
|
||||||
|
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
|
||||||
|
<div className="h-5 w-32 bg-bg-elevated rounded mb-4" />
|
||||||
|
<div className="h-20 bg-bg-elevated rounded-xl" />
|
||||||
|
</div>
|
||||||
|
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
|
||||||
|
<div className="h-5 w-24 bg-bg-elevated rounded mb-4" />
|
||||||
|
<div className="h-4 w-full bg-bg-elevated rounded mb-2" />
|
||||||
|
<div className="h-4 w-3/4 bg-bg-elevated rounded mb-4" />
|
||||||
|
<div className="h-16 bg-bg-elevated rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="lg:w-[320px]">
|
||||||
|
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
|
||||||
|
<div className="h-5 w-24 bg-bg-elevated rounded mb-5" />
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="h-10 bg-bg-elevated rounded" />
|
||||||
|
<div className="h-10 bg-bg-elevated rounded" />
|
||||||
|
<div className="h-10 bg-bg-elevated rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function AppealDetailPage() {
|
export default function AppealDetailPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
const appealId = params.id as string
|
const appealId = params.id as string
|
||||||
const [newComment, setNewComment] = useState('')
|
const [newComment, setNewComment] = useState('')
|
||||||
|
const [appeal, setAppeal] = useState<AppealDetail | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
const appeal = mockAppealDetails[appealId]
|
const loadAppealDetail = useCallback(async () => {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
const mockAppeal = mockAppealDetails[appealId]
|
||||||
|
setAppeal(mockAppeal || null)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const task = await api.getTask(appealId)
|
||||||
|
const mapped = mapTaskToAppealDetail(task)
|
||||||
|
setAppeal(mapped)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('加载申诉详情失败:', err)
|
||||||
|
toast.error('加载申诉详情失败,请稍后重试')
|
||||||
|
setAppeal(null)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [appealId, toast])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAppealDetail()
|
||||||
|
}, [loadAppealDetail])
|
||||||
|
|
||||||
|
const handleSendComment = async () => {
|
||||||
|
if (!newComment.trim()) return
|
||||||
|
|
||||||
|
if (USE_MOCK) {
|
||||||
|
toast.success('补充说明已发送')
|
||||||
|
setNewComment('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSubmitting(true)
|
||||||
|
// Use submitAppeal to add supplementary info (re-appeal with updated reason)
|
||||||
|
await api.submitAppeal(appealId, { reason: newComment.trim() })
|
||||||
|
toast.success('补充说明已发送')
|
||||||
|
setNewComment('')
|
||||||
|
// Reload to reflect any changes
|
||||||
|
loadAppealDetail()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('发送补充说明失败:', err)
|
||||||
|
toast.error('发送失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<ResponsiveLayout role="creator">
|
||||||
|
<DetailSkeleton />
|
||||||
|
</ResponsiveLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!appeal) {
|
if (!appeal) {
|
||||||
return (
|
return (
|
||||||
@ -288,13 +457,23 @@ export default function AppealDetailPage() {
|
|||||||
value={newComment}
|
value={newComment}
|
||||||
onChange={(e) => setNewComment(e.target.value)}
|
onChange={(e) => setNewComment(e.target.value)}
|
||||||
className="flex-1 px-4 py-3 bg-bg-elevated rounded-xl text-sm text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
|
className="flex-1 px-4 py-3 bg-bg-elevated rounded-xl text-sm text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
|
||||||
|
disabled={submitting}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="px-5 py-3 rounded-xl bg-accent-indigo text-white text-sm font-medium flex items-center gap-2"
|
onClick={handleSendComment}
|
||||||
|
disabled={submitting || !newComment.trim()}
|
||||||
|
className={cn(
|
||||||
|
'px-5 py-3 rounded-xl bg-accent-indigo text-white text-sm font-medium flex items-center gap-2',
|
||||||
|
(submitting || !newComment.trim()) && 'opacity-50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
<Send className="w-4 h-4" />
|
<Send className="w-4 h-4" />
|
||||||
发送
|
)}
|
||||||
|
{submitting ? '发送中...' : '发送'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@ -9,10 +9,15 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Image,
|
Image,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckCircle
|
CheckCircle,
|
||||||
|
Loader2
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
|
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
|
import { useToast } from '@/components/ui/Toast'
|
||||||
|
import type { TaskResponse } from '@/types/task'
|
||||||
|
|
||||||
// 申诉原因选项
|
// 申诉原因选项
|
||||||
const appealReasons = [
|
const appealReasons = [
|
||||||
@ -23,9 +28,19 @@ const appealReasons = [
|
|||||||
{ id: 'other', label: '其他原因', description: '其他需要说明的情况' },
|
{ id: 'other', label: '其他原因', description: '其他需要说明的情况' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Mock 任务信息类型
|
||||||
|
type TaskInfo = {
|
||||||
|
title: string
|
||||||
|
issue: string
|
||||||
|
issueDesc: string
|
||||||
|
type: string
|
||||||
|
appealRemaining: number
|
||||||
|
agencyName: string
|
||||||
|
}
|
||||||
|
|
||||||
// 任务信息(模拟从URL参数获取)
|
// 任务信息(模拟从URL参数获取)
|
||||||
const getTaskInfo = (taskId: string) => {
|
const getTaskInfo = (taskId: string): TaskInfo => {
|
||||||
const tasks: Record<string, { title: string; issue: string; issueDesc: string; type: string; appealRemaining: number; agencyName: string }> = {
|
const tasks: Record<string, TaskInfo> = {
|
||||||
'task-003': {
|
'task-003': {
|
||||||
title: 'ZZ饮品夏日',
|
title: 'ZZ饮品夏日',
|
||||||
issue: '检测到竞品提及',
|
issue: '检测到竞品提及',
|
||||||
@ -70,12 +85,99 @@ const getTaskInfo = (taskId: string) => {
|
|||||||
return tasks[taskId] || { title: '未知任务', issue: '未知问题', issueDesc: '', type: 'ai', appealRemaining: 0, agencyName: '未知代理商' }
|
return tasks[taskId] || { title: '未知任务', issue: '未知问题', issueDesc: '', type: 'ai', appealRemaining: 0, agencyName: '未知代理商' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 将 TaskResponse 映射为 TaskInfo
|
||||||
|
function mapTaskResponseToInfo(task: TaskResponse): TaskInfo {
|
||||||
|
let type = 'ai'
|
||||||
|
let issue = '审核驳回'
|
||||||
|
let issueDesc = ''
|
||||||
|
|
||||||
|
if (task.script_brand_status === 'rejected' || task.video_brand_status === 'rejected') {
|
||||||
|
type = 'brand'
|
||||||
|
issue = task.script_brand_comment || task.video_brand_comment || '品牌方审核驳回'
|
||||||
|
issueDesc = task.script_brand_comment || task.video_brand_comment || ''
|
||||||
|
} else if (task.script_agency_status === 'rejected' || task.video_agency_status === 'rejected') {
|
||||||
|
type = 'agency'
|
||||||
|
issue = task.script_agency_comment || task.video_agency_comment || '代理商审核驳回'
|
||||||
|
issueDesc = task.script_agency_comment || task.video_agency_comment || ''
|
||||||
|
} else {
|
||||||
|
// AI rejection or default
|
||||||
|
const aiResult = task.script_ai_result || task.video_ai_result
|
||||||
|
if (aiResult && aiResult.violations.length > 0) {
|
||||||
|
issue = aiResult.violations[0].content || 'AI审核不通过'
|
||||||
|
issueDesc = aiResult.summary || aiResult.violations.map(v => v.content).join('; ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default appeal quota: 1 per task minus used appeals
|
||||||
|
const defaultQuota = 1
|
||||||
|
const appealRemaining = Math.max(0, defaultQuota - task.appeal_count)
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: task.name,
|
||||||
|
issue,
|
||||||
|
issueDesc,
|
||||||
|
type,
|
||||||
|
appealRemaining,
|
||||||
|
agencyName: task.agency?.name || '未知代理商',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表单骨架屏
|
||||||
|
function FormSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 h-full animate-pulse">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="h-8 w-16 bg-bg-elevated rounded-lg" />
|
||||||
|
<div className="h-6 w-24 bg-bg-elevated rounded" />
|
||||||
|
<div className="h-4 w-64 bg-bg-elevated rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="h-10 w-48 bg-bg-elevated rounded-xl" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col lg:flex-row gap-6 flex-1">
|
||||||
|
<div className="flex-1 flex flex-col gap-5">
|
||||||
|
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
|
||||||
|
<div className="h-5 w-24 bg-bg-elevated rounded mb-4" />
|
||||||
|
<div className="h-20 bg-bg-elevated rounded-xl" />
|
||||||
|
</div>
|
||||||
|
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
|
||||||
|
<div className="h-5 w-24 bg-bg-elevated rounded mb-4" />
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="h-16 bg-bg-elevated rounded-xl" />
|
||||||
|
<div className="h-16 bg-bg-elevated rounded-xl" />
|
||||||
|
<div className="h-16 bg-bg-elevated rounded-xl" />
|
||||||
|
<div className="h-16 bg-bg-elevated rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
|
||||||
|
<div className="h-5 w-24 bg-bg-elevated rounded mb-4" />
|
||||||
|
<div className="h-32 bg-bg-elevated rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="lg:w-[320px]">
|
||||||
|
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
|
||||||
|
<div className="h-5 w-24 bg-bg-elevated rounded mb-5" />
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="h-4 w-full bg-bg-elevated rounded" />
|
||||||
|
<div className="h-4 w-full bg-bg-elevated rounded" />
|
||||||
|
<div className="h-4 w-full bg-bg-elevated rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="h-12 bg-bg-elevated rounded-xl mt-6" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function NewAppealPage() {
|
export default function NewAppealPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
|
const toast = useToast()
|
||||||
const taskId = searchParams.get('taskId') || ''
|
const taskId = searchParams.get('taskId') || ''
|
||||||
const taskInfo = getTaskInfo(taskId)
|
|
||||||
|
|
||||||
|
const [taskInfo, setTaskInfo] = useState<TaskInfo | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
const [selectedReason, setSelectedReason] = useState<string>('')
|
const [selectedReason, setSelectedReason] = useState<string>('')
|
||||||
const [content, setContent] = useState('')
|
const [content, setContent] = useState('')
|
||||||
const [attachments, setAttachments] = useState<{ name: string; type: 'image' | 'document' }[]>([])
|
const [attachments, setAttachments] = useState<{ name: string; type: 'image' | 'document' }[]>([])
|
||||||
@ -84,7 +186,40 @@ export default function NewAppealPage() {
|
|||||||
const [isRequestingQuota, setIsRequestingQuota] = useState(false)
|
const [isRequestingQuota, setIsRequestingQuota] = useState(false)
|
||||||
const [quotaRequested, setQuotaRequested] = useState(false)
|
const [quotaRequested, setQuotaRequested] = useState(false)
|
||||||
|
|
||||||
const hasAppealQuota = taskInfo.appealRemaining > 0
|
// Load task info
|
||||||
|
const loadTaskInfo = useCallback(async () => {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
setTaskInfo(getTaskInfo(taskId))
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!taskId) {
|
||||||
|
toast.error('缺少任务ID参数')
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const task = await api.getTask(taskId)
|
||||||
|
const info = mapTaskResponseToInfo(task)
|
||||||
|
setTaskInfo(info)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('加载任务信息失败:', err)
|
||||||
|
toast.error('加载任务信息失败,请稍后重试')
|
||||||
|
// Fallback to a default
|
||||||
|
setTaskInfo({ title: '未知任务', issue: '未知问题', issueDesc: '', type: 'ai', appealRemaining: 0, agencyName: '未知代理商' })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [taskId, toast])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTaskInfo()
|
||||||
|
}, [loadTaskInfo])
|
||||||
|
|
||||||
|
const hasAppealQuota = taskInfo ? taskInfo.appealRemaining > 0 : false
|
||||||
|
|
||||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = e.target.files
|
const files = e.target.files
|
||||||
@ -104,26 +239,69 @@ export default function NewAppealPage() {
|
|||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!selectedReason || !content.trim()) return
|
if (!selectedReason || !content.trim()) return
|
||||||
|
|
||||||
|
if (USE_MOCK) {
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
// 模拟提交
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
setIsSubmitted(true)
|
setIsSubmitted(true)
|
||||||
|
|
||||||
// 2秒后跳转到申诉列表
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push('/creator/appeals')
|
router.push('/creator/appeals')
|
||||||
}, 2000)
|
}, 2000)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true)
|
||||||
|
const reasonLabel = appealReasons.find(r => r.id === selectedReason)?.label || selectedReason
|
||||||
|
const appealReason = `[${reasonLabel}] ${content.trim()}`
|
||||||
|
await api.submitAppeal(taskId, { reason: appealReason })
|
||||||
|
toast.success('申诉提交成功')
|
||||||
|
setIsSubmitted(true)
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push('/creator/appeals')
|
||||||
|
}, 2000)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('提交申诉失败:', err)
|
||||||
|
toast.error('提交申诉失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const canSubmit = selectedReason && content.trim().length >= 20 && hasAppealQuota
|
const canSubmit = selectedReason && content.trim().length >= 20 && hasAppealQuota
|
||||||
|
|
||||||
// 申请增加申诉次数
|
// 申请增加申诉次数
|
||||||
const handleRequestQuota = async () => {
|
const handleRequestQuota = async () => {
|
||||||
|
if (USE_MOCK) {
|
||||||
setIsRequestingQuota(true)
|
setIsRequestingQuota(true)
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
setIsRequestingQuota(false)
|
setIsRequestingQuota(false)
|
||||||
setQuotaRequested(true)
|
setQuotaRequested(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsRequestingQuota(true)
|
||||||
|
await api.increaseAppealCount(taskId)
|
||||||
|
toast.success('申请已发送,等待代理商处理')
|
||||||
|
setQuotaRequested(true)
|
||||||
|
// Reload task info to get updated appeal count
|
||||||
|
loadTaskInfo()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('申请增加申诉次数失败:', err)
|
||||||
|
toast.error('申请失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
setIsRequestingQuota(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载中骨架屏
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<ResponsiveLayout role="creator">
|
||||||
|
<FormSkeleton />
|
||||||
|
</ResponsiveLayout>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提交成功界面
|
// 提交成功界面
|
||||||
@ -148,6 +326,9 @@ export default function NewAppealPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use fallback if taskInfo is somehow null after loading
|
||||||
|
const info = taskInfo || { title: '未知任务', issue: '未知问题', issueDesc: '', type: 'ai', appealRemaining: 0, agencyName: '未知代理商' }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveLayout role="creator">
|
<ResponsiveLayout role="creator">
|
||||||
<div className="flex flex-col gap-6 h-full">
|
<div className="flex flex-col gap-6 h-full">
|
||||||
@ -171,7 +352,7 @@ export default function NewAppealPage() {
|
|||||||
)}>
|
)}>
|
||||||
<AlertTriangle className={cn('w-5 h-5', hasAppealQuota ? 'text-accent-indigo' : 'text-accent-coral')} />
|
<AlertTriangle className={cn('w-5 h-5', hasAppealQuota ? 'text-accent-indigo' : 'text-accent-coral')} />
|
||||||
<span className={cn('text-sm font-medium', hasAppealQuota ? 'text-accent-indigo' : 'text-accent-coral')}>
|
<span className={cn('text-sm font-medium', hasAppealQuota ? 'text-accent-indigo' : 'text-accent-coral')}>
|
||||||
本任务剩余 {taskInfo.appealRemaining} 次申诉机会
|
本任务剩余 {info.appealRemaining} 次申诉机会
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -185,16 +366,16 @@ export default function NewAppealPage() {
|
|||||||
<h3 className="text-base lg:text-lg font-semibold text-text-primary mb-4">关联任务</h3>
|
<h3 className="text-base lg:text-lg font-semibold text-text-primary mb-4">关联任务</h3>
|
||||||
<div className="bg-bg-elevated rounded-xl p-4">
|
<div className="bg-bg-elevated rounded-xl p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className="text-base font-semibold text-text-primary">{taskInfo.title}</span>
|
<span className="text-base font-semibold text-text-primary">{info.title}</span>
|
||||||
<span className="px-2.5 py-1 rounded-full text-xs font-medium bg-accent-coral/15 text-accent-coral">
|
<span className="px-2.5 py-1 rounded-full text-xs font-medium bg-accent-coral/15 text-accent-coral">
|
||||||
{taskInfo.type === 'ai' ? 'AI审核' : taskInfo.type === 'agency' ? '代理商审核' : '品牌方审核'}
|
{info.type === 'ai' ? 'AI审核' : info.type === 'agency' ? '代理商审核' : '品牌方审核'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<AlertTriangle className="w-4 h-4 text-accent-coral flex-shrink-0 mt-0.5" />
|
<AlertTriangle className="w-4 h-4 text-accent-coral flex-shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-medium text-text-primary">{taskInfo.issue}</span>
|
<span className="text-sm font-medium text-text-primary">{info.issue}</span>
|
||||||
<p className="text-xs text-text-secondary mt-1">{taskInfo.issueDesc}</p>
|
<p className="text-xs text-text-secondary mt-1">{info.issueDesc}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -208,7 +389,7 @@ export default function NewAppealPage() {
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="text-base font-semibold text-accent-coral mb-2">申诉次数不足</h3>
|
<h3 className="text-base font-semibold text-accent-coral mb-2">申诉次数不足</h3>
|
||||||
<p className="text-sm text-text-secondary mb-4">
|
<p className="text-sm text-text-secondary mb-4">
|
||||||
本任务的申诉次数已用完,无法提交新的申诉。您可以向代理商「{taskInfo.agencyName}」申请增加申诉次数。
|
本任务的申诉次数已用完,无法提交新的申诉。您可以向代理商「{info.agencyName}」申请增加申诉次数。
|
||||||
</p>
|
</p>
|
||||||
{quotaRequested ? (
|
{quotaRequested ? (
|
||||||
<div className="flex items-center gap-2 text-accent-green">
|
<div className="flex items-center gap-2 text-accent-green">
|
||||||
@ -220,8 +401,9 @@ export default function NewAppealPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleRequestQuota}
|
onClick={handleRequestQuota}
|
||||||
disabled={isRequestingQuota}
|
disabled={isRequestingQuota}
|
||||||
className="px-4 py-2 bg-accent-coral text-white rounded-lg text-sm font-medium hover:bg-accent-coral/90 transition-colors disabled:opacity-50"
|
className="px-4 py-2 bg-accent-coral text-white rounded-lg text-sm font-medium hover:bg-accent-coral/90 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||||
>
|
>
|
||||||
|
{isRequestingQuota && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
{isRequestingQuota ? '申请中...' : '申请增加申诉次数'}
|
{isRequestingQuota ? '申请中...' : '申请增加申诉次数'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@ -322,12 +504,13 @@ export default function NewAppealPage() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!canSubmit || isSubmitting}
|
disabled={!canSubmit || isSubmitting}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full py-4 rounded-xl text-base font-semibold',
|
'w-full py-4 rounded-xl text-base font-semibold flex items-center justify-center gap-2',
|
||||||
canSubmit && !isSubmitting
|
canSubmit && !isSubmitting
|
||||||
? 'bg-accent-indigo text-white'
|
? 'bg-accent-indigo text-white'
|
||||||
: 'bg-bg-elevated text-text-tertiary'
|
: 'bg-bg-elevated text-text-tertiary'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{isSubmitting && <Loader2 className="w-5 h-5 animate-spin" />}
|
||||||
{isSubmitting ? '提交中...' : '提交申诉'}
|
{isSubmitting ? '提交中...' : '提交申诉'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -341,7 +524,7 @@ export default function NewAppealPage() {
|
|||||||
<div className="flex flex-col gap-4 mb-6">
|
<div className="flex flex-col gap-4 mb-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-text-tertiary">关联任务</span>
|
<span className="text-sm text-text-tertiary">关联任务</span>
|
||||||
<span className="text-sm text-text-primary">{taskInfo.title}</span>
|
<span className="text-sm text-text-primary">{info.title}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-text-tertiary">申诉原因</span>
|
<span className="text-sm text-text-tertiary">申诉原因</span>
|
||||||
@ -378,12 +561,13 @@ export default function NewAppealPage() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!canSubmit || isSubmitting}
|
disabled={!canSubmit || isSubmitting}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full py-4 rounded-xl text-base font-semibold transition-colors',
|
'w-full py-4 rounded-xl text-base font-semibold transition-colors flex items-center justify-center gap-2',
|
||||||
canSubmit && !isSubmitting
|
canSubmit && !isSubmitting
|
||||||
? 'bg-accent-indigo text-white hover:bg-accent-indigo/90'
|
? 'bg-accent-indigo text-white hover:bg-accent-indigo/90'
|
||||||
: 'bg-bg-elevated text-text-tertiary cursor-not-allowed'
|
: 'bg-bg-elevated text-text-tertiary cursor-not-allowed'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{isSubmitting && <Loader2 className="w-5 h-5 animate-spin" />}
|
||||||
{isSubmitting ? '提交中...' : '提交申诉'}
|
{isSubmitting ? '提交中...' : '提交申诉'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import {
|
import {
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
@ -10,10 +10,15 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Filter,
|
Filter,
|
||||||
Search
|
Search,
|
||||||
|
Loader2
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
|
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
|
import { useToast } from '@/components/ui/Toast'
|
||||||
|
import type { TaskResponse } from '@/types/task'
|
||||||
|
|
||||||
// 申诉状态类型
|
// 申诉状态类型
|
||||||
type AppealStatus = 'pending' | 'processing' | 'approved' | 'rejected'
|
type AppealStatus = 'pending' | 'processing' | 'approved' | 'rejected'
|
||||||
@ -80,6 +85,45 @@ const mockAppeals: Appeal[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// 将 TaskResponse 映射为 Appeal UI 类型
|
||||||
|
function mapTaskToAppeal(task: TaskResponse): Appeal {
|
||||||
|
// 判断申诉类型:根据当前阶段判断被驳回的审核类型
|
||||||
|
let type: 'ai' | 'agency' | 'brand' = 'ai'
|
||||||
|
if (task.script_brand_status === 'rejected' || task.video_brand_status === 'rejected') {
|
||||||
|
type = 'brand'
|
||||||
|
} else if (task.script_agency_status === 'rejected' || task.video_agency_status === 'rejected') {
|
||||||
|
type = 'agency'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断申诉状态:根据任务阶段和当前状态推断
|
||||||
|
let status: AppealStatus = 'pending'
|
||||||
|
if (task.stage === 'completed') {
|
||||||
|
status = 'approved'
|
||||||
|
} else if (task.stage === 'rejected') {
|
||||||
|
status = 'rejected'
|
||||||
|
} else if (task.is_appeal) {
|
||||||
|
status = 'processing'
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
taskId: task.id,
|
||||||
|
taskTitle: task.name,
|
||||||
|
type,
|
||||||
|
reason: task.appeal_reason || '申诉',
|
||||||
|
content: task.appeal_reason || '',
|
||||||
|
status,
|
||||||
|
createdAt: task.updated_at ? new Date(task.updated_at).toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
}) : '',
|
||||||
|
updatedAt: task.updated_at ? new Date(task.updated_at).toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
}) : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 状态配置
|
// 状态配置
|
||||||
const statusConfig: Record<AppealStatus, { label: string; color: string; bgColor: string; icon: React.ElementType }> = {
|
const statusConfig: Record<AppealStatus, { label: string; color: string; bgColor: string; icon: React.ElementType }> = {
|
||||||
pending: { label: '待处理', color: 'text-amber-500', bgColor: 'bg-amber-500/15', icon: Clock },
|
pending: { label: '待处理', color: 'text-amber-500', bgColor: 'bg-amber-500/15', icon: Clock },
|
||||||
@ -95,6 +139,32 @@ const typeConfig: Record<string, { label: string; color: string }> = {
|
|||||||
brand: { label: '品牌方审核', color: 'text-accent-blue' },
|
brand: { label: '品牌方审核', color: 'text-accent-blue' },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 骨架屏组件
|
||||||
|
function AppealSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="bg-bg-card rounded-2xl p-5 card-shadow animate-pulse">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-bg-elevated" />
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="h-4 w-28 bg-bg-elevated rounded" />
|
||||||
|
<div className="h-3 w-36 bg-bg-elevated rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-6 w-16 bg-bg-elevated rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="h-3 w-40 bg-bg-elevated rounded" />
|
||||||
|
<div className="h-3 w-32 bg-bg-elevated rounded" />
|
||||||
|
<div className="h-4 w-full bg-bg-elevated rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between mt-4 pt-4 border-t border-border-subtle">
|
||||||
|
<div className="h-3 w-32 bg-bg-elevated rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 申诉卡片组件
|
// 申诉卡片组件
|
||||||
function AppealCard({ appeal, onClick }: { appeal: Appeal; onClick: () => void }) {
|
function AppealCard({ appeal, onClick }: { appeal: Appeal; onClick: () => void }) {
|
||||||
const status = statusConfig[appeal.status]
|
const status = statusConfig[appeal.status]
|
||||||
@ -174,9 +244,39 @@ function AppealQuotaEntryCard({ onClick }: { onClick: () => void }) {
|
|||||||
|
|
||||||
export default function CreatorAppealsPage() {
|
export default function CreatorAppealsPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
const [filter, setFilter] = useState<AppealStatus | 'all'>('all')
|
const [filter, setFilter] = useState<AppealStatus | 'all'>('all')
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [appeals] = useState<Appeal[]>(mockAppeals)
|
const [appeals, setAppeals] = useState<Appeal[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const loadAppeals = useCallback(async () => {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
setAppeals(mockAppeals)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const response = await api.listTasks(1, 50)
|
||||||
|
// Filter for tasks that have appeals (is_appeal === true or have appeal_reason)
|
||||||
|
const appealTasks = response.items.filter(
|
||||||
|
(task) => task.is_appeal || task.appeal_reason || task.appeal_count > 0
|
||||||
|
)
|
||||||
|
const mapped = appealTasks.map(mapTaskToAppeal)
|
||||||
|
setAppeals(mapped)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('加载申诉列表失败:', err)
|
||||||
|
toast.error('加载申诉列表失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [toast])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAppeals()
|
||||||
|
}, [loadAppeals])
|
||||||
|
|
||||||
// 搜索和筛选
|
// 搜索和筛选
|
||||||
const filteredAppeals = appeals.filter(appeal => {
|
const filteredAppeals = appeals.filter(appeal => {
|
||||||
@ -239,8 +339,16 @@ export default function CreatorAppealsPage() {
|
|||||||
|
|
||||||
{/* 申诉列表 */}
|
{/* 申诉列表 */}
|
||||||
<div className="flex flex-col gap-4 flex-1 overflow-y-auto pr-2">
|
<div className="flex flex-col gap-4 flex-1 overflow-y-auto pr-2">
|
||||||
<h2 className="text-lg font-semibold text-text-primary">申诉记录 ({filteredAppeals.length})</h2>
|
<h2 className="text-lg font-semibold text-text-primary">
|
||||||
{filteredAppeals.length > 0 ? (
|
申诉记录 {!loading && `(${filteredAppeals.length})`}
|
||||||
|
</h2>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<AppealSkeleton />
|
||||||
|
<AppealSkeleton />
|
||||||
|
<AppealSkeleton />
|
||||||
|
</>
|
||||||
|
) : filteredAppeals.length > 0 ? (
|
||||||
filteredAppeals.map((appeal) => (
|
filteredAppeals.map((appeal) => (
|
||||||
<AppealCard
|
<AppealCard
|
||||||
key={appeal.id}
|
key={appeal.id}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@ -9,10 +9,15 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
Video,
|
Video,
|
||||||
Filter,
|
Filter,
|
||||||
ChevronRight
|
ChevronRight,
|
||||||
|
Loader2
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
|
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
|
import { useToast } from '@/components/ui/Toast'
|
||||||
|
import type { TaskResponse } from '@/types/task'
|
||||||
|
|
||||||
// 历史任务状态类型
|
// 历史任务状态类型
|
||||||
type HistoryStatus = 'completed' | 'expired' | 'cancelled'
|
type HistoryStatus = 'completed' | 'expired' | 'cancelled'
|
||||||
@ -80,6 +85,17 @@ const mockHistory: HistoryTask[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
function mapTaskResponseToHistory(task: TaskResponse): HistoryTask {
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
title: task.name,
|
||||||
|
description: task.project.name,
|
||||||
|
status: task.stage === 'completed' ? 'completed' : 'completed',
|
||||||
|
completedAt: task.updated_at?.split('T')[0],
|
||||||
|
platform: '抖音', // backend doesn't return platform info yet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 状态配置
|
// 状态配置
|
||||||
const statusConfig: Record<HistoryStatus, { label: string; color: string; bgColor: string; icon: React.ElementType }> = {
|
const statusConfig: Record<HistoryStatus, { label: string; color: string; bgColor: string; icon: React.ElementType }> = {
|
||||||
completed: { label: '已完成', color: 'text-accent-green', bgColor: 'bg-accent-green/15', icon: CheckCircle },
|
completed: { label: '已完成', color: 'text-accent-green', bgColor: 'bg-accent-green/15', icon: CheckCircle },
|
||||||
@ -87,6 +103,32 @@ const statusConfig: Record<HistoryStatus, { label: string; color: string; bgColo
|
|||||||
cancelled: { label: '已取消', color: 'text-accent-coral', bgColor: 'bg-accent-coral/15', icon: XCircle },
|
cancelled: { label: '已取消', color: 'text-accent-coral', bgColor: 'bg-accent-coral/15', icon: XCircle },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 骨架屏
|
||||||
|
function HistorySkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<div key={i} className="bg-bg-card rounded-2xl p-5 card-shadow animate-pulse">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-16 h-12 rounded-lg bg-bg-elevated" />
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="h-4 w-40 bg-bg-elevated rounded" />
|
||||||
|
<div className="h-3 w-28 bg-bg-elevated rounded" />
|
||||||
|
<div className="h-3 w-20 bg-bg-elevated rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-8 w-20 bg-bg-elevated rounded-lg" />
|
||||||
|
<div className="w-5 h-5 bg-bg-elevated rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 历史任务卡片
|
// 历史任务卡片
|
||||||
function HistoryCard({ task, onClick }: { task: HistoryTask; onClick: () => void }) {
|
function HistoryCard({ task, onClick }: { task: HistoryTask; onClick: () => void }) {
|
||||||
const status = statusConfig[task.status]
|
const status = statusConfig[task.status]
|
||||||
@ -127,9 +169,37 @@ function HistoryCard({ task, onClick }: { task: HistoryTask; onClick: () => void
|
|||||||
|
|
||||||
export default function CreatorHistoryPage() {
|
export default function CreatorHistoryPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
const [filter, setFilter] = useState<HistoryStatus | 'all'>('all')
|
const [filter, setFilter] = useState<HistoryStatus | 'all'>('all')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [historyTasks, setHistoryTasks] = useState<HistoryTask[]>([])
|
||||||
|
|
||||||
const filteredHistory = filter === 'all' ? mockHistory : mockHistory.filter(t => t.status === filter)
|
const loadHistory = useCallback(async () => {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
setHistoryTasks(mockHistory)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const response = await api.listTasks(1, 50, 'completed')
|
||||||
|
const mapped = response.items.map(mapTaskResponseToHistory)
|
||||||
|
setHistoryTasks(mapped)
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '加载历史记录失败'
|
||||||
|
toast.error(message)
|
||||||
|
console.error('加载历史记录失败:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [toast])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadHistory()
|
||||||
|
}, [loadHistory])
|
||||||
|
|
||||||
|
const filteredHistory = filter === 'all' ? historyTasks : historyTasks.filter(t => t.status === filter)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveLayout role="creator">
|
<ResponsiveLayout role="creator">
|
||||||
@ -167,21 +237,21 @@ export default function CreatorHistoryPage() {
|
|||||||
<div className="flex items-center gap-6 bg-bg-card rounded-2xl p-5 card-shadow">
|
<div className="flex items-center gap-6 bg-bg-card rounded-2xl p-5 card-shadow">
|
||||||
<div className="flex flex-col items-center gap-1 flex-1">
|
<div className="flex flex-col items-center gap-1 flex-1">
|
||||||
<span className="text-2xl font-bold text-accent-green">
|
<span className="text-2xl font-bold text-accent-green">
|
||||||
{mockHistory.filter(t => t.status === 'completed').length}
|
{historyTasks.filter(t => t.status === 'completed').length}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-text-tertiary">已完成</span>
|
<span className="text-xs text-text-tertiary">已完成</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-px h-10 bg-border-subtle" />
|
<div className="w-px h-10 bg-border-subtle" />
|
||||||
<div className="flex flex-col items-center gap-1 flex-1">
|
<div className="flex flex-col items-center gap-1 flex-1">
|
||||||
<span className="text-2xl font-bold text-text-tertiary">
|
<span className="text-2xl font-bold text-text-tertiary">
|
||||||
{mockHistory.filter(t => t.status === 'expired').length}
|
{historyTasks.filter(t => t.status === 'expired').length}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-text-tertiary">已过期</span>
|
<span className="text-xs text-text-tertiary">已过期</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-px h-10 bg-border-subtle" />
|
<div className="w-px h-10 bg-border-subtle" />
|
||||||
<div className="flex flex-col items-center gap-1 flex-1">
|
<div className="flex flex-col items-center gap-1 flex-1">
|
||||||
<span className="text-2xl font-bold text-accent-coral">
|
<span className="text-2xl font-bold text-accent-coral">
|
||||||
{mockHistory.filter(t => t.status === 'cancelled').length}
|
{historyTasks.filter(t => t.status === 'cancelled').length}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-text-tertiary">已取消</span>
|
<span className="text-xs text-text-tertiary">已取消</span>
|
||||||
</div>
|
</div>
|
||||||
@ -189,13 +259,23 @@ export default function CreatorHistoryPage() {
|
|||||||
|
|
||||||
{/* 任务列表 */}
|
{/* 任务列表 */}
|
||||||
<div className="flex flex-col gap-4 flex-1 overflow-y-auto pr-2">
|
<div className="flex flex-col gap-4 flex-1 overflow-y-auto pr-2">
|
||||||
{filteredHistory.map((task) => (
|
{loading ? (
|
||||||
|
<HistorySkeleton />
|
||||||
|
) : filteredHistory.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
|
<Clock className="w-12 h-12 text-text-tertiary mb-4" />
|
||||||
|
<p className="text-text-secondary">暂无历史记录</p>
|
||||||
|
<p className="text-sm text-text-tertiary mt-1">完成的任务将显示在这里</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredHistory.map((task) => (
|
||||||
<HistoryCard
|
<HistoryCard
|
||||||
key={task.id}
|
key={task.id}
|
||||||
task={task}
|
task={task}
|
||||||
onClick={() => router.push(`/creator/task/${task.id}`)}
|
onClick={() => router.push(`/creator/task/${task.id}`)}
|
||||||
/>
|
/>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ResponsiveLayout>
|
</ResponsiveLayout>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useRouter, useParams } from 'next/navigation'
|
import { useRouter, useParams } from 'next/navigation'
|
||||||
import { useToast } from '@/components/ui/Toast'
|
import { useToast } from '@/components/ui/Toast'
|
||||||
import {
|
import {
|
||||||
@ -14,11 +14,16 @@ import {
|
|||||||
Building2,
|
Building2,
|
||||||
Calendar,
|
Calendar,
|
||||||
Clock,
|
Clock,
|
||||||
ChevronRight
|
ChevronRight,
|
||||||
|
Loader2
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
|
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
|
||||||
import { Modal } from '@/components/ui/Modal'
|
import { Modal } from '@/components/ui/Modal'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
|
import type { BriefResponse } from '@/types/brief'
|
||||||
|
import type { TaskResponse } from '@/types/task'
|
||||||
|
|
||||||
// 代理商Brief文档类型
|
// 代理商Brief文档类型
|
||||||
type AgencyBriefFile = {
|
type AgencyBriefFile = {
|
||||||
@ -29,6 +34,19 @@ type AgencyBriefFile = {
|
|||||||
description?: string
|
description?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 页面视图模型
|
||||||
|
type BriefViewModel = {
|
||||||
|
taskName: string
|
||||||
|
agencyName: string
|
||||||
|
brandName: string
|
||||||
|
deadline: string
|
||||||
|
createdAt: string
|
||||||
|
files: AgencyBriefFile[]
|
||||||
|
sellingPoints: { id: string; content: string; required: boolean }[]
|
||||||
|
blacklistWords: { id: string; word: string; reason: string }[]
|
||||||
|
contentRequirements: string[]
|
||||||
|
}
|
||||||
|
|
||||||
// 模拟任务数据
|
// 模拟任务数据
|
||||||
const mockTaskInfo = {
|
const mockTaskInfo = {
|
||||||
id: 'task-001',
|
id: 'task-001',
|
||||||
@ -69,11 +87,151 @@ const mockAgencyBrief = {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildMockViewModel(): BriefViewModel {
|
||||||
|
return {
|
||||||
|
taskName: mockTaskInfo.taskName,
|
||||||
|
agencyName: mockTaskInfo.agencyName,
|
||||||
|
brandName: mockTaskInfo.brandName,
|
||||||
|
deadline: mockTaskInfo.deadline,
|
||||||
|
createdAt: mockTaskInfo.createdAt,
|
||||||
|
files: mockAgencyBrief.files,
|
||||||
|
sellingPoints: mockAgencyBrief.sellingPoints,
|
||||||
|
blacklistWords: mockAgencyBrief.blacklistWords,
|
||||||
|
contentRequirements: mockAgencyBrief.contentRequirements,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildViewModelFromAPI(task: TaskResponse, brief: BriefResponse): BriefViewModel {
|
||||||
|
// Map attachments to file list
|
||||||
|
const files: AgencyBriefFile[] = (brief.attachments ?? []).map((att, idx) => ({
|
||||||
|
id: att.id || `att-${idx}`,
|
||||||
|
name: att.name,
|
||||||
|
size: att.size || '',
|
||||||
|
uploadedAt: brief.updated_at?.split('T')[0] || '',
|
||||||
|
description: undefined,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Map selling points
|
||||||
|
const sellingPoints = (brief.selling_points ?? []).map((sp, idx) => ({
|
||||||
|
id: `sp-${idx}`,
|
||||||
|
content: sp.content,
|
||||||
|
required: sp.required,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Map blacklist words
|
||||||
|
const blacklistWords = (brief.blacklist_words ?? []).map((bw, idx) => ({
|
||||||
|
id: `bw-${idx}`,
|
||||||
|
word: bw.word,
|
||||||
|
reason: bw.reason,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Build content requirements
|
||||||
|
const contentRequirements: string[] = []
|
||||||
|
if (brief.min_duration != null || brief.max_duration != null) {
|
||||||
|
const minStr = brief.min_duration != null ? `${brief.min_duration}` : '?'
|
||||||
|
const maxStr = brief.max_duration != null ? `${brief.max_duration}` : '?'
|
||||||
|
contentRequirements.push(`视频时长:${minStr}-${maxStr}秒`)
|
||||||
|
}
|
||||||
|
if (brief.other_requirements) {
|
||||||
|
contentRequirements.push(brief.other_requirements)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
taskName: task.name,
|
||||||
|
agencyName: task.agency.name,
|
||||||
|
brandName: task.project.brand_name || task.project.name,
|
||||||
|
deadline: '', // backend task has no deadline field yet
|
||||||
|
createdAt: task.created_at.split('T')[0],
|
||||||
|
files,
|
||||||
|
sellingPoints,
|
||||||
|
blacklistWords,
|
||||||
|
contentRequirements,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 骨架屏
|
||||||
|
function BriefSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 h-full animate-pulse">
|
||||||
|
{/* 顶部导航骨架 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="h-8 w-16 bg-bg-elevated rounded-lg" />
|
||||||
|
<div className="h-7 w-48 bg-bg-elevated rounded" />
|
||||||
|
<div className="h-4 w-36 bg-bg-elevated rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="h-10 w-28 bg-bg-elevated rounded-xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 任务信息骨架 */}
|
||||||
|
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
|
||||||
|
<div className="h-5 w-24 bg-bg-elevated rounded mb-4" />
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-bg-elevated" />
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="h-3 w-12 bg-bg-elevated rounded" />
|
||||||
|
<div className="h-4 w-20 bg-bg-elevated rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 内容区域骨架 */}
|
||||||
|
<div className="flex-1 space-y-6">
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<div key={i} className="bg-bg-card rounded-2xl p-5 card-shadow">
|
||||||
|
<div className="h-5 w-32 bg-bg-elevated rounded mb-4" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-4 w-full bg-bg-elevated rounded" />
|
||||||
|
<div className="h-4 w-3/4 bg-bg-elevated rounded" />
|
||||||
|
<div className="h-4 w-1/2 bg-bg-elevated rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function TaskBriefPage() {
|
export default function TaskBriefPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const taskId = params.id as string
|
||||||
const [previewFile, setPreviewFile] = useState<AgencyBriefFile | null>(null)
|
const [previewFile, setPreviewFile] = useState<AgencyBriefFile | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [viewModel, setViewModel] = useState<BriefViewModel | null>(null)
|
||||||
|
|
||||||
|
const loadBriefData = useCallback(async () => {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
setViewModel(buildMockViewModel())
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
// First get the task to find its project ID
|
||||||
|
const task = await api.getTask(taskId)
|
||||||
|
// Then get the brief for that project
|
||||||
|
const brief = await api.getBrief(task.project.id)
|
||||||
|
setViewModel(buildViewModelFromAPI(task, brief))
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '加载Brief失败'
|
||||||
|
toast.error(message)
|
||||||
|
console.error('加载Brief失败:', err)
|
||||||
|
// Fallback: still show task info if brief load fails
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [taskId, toast])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadBriefData()
|
||||||
|
}, [loadBriefData])
|
||||||
|
|
||||||
const handleDownload = (file: AgencyBriefFile) => {
|
const handleDownload = (file: AgencyBriefFile) => {
|
||||||
toast.info(`下载文件: ${file.name}`)
|
toast.info(`下载文件: ${file.name}`)
|
||||||
@ -83,8 +241,16 @@ export default function TaskBriefPage() {
|
|||||||
toast.info('下载全部文件')
|
toast.info('下载全部文件')
|
||||||
}
|
}
|
||||||
|
|
||||||
const requiredPoints = mockAgencyBrief.sellingPoints.filter(sp => sp.required)
|
if (loading || !viewModel) {
|
||||||
const optionalPoints = mockAgencyBrief.sellingPoints.filter(sp => !sp.required)
|
return (
|
||||||
|
<ResponsiveLayout role="creator">
|
||||||
|
<BriefSkeleton />
|
||||||
|
</ResponsiveLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredPoints = viewModel.sellingPoints.filter(sp => sp.required)
|
||||||
|
const optionalPoints = viewModel.sellingPoints.filter(sp => !sp.required)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveLayout role="creator">
|
<ResponsiveLayout role="creator">
|
||||||
@ -102,7 +268,7 @@ export default function TaskBriefPage() {
|
|||||||
返回
|
返回
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-xl lg:text-[28px] font-bold text-text-primary">{mockTaskInfo.taskName}</h1>
|
<h1 className="text-xl lg:text-[28px] font-bold text-text-primary">{viewModel.taskName}</h1>
|
||||||
<p className="text-sm lg:text-[15px] text-text-secondary">查看任务要求和Brief文档</p>
|
<p className="text-sm lg:text-[15px] text-text-secondary">查看任务要求和Brief文档</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => router.push(`/creator/task/${params.id}`)}>
|
<Button onClick={() => router.push(`/creator/task/${params.id}`)}>
|
||||||
@ -121,7 +287,7 @@ export default function TaskBriefPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-text-tertiary">代理商</p>
|
<p className="text-xs text-text-tertiary">代理商</p>
|
||||||
<p className="text-sm font-medium text-text-primary">{mockTaskInfo.agencyName}</p>
|
<p className="text-sm font-medium text-text-primary">{viewModel.agencyName}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@ -130,7 +296,7 @@ export default function TaskBriefPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-text-tertiary">品牌方</p>
|
<p className="text-xs text-text-tertiary">品牌方</p>
|
||||||
<p className="text-sm font-medium text-text-primary">{mockTaskInfo.brandName}</p>
|
<p className="text-sm font-medium text-text-primary">{viewModel.brandName}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@ -139,30 +305,33 @@ export default function TaskBriefPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-text-tertiary">分配时间</p>
|
<p className="text-xs text-text-tertiary">分配时间</p>
|
||||||
<p className="text-sm font-medium text-text-primary">{mockTaskInfo.createdAt}</p>
|
<p className="text-sm font-medium text-text-primary">{viewModel.createdAt}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{viewModel.deadline && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-xl bg-accent-coral/15 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-xl bg-accent-coral/15 flex items-center justify-center">
|
||||||
<Clock className="w-5 h-5 text-accent-coral" />
|
<Clock className="w-5 h-5 text-accent-coral" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-text-tertiary">截止日期</p>
|
<p className="text-xs text-text-tertiary">截止日期</p>
|
||||||
<p className="text-sm font-medium text-text-primary">{mockTaskInfo.deadline}</p>
|
<p className="text-sm font-medium text-text-primary">{viewModel.deadline}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 主要内容区域 - 可滚动 */}
|
{/* 主要内容区域 - 可滚动 */}
|
||||||
<div className="flex-1 overflow-y-auto space-y-6">
|
<div className="flex-1 overflow-y-auto space-y-6">
|
||||||
{/* Brief文档列表 */}
|
{/* Brief文档列表 */}
|
||||||
|
{viewModel.files.length > 0 && (
|
||||||
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
|
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<File className="w-5 h-5 text-accent-indigo" />
|
<File className="w-5 h-5 text-accent-indigo" />
|
||||||
<h3 className="text-base font-semibold text-text-primary">Brief 文档</h3>
|
<h3 className="text-base font-semibold text-text-primary">Brief 文档</h3>
|
||||||
<span className="text-sm text-text-tertiary">({mockAgencyBrief.files.length}个文件)</span>
|
<span className="text-sm text-text-tertiary">({viewModel.files.length}个文件)</span>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="secondary" size="sm" onClick={handleDownloadAll}>
|
<Button variant="secondary" size="sm" onClick={handleDownloadAll}>
|
||||||
<Download className="w-4 h-4" />
|
<Download className="w-4 h-4" />
|
||||||
@ -170,7 +339,7 @@ export default function TaskBriefPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
{mockAgencyBrief.files.map((file) => (
|
{viewModel.files.map((file) => (
|
||||||
<div
|
<div
|
||||||
key={file.id}
|
key={file.id}
|
||||||
className="flex items-center justify-between p-4 bg-bg-elevated rounded-xl hover:bg-bg-page transition-colors"
|
className="flex items-center justify-between p-4 bg-bg-elevated rounded-xl hover:bg-bg-page transition-colors"
|
||||||
@ -207,15 +376,17 @@ export default function TaskBriefPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 内容要求 */}
|
{/* 内容要求 */}
|
||||||
|
{viewModel.contentRequirements.length > 0 && (
|
||||||
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
|
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<FileText className="w-5 h-5 text-accent-amber" />
|
<FileText className="w-5 h-5 text-accent-amber" />
|
||||||
<h3 className="text-base font-semibold text-text-primary">内容要求</h3>
|
<h3 className="text-base font-semibold text-text-primary">内容要求</h3>
|
||||||
</div>
|
</div>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{mockAgencyBrief.contentRequirements.map((req, index) => (
|
{viewModel.contentRequirements.map((req, index) => (
|
||||||
<li key={index} className="flex items-start gap-2 text-sm text-text-secondary">
|
<li key={index} className="flex items-start gap-2 text-sm text-text-secondary">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-accent-amber mt-2 flex-shrink-0" />
|
<span className="w-1.5 h-1.5 rounded-full bg-accent-amber mt-2 flex-shrink-0" />
|
||||||
{req}
|
{req}
|
||||||
@ -223,8 +394,10 @@ export default function TaskBriefPage() {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 卖点要求 */}
|
{/* 卖点要求 */}
|
||||||
|
{viewModel.sellingPoints.length > 0 && (
|
||||||
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
|
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<Target className="w-5 h-5 text-accent-green" />
|
<Target className="w-5 h-5 text-accent-green" />
|
||||||
@ -257,15 +430,17 @@ export default function TaskBriefPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 违禁词 */}
|
{/* 违禁词 */}
|
||||||
|
{viewModel.blacklistWords.length > 0 && (
|
||||||
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
|
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<Ban className="w-5 h-5 text-accent-coral" />
|
<Ban className="w-5 h-5 text-accent-coral" />
|
||||||
<h3 className="text-base font-semibold text-text-primary">违禁词(请勿在内容中使用)</h3>
|
<h3 className="text-base font-semibold text-text-primary">违禁词(请勿在内容中使用)</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{mockAgencyBrief.blacklistWords.map((bw) => (
|
{viewModel.blacklistWords.map((bw) => (
|
||||||
<span
|
<span
|
||||||
key={bw.id}
|
key={bw.id}
|
||||||
className="px-3 py-1.5 text-sm bg-accent-coral/15 text-accent-coral rounded-lg border border-accent-coral/30"
|
className="px-3 py-1.5 text-sm bg-accent-coral/15 text-accent-coral rounded-lg border border-accent-coral/30"
|
||||||
@ -275,6 +450,7 @@ export default function TaskBriefPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 底部操作按钮 */}
|
{/* 底部操作按钮 */}
|
||||||
<div className="flex justify-center py-4">
|
<div className="flex justify-center py-4">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user