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:
Your Name 2026-02-09 16:29:43 +08:00
parent 54eaa54966
commit a8be7bbca9
24 changed files with 5244 additions and 1845 deletions

View File

@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
@ -18,14 +18,47 @@ import {
Download,
File,
Send,
Image as ImageIcon
Image as ImageIcon,
Loader2
} 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'
// 申诉详情类型
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',
taskId: 'task-001',
taskTitle: '夏日护肤推广脚本',
@ -38,6 +71,7 @@ const mockAppealDetail = {
content: '脚本中提到的"某品牌"是泛指并非特指竞品AI系统可能误解了语境。我在脚本中使用的是泛化表述并没有提及任何具体的竞品名称。请代理商重新审核此处谢谢',
status: 'pending' as AppealStatus,
createdAt: '2026-02-06 10:30',
appealCount: 1,
// 附件
attachments: [
{ 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 }> = {
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 toast = useToast()
const params = useParams()
const [appeal] = useState(mockAppealDetail)
const taskId = params.id as string
const [appeal, setAppeal] = useState(mockAppealDetail)
const [replyContent, setReplyContent] = useState('')
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 StatusIcon = status.icon
@ -83,10 +203,26 @@ export default function AgencyAppealDetailPage() {
return
}
setIsSubmitting(true)
// 模拟提交
await new Promise(resolve => setTimeout(resolve, 1000))
toast.success('申诉已通过')
router.push('/agency/appeals')
try {
if (USE_MOCK) {
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('申诉已通过')
router.push('/agency/appeals')
} catch (err) {
console.error('Failed to approve appeal:', err)
toast.error('操作失败,请重试')
setIsSubmitting(false)
}
}
const handleReject = async () => {
@ -95,10 +231,34 @@ export default function AgencyAppealDetailPage() {
return
}
setIsSubmitting(true)
// 模拟提交
await new Promise(resolve => setTimeout(resolve, 1000))
toast.success('申诉已驳回')
router.push('/agency/appeals')
try {
if (USE_MOCK) {
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('申诉已驳回')
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 (
@ -186,7 +346,9 @@ export default function AgencyAppealDetailPage() {
<span className="font-medium text-text-primary">{appeal.originalIssue.title}</span>
</div>
<p className="text-sm text-text-secondary">{appeal.originalIssue.description}</p>
<p className="text-xs text-text-tertiary mt-2">: {appeal.originalIssue.location}</p>
{appeal.originalIssue.location && (
<p className="text-xs text-text-tertiary mt-2">: {appeal.originalIssue.location}</p>
)}
</div>
</CardContent>
</Card>
@ -209,6 +371,10 @@ export default function AgencyAppealDetailPage() {
<span className="text-sm text-text-tertiary"></span>
<p className="text-text-primary mt-1 leading-relaxed">{appeal.content}</p>
</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 && (
@ -303,7 +469,7 @@ export default function AgencyAppealDetailPage() {
onClick={handleApprove}
disabled={isSubmitting}
>
<CheckCircle size={16} />
{isSubmitting ? <Loader2 size={16} className="animate-spin" /> : <CheckCircle size={16} />}
</Button>
<Button
@ -312,7 +478,7 @@ export default function AgencyAppealDetailPage() {
onClick={handleReject}
disabled={isSubmitting}
>
<XCircle size={16} />
{isSubmitting ? <Loader2 size={16} className="animate-spin" /> : <XCircle size={16} />}
</Button>
</div>

View File

@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
@ -15,9 +15,13 @@ import {
ChevronRight,
User,
FileText,
Video
Video,
Loader2
} from 'lucide-react'
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'
@ -118,6 +122,46 @@ const typeConfig: Record<AppealType, { label: string; color: string }> = {
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 }) {
const status = statusConfig[appeal.status]
const type = typeConfig[appeal.type]
@ -191,13 +235,40 @@ function AppealCard({ appeal }: { appeal: Appeal }) {
export default function AgencyAppealsPage() {
const [filter, setFilter] = useState<AppealStatus | 'all'>('all')
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 processingCount = mockAppeals.filter(a => a.status === 'processing').length
const pendingCount = appeals.filter(a => a.status === 'pending').length
const processingCount = appeals.filter(a => a.status === 'processing').length
// 筛选
const filteredAppeals = mockAppeals.filter(appeal => {
const filteredAppeals = appeals.filter(appeal => {
const matchesSearch = searchQuery === '' ||
appeal.taskTitle.toLowerCase().includes(searchQuery.toLowerCase()) ||
appeal.creatorName.toLowerCase().includes(searchQuery.toLowerCase()) ||
@ -270,7 +341,12 @@ export default function AgencyAppealsPage() {
</CardTitle>
</CardHeader>
<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) => (
<AppealCard key={appeal.id} appeal={appeal} />
))

View File

@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
@ -26,9 +26,14 @@ import {
Save,
Upload,
Trash2,
File
File,
Loader2
} from 'lucide-react'
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 = {
@ -39,8 +44,32 @@ type BriefFile = {
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只读
const mockBrandBrief = {
const mockBrandBrief: BrandBriefView = {
id: 'brief-001',
projectName: 'XX品牌618推广',
brandName: 'XX护肤品牌',
@ -58,15 +87,6 @@ const mockBrandBrief = {
},
}
// 代理商上传的Brief文档可编辑
type AgencyFile = {
id: string
name: string
size: string
uploadedAt: string
description?: string
}
// 代理商自己的配置(可编辑)
const mockAgencyConfig = {
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() {
const router = useRouter()
const params = useParams()
const toast = useToast()
const projectId = params.id as string
// 加载状态
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
// 品牌方 Brief只读
const [brandBrief] = useState(mockBrandBrief)
const [brandBrief, setBrandBrief] = useState(mockBrandBrief)
// 代理商配置(可编辑)
const [agencyConfig, setAgencyConfig] = useState(mockAgencyConfig)
@ -148,6 +205,94 @@ export default function BriefConfigPage() {
const [isAIParsing, setIsAIParsing] = 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 rules = platformRules[brandBrief.platform as keyof typeof platformRules] || platformRules.douyin
@ -180,6 +325,42 @@ export default function BriefConfigPage() {
// 保存配置
const handleSave = async () => {
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))
setIsSaving(false)
toast.success('配置已保存!')
@ -263,6 +444,10 @@ export default function BriefConfigPage() {
toast.info(`下载文件: ${file.name}`)
}
if (loading) {
return <BriefDetailSkeleton />
}
return (
<div className="space-y-6">
{/* 顶部导航 */}
@ -290,7 +475,7 @@ export default function BriefConfigPage() {
{isExporting ? '导出中...' : '导出规则'}
</Button>
<Button onClick={handleSave} disabled={isSaving}>
<Save size={16} />
{isSaving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
{isSaving ? '保存中...' : '保存配置'}
</Button>
</div>
@ -357,6 +542,12 @@ export default function BriefConfigPage() {
{brandBrief.files.length}
</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>
</Card>
@ -381,6 +572,9 @@ export default function BriefConfigPage() {
{c}
</span>
))}
{brandBrief.brandRules.competitors.length === 0 && (
<span className="text-sm text-text-tertiary"></span>
)}
</div>
</div>
</CardContent>
@ -609,7 +803,7 @@ export default function BriefConfigPage() {
{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>
<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>
</div>
<button
@ -652,7 +846,7 @@ export default function BriefConfigPage() {
</div>
<div className="flex justify-between">
<span className="text-text-secondary"></span>
<span className="text-text-primary">{agencyConfig.configuredAt}</span>
<span className="text-text-primary">{agencyConfig.configuredAt || '-'}</span>
</div>
</CardContent>
</Card>
@ -705,6 +899,12 @@ export default function BriefConfigPage() {
</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>
</Modal>

View File

@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
@ -13,14 +13,35 @@ import {
CheckCircle,
AlertTriangle,
ChevronRight,
Settings
Settings,
Loader2
} from 'lucide-react'
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',
projectId: 'proj-001',
projectName: 'XX品牌618推广',
brandName: 'XX护肤品牌',
platform: 'douyin',
@ -33,6 +54,7 @@ const mockBriefs = [
},
{
id: 'brief-002',
projectId: 'proj-002',
projectName: '新品口红系列',
brandName: 'XX美妆品牌',
platform: 'xiaohongshu',
@ -45,6 +67,7 @@ const mockBriefs = [
},
{
id: 'brief-003',
projectId: 'proj-003',
projectName: '护肤品秋季活动',
brandName: 'XX护肤品牌',
platform: 'bilibili',
@ -63,19 +86,118 @@ function StatusTag({ status }: { status: string }) {
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() {
const [searchQuery, setSearchQuery] = useState('')
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()) ||
brief.brandName.toLowerCase().includes(searchQuery.toLowerCase())
const matchesStatus = statusFilter === 'all' || brief.status === statusFilter
return matchesSearch && matchesStatus
})
const pendingCount = mockBriefs.filter(b => b.status === 'pending').length
const configuredCount = mockBriefs.filter(b => b.status === 'configured').length
const pendingCount = briefs.filter(b => b.status === 'pending').length
const configuredCount = briefs.filter(b => b.status === 'configured').length
return (
<div className="space-y-6 min-h-0">
@ -143,7 +265,7 @@ export default function AgencyBriefsPage() {
{filteredBriefs.map((brief) => {
const platform = getPlatformInfo(brief.platform)
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">
{/* 平台顶部条 */}
{platform && (

View File

@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
@ -26,9 +26,14 @@ import {
MessageSquareText,
Trash2,
FolderPlus,
X
X,
Loader2
} from 'lucide-react'
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' |
@ -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' },
}
// 后端 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 {
id: string
@ -172,9 +194,19 @@ export default function AgencyCreatorsPage() {
const [inviteCreatorId, setInviteCreatorId] = useState('')
const [inviteResult, setInviteResult] = useState<{ success: boolean; message: string } | null>(null)
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 [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)
@ -189,11 +221,97 @@ export default function AgencyCreatorsPage() {
const [assignModal, setAssignModal] = useState<{ open: boolean; creator: Creator | null }>({ open: false, creator: null })
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 =>
creator.name.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) => {
setExpandedCreators(prev =>
@ -211,45 +329,90 @@ export default function AgencyCreatorsPage() {
}
// 增加申诉次数
const handleAddAppealQuota = (creatorId: string, taskId: string) => {
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
}),
const handleAddAppealQuota = async (creatorId: string, taskId: string) => {
if (USE_MOCK) {
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
}))
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()) {
setInviteResult({ success: false, message: '请输入达人ID' })
return
}
// 模拟检查达人ID是否存在
const idPattern = /^CR\d{6}$/
if (!idPattern.test(inviteCreatorId.toUpperCase())) {
setInviteResult({ success: false, message: '达人ID格式错误应为CR+6位数字' })
if (USE_MOCK) {
// 模拟检查达人ID是否存在
const idPattern = /^CR\d{6}$/
if (!idPattern.test(inviteCreatorId.toUpperCase())) {
setInviteResult({ success: false, message: '达人ID格式错误应为CR+6位数字' })
return
}
// 检查是否已邀请
if (creators.some(c => c.creatorId === inviteCreatorId.toUpperCase())) {
setInviteResult({ success: false, message: '该达人已在您的列表中' })
return
}
// 模拟发送邀请成功
setInviteResult({ success: true, message: `已向达人 ${inviteCreatorId.toUpperCase()} 发送邀请` })
return
}
// 检查是否已邀请
if (creators.some(c => c.creatorId === inviteCreatorId.toUpperCase())) {
setInviteResult({ success: false, message: '该达人已在您的列表中' })
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)
}
// 模拟发送邀请成功
setInviteResult({ success: true, message: `已向达人 ${inviteCreatorId.toUpperCase()} 发送邀请` })
}
const handleCloseInviteModal = () => {
@ -283,11 +446,28 @@ export default function AgencyCreatorsPage() {
}
// 确认删除
const handleConfirmDelete = () => {
if (deleteModal.creator) {
const handleConfirmDelete = async () => {
if (!deleteModal.creator) return
if (USE_MOCK) {
setCreators(prev => prev.filter(c => c.id !== deleteModal.creator!.id))
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)
}
setDeleteModal({ open: false, creator: null })
}
// 打开分配项目弹窗
@ -299,14 +479,66 @@ export default function AgencyCreatorsPage() {
// 确认分配项目
const handleConfirmAssign = () => {
const projectList = USE_MOCK ? mockProjects : projects
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}`)
}
setAssignModal({ open: false, creator: null })
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 (
<div className="space-y-6 min-h-0">
{/* 页面标题 */}
@ -328,7 +560,7 @@ export default function AgencyCreatorsPage() {
<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">{mockCreators.length}</p>
<p className="text-2xl font-bold text-text-primary">{totalCreators}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-accent-indigo/20 flex items-center justify-center">
<Users size={20} className="text-accent-indigo" />
@ -341,7 +573,7 @@ export default function AgencyCreatorsPage() {
<div className="flex items-center justify-between">
<div>
<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 className="w-10 h-10 rounded-lg bg-accent-green/20 flex items-center justify-center">
<CheckCircle size={20} className="text-accent-green" />
@ -354,7 +586,7 @@ export default function AgencyCreatorsPage() {
<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">{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 className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
<FileText size={20} className="text-purple-400" />
@ -367,7 +599,7 @@ export default function AgencyCreatorsPage() {
<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">{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 className="w-10 h-10 rounded-lg bg-orange-500/20 flex items-center justify-center">
<Video size={20} className="text-orange-400" />
@ -488,18 +720,34 @@ export default function AgencyCreatorsPage() {
</div>
</td>
<td className="px-6 py-4">
<StatusTag status={creator.status} />
{USE_MOCK ? (
<StatusTag status={creator.status} />
) : (
<SuccessTag></SuccessTag>
)}
</td>
<td className="px-6 py-4">
<span className="text-text-primary">{creator.scriptCount.passed}</span>
<span className="text-text-tertiary">/{creator.scriptCount.total}</span>
{USE_MOCK ? (
<>
<span className="text-text-primary">{creator.scriptCount.passed}</span>
<span className="text-text-tertiary">/{creator.scriptCount.total}</span>
</>
) : (
<span className="text-text-tertiary">-</span>
)}
</td>
<td className="px-6 py-4">
<span className="text-text-primary">{creator.videoCount.passed}</span>
<span className="text-text-tertiary">/{creator.videoCount.total}</span>
{USE_MOCK ? (
<>
<span className="text-text-primary">{creator.videoCount.passed}</span>
<span className="text-text-tertiary">/{creator.videoCount.total}</span>
</>
) : (
<span className="text-text-tertiary">-</span>
)}
</td>
<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">
<span className={`font-medium ${creator.passRate >= 90 ? 'text-accent-green' : creator.passRate >= 80 ? 'text-accent-indigo' : 'text-orange-400'}`}>
{creator.passRate}%
@ -589,9 +837,14 @@ export default function AgencyCreatorsPage() {
<Button
variant="secondary"
size="sm"
disabled={submitting}
onClick={() => handleAddAppealQuota(creator.id, task.id)}
>
<PlusCircle size={14} />
{submitting ? (
<Loader2 size={14} className="animate-spin" />
) : (
<PlusCircle size={14} />
)}
+1
</Button>
</div>
@ -639,7 +892,8 @@ export default function AgencyCreatorsPage() {
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"
/>
<Button variant="secondary" onClick={handleInvite}>
<Button variant="secondary" onClick={handleInvite} disabled={submitting}>
{submitting ? <Loader2 size={16} className="animate-spin" /> : null}
</Button>
</div>
@ -669,6 +923,9 @@ export default function AgencyCreatorsPage() {
onClick={() => {
if (inviteResult?.success) {
handleCloseInviteModal()
if (!USE_MOCK) {
fetchData() // 刷新达人列表
}
}
}}
disabled={!inviteResult?.success}
@ -734,8 +991,9 @@ export default function AgencyCreatorsPage() {
variant="secondary"
className="border-accent-coral text-accent-coral hover:bg-accent-coral/10"
onClick={handleConfirmDelete}
disabled={submitting}
>
<Trash2 size={16} />
{submitting ? <Loader2 size={16} className="animate-spin" /> : <Trash2 size={16} />}
</Button>
</div>
@ -755,7 +1013,7 @@ export default function AgencyCreatorsPage() {
<div>
<label className="block text-sm font-medium text-text-primary mb-2"></label>
<div className="space-y-2">
{mockProjects.map((project) => (
{projectList.map((project) => (
<label
key={project.id}
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>
</label>
))}
{projectList.length === 0 && (
<p className="text-text-tertiary text-sm text-center py-4"></p>
)}
</div>
</div>
<div className="flex gap-3 justify-end pt-2">

View File

@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
@ -19,9 +19,13 @@ import {
Clock,
FileSpreadsheet,
File,
Check
Check,
Loader2
} from 'lucide-react'
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'
@ -180,9 +184,48 @@ export default function AgencyReportsPage() {
const [exportFormat, setExportFormat] = useState<'csv' | 'excel' | 'pdf'>('excel')
const [isExporting, setIsExporting] = useState(false)
const [exportSuccess, setExportSuccess] = useState(false)
const [loading, setLoading] = useState(true)
const [dashboardData, setDashboardData] = useState<AgencyDashboard | null>(null)
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 () => {
@ -246,6 +289,15 @@ export default function AgencyReportsPage() {
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 (
<div className="space-y-6">
{/* 页面标题 */}
@ -276,6 +328,30 @@ export default function AgencyReportsPage() {
</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">
<StatCard
@ -504,7 +580,7 @@ export default function AgencyReportsPage() {
<Button onClick={handleExport} disabled={isExporting}>
{isExporting ? (
<>
<Clock size={16} className="animate-spin" />
<Loader2 size={16} className="animate-spin" />
...
</>
) : (

View File

@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
@ -15,8 +15,12 @@ import {
Video,
User,
Calendar,
Download
Download,
Loader2
} from 'lucide-react'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import type { TaskResponse } from '@/types/task'
// 审核历史记录类型
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() {
const router = useRouter()
const [searchQuery, setSearchQuery] = useState('')
const [filterResult, setFilterResult] = useState<'all' | 'approved' | 'rejected'>('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 === '' ||
item.taskTitle.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 rejectedCount = mockHistoryData.filter(i => i.result === 'rejected').length
const approvedCount = historyData.filter(i => i.result === 'approved').length
const rejectedCount = historyData.filter(i => i.result === 'rejected').length
return (
<div className="space-y-6">
@ -139,7 +213,7 @@ export default function AgencyReviewHistoryPage() {
<History size={20} className="text-accent-indigo" />
</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>
</div>
</div>
@ -236,7 +310,12 @@ export default function AgencyReviewHistoryPage() {
</CardTitle>
</CardHeader>
<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) => (
<div
key={item.id}

View File

@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
@ -19,9 +19,13 @@ import {
Eye,
Shield,
Download,
MessageSquareWarning
MessageSquareWarning,
Loader2
} from 'lucide-react'
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 = {
@ -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 }) {
const steps = getAgencyReviewSteps(taskStatus)
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() {
const router = useRouter()
const toast = useToast()
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 [showRejectModal, setShowRejectModal] = useState(false)
const [showForcePassModal, setShowForcePassModal] = useState(false)
@ -100,33 +181,99 @@ export default function AgencyScriptReviewPage() {
const [forcePassReason, setForcePassReason] = useState('')
const [viewMode, setViewMode] = useState<'file' | 'parsed'>('file') // 'file' 显示原文件, 'parsed' 显示解析内容
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 = () => {
setShowApproveModal(false)
toast.success('已提交品牌方终审')
router.push('/agency/review')
useEffect(() => {
loadTask()
}, [loadTask])
const handleApprove = async () => {
if (USE_MOCK) {
setShowApproveModal(false)
toast.success('已提交品牌方终审')
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()) {
toast.error('请填写驳回原因')
return
}
setShowRejectModal(false)
toast.success('已驳回')
router.push('/agency/review')
if (USE_MOCK) {
setShowRejectModal(false)
toast.success('已驳回')
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()) {
toast.error('请填写强制通过原因')
return
}
setShowForcePassModal(false)
toast.success('已强制通过并提交品牌方终审')
router.push('/agency/review')
if (USE_MOCK) {
setShowForcePassModal(false)
toast.success('已强制通过并提交品牌方终审')
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 (
@ -226,21 +373,27 @@ export default function AgencyScriptReviewPage() {
</CardTitle>
</CardHeader>
<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="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 className="p-4 bg-bg-elevated rounded-lg">
<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 className="p-4 bg-bg-elevated rounded-lg">
<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 className="p-4 bg-bg-elevated rounded-lg">
<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>
</CardContent>
</Card>
@ -275,7 +428,7 @@ export default function AgencyScriptReviewPage() {
<div className="flex items-center gap-2 mb-1">
<WarningTag>{v.type}</WarningTag>
</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>
</div>
))}
@ -331,6 +484,9 @@ export default function AgencyScriptReviewPage() {
<span className="text-sm text-text-primary">{sp.point}</span>
</div>
))}
{task.aiAnalysis.sellingPoints.length === 0 && (
<p className="text-sm text-text-tertiary text-center py-4"></p>
)}
</CardContent>
</Card>
</div>
@ -344,13 +500,16 @@ export default function AgencyScriptReviewPage() {
{task.projectName}
</div>
<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 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 variant="success" onClick={() => setShowApproveModal(true)}>
<Button variant="success" onClick={() => setShowApproveModal(true)} disabled={submitting}>
{submitting ? <Loader2 size={16} className="animate-spin mr-1" /> : null}
</Button>
</div>
@ -382,8 +541,11 @@ export default function AgencyScriptReviewPage() {
/>
</div>
<div className="flex gap-3 justify-end">
<Button variant="ghost" onClick={() => setShowRejectModal(false)}></Button>
<Button variant="danger" onClick={handleReject}></Button>
<Button variant="ghost" onClick={() => setShowRejectModal(false)} disabled={submitting}></Button>
<Button variant="danger" onClick={handleReject} disabled={submitting}>
{submitting ? <Loader2 size={16} className="animate-spin mr-1" /> : null}
</Button>
</div>
</div>
</Modal>
@ -407,8 +569,11 @@ export default function AgencyScriptReviewPage() {
/>
</div>
<div className="flex gap-3 justify-end">
<Button variant="ghost" onClick={() => setShowForcePassModal(false)}></Button>
<Button onClick={handleForcePass}></Button>
<Button variant="ghost" onClick={() => setShowForcePassModal(false)} disabled={submitting}></Button>
<Button onClick={handleForcePass} disabled={submitting}>
{submitting ? <Loader2 size={16} className="animate-spin mr-1" /> : null}
</Button>
</div>
</div>
</Modal>

View File

@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
@ -21,9 +21,13 @@ import {
XCircle,
Download,
ExternalLink,
MessageSquareWarning
MessageSquareWarning,
Loader2
} from 'lucide-react'
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 = {
@ -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 {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
@ -113,10 +174,41 @@ function RiskLevelTag({ level }: { level: string }) {
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() {
const router = useRouter()
const toast = useToast()
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 [showApproveModal, setShowApproveModal] = useState(false)
const [showRejectModal, setShowRejectModal] = useState(false)
@ -127,33 +219,95 @@ export default function AgencyVideoReviewPage() {
const [checkedViolations, setCheckedViolations] = useState<Record<string, boolean>>({})
const [showFilePreview, setShowFilePreview] = 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 = () => {
setShowApproveModal(false)
toast.success('已提交品牌方终审')
router.push('/agency/review')
useEffect(() => {
loadTask()
}, [loadTask])
const handleApprove = async () => {
if (USE_MOCK) {
setShowApproveModal(false)
toast.success('已提交品牌方终审')
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()) {
toast.error('请填写驳回原因')
return
}
setShowRejectModal(false)
toast.success('已驳回')
router.push('/agency/review')
if (USE_MOCK) {
setShowRejectModal(false)
toast.success('已驳回')
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()) {
toast.error('请填写强制通过原因')
return
}
setShowForcePassModal(false)
toast.success('已强制通过并提交品牌方终审')
router.push('/agency/review')
if (USE_MOCK) {
setShowForcePassModal(false)
toast.success('已强制通过并提交品牌方终审')
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 })),
].sort((a, b) => a.time - b.time)
if (loading) {
return <LoadingSkeleton />
}
return (
<div className="space-y-4">
{/* 顶部导航 */}
@ -298,7 +456,7 @@ export default function AgencyVideoReviewPage() {
</span>
</div>
<p className="text-text-secondary text-sm">
{task.hardViolations.length}{task.sentimentWarnings.length}
{task.aiSummary || `视频整体合规,发现${task.hardViolations.length}处硬性问题和${task.sentimentWarnings.length}处舆情提示需人工确认`}
</p>
</CardContent>
</Card>
@ -329,12 +487,15 @@ export default function AgencyVideoReviewPage() {
<ErrorTag>{v.type}</ErrorTag>
<span className="text-xs text-text-tertiary">{formatTimestamp(v.timestamp)}</span>
</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>
</div>
</div>
</div>
))}
{task.hardViolations.length === 0 && (
<p className="text-sm text-text-tertiary text-center py-4"></p>
)}
</CardContent>
</Card>
@ -355,7 +516,7 @@ export default function AgencyVideoReviewPage() {
<span className="text-xs text-text-tertiary">{formatTimestamp(w.timestamp)}</span>
</div>
<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>
))}
</CardContent>
@ -386,6 +547,9 @@ export default function AgencyVideoReviewPage() {
)}
</div>
))}
{task.sellingPointsCovered.length === 0 && (
<p className="text-sm text-text-tertiary text-center py-4"></p>
)}
</CardContent>
</Card>
</div>
@ -399,13 +563,16 @@ export default function AgencyVideoReviewPage() {
{Object.values(checkedViolations).filter(Boolean).length}/{task.hardViolations.length}
</div>
<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 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 variant="success" onClick={() => setShowApproveModal(true)}>
<Button variant="success" onClick={() => setShowApproveModal(true)} disabled={submitting}>
{submitting ? <Loader2 size={16} className="animate-spin mr-1" /> : null}
</Button>
</div>
@ -430,7 +597,7 @@ export default function AgencyVideoReviewPage() {
<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>
{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 && (
<div className="text-sm text-text-tertiary"></div>
@ -446,8 +613,11 @@ export default function AgencyVideoReviewPage() {
/>
</div>
<div className="flex gap-3 justify-end">
<Button variant="ghost" onClick={() => setShowRejectModal(false)}></Button>
<Button variant="danger" onClick={handleReject}></Button>
<Button variant="ghost" onClick={() => setShowRejectModal(false)} disabled={submitting}></Button>
<Button variant="danger" onClick={handleReject} disabled={submitting}>
{submitting ? <Loader2 size={16} className="animate-spin mr-1" /> : null}
</Button>
</div>
</div>
</Modal>
@ -480,8 +650,11 @@ export default function AgencyVideoReviewPage() {
<span className="text-sm text-text-secondary"></span>
</label>
<div className="flex gap-3 justify-end">
<Button variant="ghost" onClick={() => setShowForcePassModal(false)}></Button>
<Button onClick={handleForcePass}></Button>
<Button variant="ghost" onClick={() => setShowForcePassModal(false)} disabled={submitting}></Button>
<Button onClick={handleForcePass} disabled={submitting}>
{submitting ? <Loader2 size={16} className="animate-spin mr-1" /> : null}
</Button>
</div>
</div>
</Modal>

View File

@ -1,13 +1,37 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
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 { Button } from '@/components/ui/Button'
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',
videoTitle: '美食探店vlog',
creatorName: '吃货小胖',
@ -21,6 +45,7 @@ const mockTaskDetail = {
reviewedAt: '2024-02-04 12:00',
reviewerName: '审核员A',
reviewNotes: '内容积极正面,品牌露出合适,通过审核。',
videoUrl: null,
softWarnings: [
{ 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 }) {
if (status === 'approved') return <SuccessTag></SuccessTag>
if (status === 'rejected') return <ErrorTag></ErrorTag>
@ -39,10 +249,64 @@ function StatusBadge({ status }: { status: string }) {
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() {
const router = useRouter()
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 (
<div className="space-y-6">
@ -67,9 +331,17 @@ export default function TaskDetailPage() {
<Card>
<CardContent className="p-0">
<div className="aspect-video bg-gray-900 rounded-t-lg flex items-center justify-center">
<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" />
</button>
{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">
<Play size={32} className="text-white ml-1" />
</button>
)}
</div>
</CardContent>
</Card>
@ -80,14 +352,14 @@ export default function TaskDetailPage() {
<div className="grid grid-cols-2 gap-6">
<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'}`}>
{task.aiScore}
<div className={`text-3xl font-bold ${task.aiScore != null && task.aiScore >= 80 ? 'text-green-600' : 'text-yellow-600'}`}>
{task.aiScore ?? '-'}
</div>
</div>
<div>
<div className="text-sm text-gray-500"></div>
<div className={`text-3xl font-bold ${task.finalScore >= 80 ? 'text-green-600' : 'text-yellow-600'}`}>
{task.finalScore}
<div className={`text-3xl font-bold ${task.finalScore != null && task.finalScore >= 80 ? 'text-green-600' : 'text-yellow-600'}`}>
{task.finalScore ?? '-'}
</div>
</div>
</div>

View File

@ -1,144 +1,112 @@
'use client'
import { useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Modal } from '@/components/ui/Modal'
import { SuccessTag, PendingTag, WarningTag } from '@/components/ui/Tag'
import { SuccessTag, PendingTag } from '@/components/ui/Tag'
import { useToast } from '@/components/ui/Toast'
import {
Search,
Plus,
Users,
TrendingUp,
TrendingDown,
Copy,
CheckCircle,
Clock,
MoreVertical,
Building2,
AlertCircle,
UserPlus,
MessageSquareText,
Trash2,
FolderPlus
FolderPlus,
Loader2,
} 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'
// 代理商类型
interface Agency {
id: string
agencyId: string // 代理商IDAG开头
name: string
companyName: string
email: string
status: 'active' | 'pending' | 'paused'
creatorCount: number
projectCount: number
passRate: number
trend: 'up' | 'down' | 'stable'
joinedAt: string
remark?: string
// ==================== Mock 数据 ====================
const mockAgencies: AgencyDetail[] = [
{ id: 'AG789012', name: '星耀传媒', contact_name: '张经理', force_pass_enabled: true },
{ id: 'AG456789', name: '创意无限', contact_name: '李总', force_pass_enabled: false },
{ id: 'AG123456', name: '美妆达人MCN', contact_name: '王经理', force_pass_enabled: false },
{ id: 'AG111111', name: '蓝海科技', force_pass_enabled: true },
]
const mockProjects: ProjectResponse[] = [
{ id: 'PJ000001', name: 'XX品牌618推广', brand_id: 'BR000001', status: 'active', agencies: [], task_count: 5, created_at: '2025-06-01', updated_at: '2025-06-01' },
{ id: 'PJ000002', name: '口红系列推广', brand_id: 'BR000001', status: 'active', agencies: [], task_count: 3, created_at: '2025-07-01', updated_at: '2025-07-01' },
]
function StatusTag({ forcePass }: { forcePass: boolean }) {
if (forcePass) return <SuccessTag></SuccessTag>
return <PendingTag></PendingTag>
}
// 模拟项目列表(用于分配代理商)
const mockProjects = [
{ id: 'proj-001', name: 'XX品牌618推广' },
{ id: 'proj-002', name: '口红系列推广' },
{ id: 'proj-003', name: 'XX运动品牌' },
{ id: 'proj-004', name: '护肤品秋季活动' },
]
// 模拟代理商列表
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>
function AgencySkeleton() {
return (
<div className="animate-pulse">
<div className="h-20 bg-bg-elevated rounded-lg mb-2" />
<div className="h-20 bg-bg-elevated rounded-lg mb-2" />
<div className="h-20 bg-bg-elevated rounded-lg" />
</div>
)
}
export default function AgenciesManagePage() {
const toast = useToast()
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 [showInviteModal, setShowInviteModal] = useState(false)
const [inviteAgencyId, setInviteAgencyId] = useState('')
const [inviting, setInviting] = useState(false)
const [inviteResult, setInviteResult] = useState<{ success: boolean; message: 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 [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 =>
agency.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
agency.agencyId.toLowerCase().includes(searchQuery.toLowerCase()) ||
agency.companyName.toLowerCase().includes(searchQuery.toLowerCase())
agency.id.toLowerCase().includes(searchQuery.toLowerCase()) ||
(agency.contact_name || '').toLowerCase().includes(searchQuery.toLowerCase())
)
// 复制代理商ID
@ -149,27 +117,36 @@ export default function AgenciesManagePage() {
}
// 邀请代理商
const handleInvite = () => {
const handleInvite = async () => {
if (!inviteAgencyId.trim()) {
setInviteResult({ success: false, message: '请输入代理商ID' })
return
}
// 检查代理商ID格式
const idPattern = /^AG\d{6}$/
if (!idPattern.test(inviteAgencyId.toUpperCase())) {
setInviteResult({ success: false, message: '代理商ID格式错误应为AG+6位数字' })
return
}
// 检查是否已邀请
if (agencies.some(a => a.agencyId === inviteAgencyId.toUpperCase())) {
if (agencies.some(a => a.id === inviteAgencyId.toUpperCase())) {
setInviteResult({ success: false, message: '该代理商已在您的列表中' })
return
}
// 模拟发送邀请成功
setInviteResult({ success: true, message: `已向代理商 ${inviteAgencyId.toUpperCase()} 发送邀请` })
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()} 发送邀请` })
} catch (err) {
setInviteResult({ success: false, message: err instanceof Error ? err.message : '邀请失败' })
} finally {
setInviting(false)
}
}
const handleCloseInviteModal = () => {
@ -178,40 +155,42 @@ export default function AgenciesManagePage() {
setInviteResult(null)
}
// 打开备注弹窗
const handleOpenRemark = (agency: Agency) => {
setRemarkText(agency.remark || '')
setRemarkModal({ open: true, agency })
setOpenMenuId(null)
}
// 保存备注
const handleSaveRemark = () => {
if (remarkModal.agency) {
setAgencies(prev => prev.map(a =>
a.id === remarkModal.agency!.id ? { ...a, remark: remarkText } : a
))
const handleConfirmInvite = async () => {
if (inviteResult?.success) {
handleCloseInviteModal()
await loadData()
}
setRemarkModal({ open: false, agency: null })
setRemarkText('')
}
// 打开删除确认
const handleOpenDelete = (agency: Agency) => {
const handleOpenDelete = (agency: AgencyDetail) => {
setDeleteModal({ open: true, agency })
setOpenMenuId(null)
}
// 确认删除
const handleConfirmDelete = () => {
if (deleteModal.agency) {
setAgencies(prev => prev.filter(a => a.id !== deleteModal.agency!.id))
const handleConfirmDelete = async () => {
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))
} 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([])
setAssignModal({ open: true, agency })
setOpenMenuId(null)
@ -220,23 +199,34 @@ export default function AgenciesManagePage() {
// 切换项目选择
const toggleProjectSelection = (projectId: string) => {
setSelectedProjects(prev =>
prev.includes(projectId)
? prev.filter(id => id !== projectId)
: [...prev, projectId]
prev.includes(projectId) ? prev.filter(id => id !== projectId) : [...prev, projectId]
)
}
// 确认分配项目
const handleConfirmAssign = () => {
if (assignModal.agency && selectedProjects.length > 0) {
const projectNames = mockProjects
const handleConfirmAssign = async () => {
if (!assignModal.agency || selectedProjects.length === 0) return
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))
.map(p => p.name)
.join('、')
toast.success(`已将代理商「${assignModal.agency.name}」分配到项目「${projectNames}`)
} catch (err) {
toast.error('分配失败')
} finally {
setAssigning(false)
setAssignModal({ open: false, agency: null })
setSelectedProjects([])
}
setAssignModal({ open: false, agency: null })
setSelectedProjects([])
}
return (
@ -254,7 +244,7 @@ export default function AgenciesManagePage() {
</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>
<CardContent className="py-4">
<div className="flex items-center justify-between">
@ -272,8 +262,8 @@ export default function AgenciesManagePage() {
<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-accent-green">{agencies.filter(a => a.status === 'active').length}</p>
<p className="text-sm text-text-secondary"></p>
<p className="text-2xl font-bold text-accent-green">{agencies.filter(a => a.force_pass_enabled).length}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-accent-green/20 flex items-center justify-center">
<CheckCircle size={20} className="text-accent-green" />
@ -285,28 +275,11 @@ export default function AgenciesManagePage() {
<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-yellow-400">{agencies.filter(a => a.status === 'pending').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>
<p className="text-sm text-text-secondary"></p>
<p className="text-2xl font-bold text-text-primary">{projects.length}</p>
</div>
<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>
</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" />
<input
type="text"
placeholder="搜索代理商名称、ID或公司名..."
placeholder="搜索代理商名称、ID..."
value={searchQuery}
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"
@ -328,127 +301,93 @@ export default function AgenciesManagePage() {
{/* 代理商列表 */}
<Card>
<CardContent className="p-0 overflow-x-auto">
<table className="w-full min-w-[900px]">
<thead>
<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">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>
</tr>
</thead>
<tbody>
{filteredAgencies.map((agency) => (
<tr key={agency.id} className="border-b border-border-subtle last:border-0 hover:bg-bg-elevated/50">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-accent-indigo/15 flex items-center justify-center">
<Building2 size={20} className="text-accent-indigo" />
</div>
<div>
<div className="flex items-center gap-2">
<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>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<code className="px-2 py-1 rounded bg-bg-elevated text-sm font-mono text-accent-indigo">
{agency.agencyId}
</code>
<button
type="button"
onClick={() => handleCopyAgencyId(agency.agencyId)}
className="p-1 rounded hover:bg-bg-elevated transition-colors"
title="复制代理商ID"
>
{copiedId === agency.agencyId ? (
<CheckCircle size={14} className="text-accent-green" />
) : (
<Copy size={14} className="text-text-tertiary" />
)}
</button>
</div>
</td>
<td className="px-6 py-4">
<StatusTag status={agency.status} />
</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">
{agency.status === 'active' ? (
<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 className="px-6 py-4 text-sm text-text-tertiary">{agency.joinedAt}</td>
<td className="px-6 py-4">
<div className="relative">
<Button
variant="ghost"
size="sm"
onClick={() => setOpenMenuId(openMenuId === agency.id ? null : agency.id)}
>
<MoreVertical size={16} />
</Button>
{/* 下拉菜单 */}
{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">
<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
type="button"
onClick={() => handleOpenAssign(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"
>
<FolderPlus size={14} className="text-text-secondary" />
</button>
<button
type="button"
onClick={() => handleOpenDelete(agency)}
className="w-full px-4 py-2.5 text-left text-sm text-accent-coral hover:bg-accent-coral/10 flex items-center gap-2"
>
<Trash2 size={14} />
</button>
</div>
)}
</div>
</td>
{loading ? (
<div className="p-6"><AgencySkeleton /></div>
) : (
<table className="w-full min-w-[700px]">
<thead>
<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">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>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{filteredAgencies.map((agency) => (
<tr key={agency.id} className="border-b border-border-subtle last:border-0 hover:bg-bg-elevated/50">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-accent-indigo/15 flex items-center justify-center">
<Building2 size={20} className="text-accent-indigo" />
</div>
<span className="font-medium text-text-primary">{agency.name}</span>
</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<code className="px-2 py-1 rounded bg-bg-elevated text-sm font-mono text-accent-indigo">
{agency.id}
</code>
<button
type="button"
onClick={() => handleCopyAgencyId(agency.id)}
className="p-1 rounded hover:bg-bg-elevated transition-colors"
title="复制代理商ID"
>
{copiedId === agency.id ? (
<CheckCircle size={14} className="text-accent-green" />
) : (
<Copy size={14} className="text-text-tertiary" />
)}
</button>
</div>
</td>
<td className="px-6 py-4 text-text-secondary text-sm">
{agency.contact_name || '-'}
</td>
<td className="px-6 py-4">
<StatusTag forcePass={agency.force_pass_enabled} />
</td>
<td className="px-6 py-4">
<div className="relative">
<Button
variant="ghost"
size="sm"
onClick={() => setOpenMenuId(openMenuId === agency.id ? null : agency.id)}
>
<MoreVertical size={16} />
</Button>
{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">
<button
type="button"
onClick={() => handleOpenAssign(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"
>
<FolderPlus size={14} className="text-text-secondary" />
</button>
<button
type="button"
onClick={() => handleOpenDelete(agency)}
className="w-full px-4 py-2.5 text-left text-sm text-accent-coral hover:bg-accent-coral/10 flex items-center gap-2"
>
<Trash2 size={14} />
</button>
</div>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
{filteredAgencies.length === 0 && (
{!loading && filteredAgencies.length === 0 && (
<div className="text-center py-12 text-text-tertiary">
<Building2 size={48} className="mx-auto mb-4 opacity-50" />
<p></p>
@ -477,8 +416,8 @@ export default function AgenciesManagePage() {
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"
/>
<Button variant="secondary" onClick={handleInvite}>
<Button variant="secondary" onClick={handleInvite} disabled={inviting}>
{inviting ? <Loader2 size={16} className="animate-spin" /> : '查找'}
</Button>
</div>
<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>
<Button
onClick={() => {
if (inviteResult?.success) {
handleCloseInviteModal()
}
}}
disabled={!inviteResult?.success}
>
<Button onClick={handleConfirmInvite} disabled={!inviteResult?.success}>
<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>
</div>
</div>
@ -572,8 +476,9 @@ export default function AgenciesManagePage() {
variant="secondary"
className="border-accent-coral text-accent-coral hover:bg-accent-coral/10"
onClick={handleConfirmDelete}
disabled={deleting}
>
<Trash2 size={16} />
{deleting ? <Loader2 size={16} className="animate-spin" /> : <Trash2 size={16} />}
</Button>
</div>
@ -593,7 +498,7 @@ export default function AgenciesManagePage() {
<div>
<label className="block text-sm font-medium text-text-primary mb-2"></label>
<div className="space-y-2 max-h-60 overflow-y-auto">
{mockProjects.map((project) => {
{projects.map((project) => {
const isSelected = selectedProjects.includes(project.id)
return (
<button
@ -626,8 +531,8 @@ export default function AgenciesManagePage() {
<Button variant="ghost" onClick={() => { setAssignModal({ open: false, agency: null }); setSelectedProjects([]); }}>
</Button>
<Button onClick={handleConfirmAssign} disabled={selectedProjects.length === 0}>
<FolderPlus size={16} />
<Button onClick={handleConfirmAssign} disabled={selectedProjects.length === 0 || assigning}>
{assigning ? <Loader2 size={16} className="animate-spin" /> : <FolderPlus size={16} />}
</Button>
</div>

View File

@ -1,11 +1,8 @@
'use client'
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select'
import { SuccessTag, ErrorTag, PendingTag } from '@/components/ui/Tag'
import { useToast } from '@/components/ui/Toast'
import {
Bot,
@ -21,59 +18,62 @@ import {
RefreshCw,
Clock
} from 'lucide-react'
// AI 服务状态类型
type ServiceStatus = 'healthy' | 'degraded' | 'error' | 'unknown'
interface AIServiceHealth {
status: ServiceStatus
lastChecked: string | null
lastError: string | null
failedCount: number // 连续失败次数
queuedTasks: number // 队列中等待的任务数
}
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import type { AIProvider, AIConfigResponse, ConnectionTestResponse, ModelInfo } from '@/types/ai-config'
// AI 提供商选项
const providerOptions = [
const providerOptions: { value: AIProvider | string; label: string }[] = [
{ value: 'oneapi', label: 'OneAPI 中转服务' },
{ value: 'anthropic', label: 'Anthropic Claude' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'deepseek', label: 'DeepSeek' },
{ value: 'custom', label: '自定义' },
{ value: 'qwen', label: '通义千问' },
{ value: 'doubao', label: '豆包' },
{ value: 'zhipu', label: '智谱' },
{ value: 'moonshot', label: 'Moonshot' },
]
// 模拟可用模型列表
const availableModels = {
llm: [
{ value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5', tags: ['推荐', '高性能'] },
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4', tags: ['性价比'] },
{ value: 'gpt-4o', label: 'GPT-4o', tags: ['文字', '视觉'] },
{ value: 'deepseek-chat', label: 'DeepSeek Chat', tags: ['高性价比'] },
// Mock 可用模型列表
const mockModels: Record<string, ModelInfo[]> = {
text: [
{ id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5' },
{ id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4' },
{ id: 'gpt-4o', name: 'GPT-4o' },
{ id: 'deepseek-chat', name: 'DeepSeek Chat' },
],
vision: [
{ value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5', tags: ['推荐'] },
{ value: 'gpt-4o', label: 'GPT-4o', tags: ['视觉'] },
{ value: 'doubao-seed-1.6-thinking-vision', label: '豆包 Vision', tags: ['中文优化'] },
{ id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5' },
{ id: 'gpt-4o', name: 'GPT-4o' },
],
asr: [
{ value: 'whisper-large-v3', label: 'Whisper Large V3', tags: ['推荐'] },
{ value: 'whisper-medium', label: 'Whisper Medium', tags: ['快速'] },
{ value: 'paraformer-zh', label: '达摩院 Paraformer', tags: ['中文优化'] },
audio: [
{ id: 'whisper-large-v3', name: 'Whisper Large V3' },
{ id: 'whisper-medium', name: 'Whisper Medium' },
],
}
type TestResult = {
llm: 'idle' | 'testing' | 'success' | 'failed'
vision: 'idle' | 'testing' | 'success' | 'failed'
asr: 'idle' | 'testing' | 'success' | 'failed'
type TestStatus = 'idle' | 'testing' | 'success' | 'failed'
function ConfigSkeleton() {
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() {
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 [apiKey, setApiKey] = useState('')
const [showApiKey, setShowApiKey] = useState(false)
const [isConfigured, setIsConfigured] = useState(false)
const [llmModel, setLlmModel] = useState('claude-opus-4-5-20251101')
const [visionModel, setVisionModel] = useState('claude-opus-4-5-20251101')
@ -82,116 +82,147 @@ export default function AIConfigPage() {
const [temperature, setTemperature] = useState(0.7)
const [maxTokens, setMaxTokens] = useState(2000)
const [testResults, setTestResults] = useState<TestResult>({
llm: 'idle',
vision: 'idle',
asr: 'idle',
const [availableModels, setAvailableModels] = useState<Record<string, ModelInfo[]>>(mockModels)
const [testResults, setTestResults] = useState<Record<string, { status: TestStatus; latency?: number; error?: string }>>({
text: { status: 'idle' },
vision: { status: 'idle' },
audio: { status: 'idle' },
})
// AI 服务健康状态(模拟数据,实际从后端获取)
const [serviceHealth, setServiceHealth] = useState<AIServiceHealth>({
status: 'healthy',
lastChecked: '2026-02-06 16:30:00',
lastError: null,
failedCount: 0,
queuedTasks: 0,
})
const loadConfig = useCallback(async () => {
if (USE_MOCK) {
setLoading(false)
return
}
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])
// 模拟检查服务状态
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,
})
}
// 页面加载时检查服务状态
useEffect(() => {
checkServiceHealth()
}, [])
useEffect(() => { loadConfig() }, [loadConfig])
const handleTestConnection = async () => {
// 模拟测试连接
setTestResults({ llm: 'testing', vision: 'testing', asr: 'testing' })
setTestResults({
text: { status: 'testing' },
vision: { status: 'testing' },
audio: { status: 'testing' },
})
// 模拟延迟
await new Promise(resolve => setTimeout(resolve, 1500))
setTestResults(prev => ({ ...prev, llm: 'success' }))
if (USE_MOCK) {
await new Promise(resolve => setTimeout(resolve, 1500))
setTestResults(prev => ({ ...prev, text: { status: 'success', latency: 320 } }))
await new Promise(resolve => setTimeout(resolve, 1000))
setTestResults(prev => ({ ...prev, vision: { status: 'success', latency: 450 } }))
await new Promise(resolve => setTimeout(resolve, 800))
setTestResults(prev => ({ ...prev, audio: { status: 'success', latency: 280 } }))
return
}
await new Promise(resolve => setTimeout(resolve, 1000))
setTestResults(prev => ({ ...prev, vision: 'success' }))
await new Promise(resolve => setTimeout(resolve, 800))
setTestResults(prev => ({ ...prev, asr: 'success' }))
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 = () => {
toast.success('配置已保存')
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('配置已保存')
} catch (err) {
toast.error('保存失败')
} finally {
setSaving(false)
}
}
const getTestStatusIcon = (status: string) => {
switch (status) {
const getTestStatusIcon = (key: string) => {
const result = testResults[key]
if (!result) return null
switch (result.status) {
case 'testing':
return <Loader2 size={16} className="text-blue-500 animate-spin" />
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':
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:
return null
}
}
// 获取服务状态配置
const getServiceStatusConfig = (status: ServiceStatus) => {
switch (status) {
case 'healthy':
return {
label: '服务正常',
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,
}
}
if (loading) {
return (
<div className="space-y-6 max-w-4xl">
<h1 className="text-2xl font-bold text-text-primary">AI </h1>
<ConfigSkeleton />
</div>
)
}
const statusConfig = getServiceStatusConfig(serviceHealth.status)
const StatusIcon = statusConfig.icon
return (
<div className="space-y-6 max-w-4xl">
<div className="flex items-center justify-between">
@ -199,58 +230,13 @@ export default function AIConfigPage() {
<h1 className="text-2xl font-bold text-text-primary">AI </h1>
<p className="text-sm text-text-secondary mt-1"> AI </p>
</div>
{/* 服务状态标签 */}
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-lg ${statusConfig.bgColor} border ${statusConfig.borderColor}`}>
<StatusIcon size={16} className={statusConfig.color} />
<span className={`text-sm font-medium ${statusConfig.color}`}>{statusConfig.label}</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>
{isConfigured && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-accent-green/15 border border-accent-green/30">
<CheckCircle size={16} className="text-accent-green" />
<span className="text-sm font-medium text-accent-green"></span>
</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">
@ -286,7 +272,7 @@ export default function AIConfigPage() {
))}
</select>
<p className="text-xs text-text-tertiary mt-1">
OneAPIAnthropic ClaudeOpenAIDeepSeek
使 OneAPI 便 AI
</p>
</div>
</CardContent>
@ -306,17 +292,15 @@ export default function AIConfigPage() {
<div className="flex items-center gap-2 mb-3">
<Bot size={16} className="text-accent-indigo" />
<span className="font-medium text-text-primary"> (LLM)</span>
{getTestStatusIcon(testResults.llm)}
{getTestStatusIcon('text')}
</div>
<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"
value={llmModel}
onChange={(e) => setLlmModel(e.target.value)}
>
{availableModels.llm.map(model => (
<option key={model.value} value={model.value}>
{model.label} [{model.tags.join(', ')}]
</option>
{(availableModels.text || []).map(model => (
<option key={model.id} value={model.id}>{model.name}</option>
))}
</select>
<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">
<Eye size={16} className="text-accent-green" />
<span className="font-medium text-text-primary"> (Vision)</span>
{getTestStatusIcon(testResults.vision)}
{getTestStatusIcon('vision')}
</div>
<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"
value={visionModel}
onChange={(e) => setVisionModel(e.target.value)}
>
{availableModels.vision.map(model => (
<option key={model.value} value={model.value}>
{model.label} [{model.tags.join(', ')}]
</option>
{(availableModels.vision || []).map(model => (
<option key={model.id} value={model.id}>{model.name}</option>
))}
</select>
<p className="text-xs text-text-tertiary mt-2">/Logo CV </p>
<p className="text-xs text-text-tertiary mt-2">/</p>
</div>
{/* 音频解析模型 */}
@ -348,17 +330,15 @@ export default function AIConfigPage() {
<div className="flex items-center gap-2 mb-3">
<Mic size={16} className="text-orange-400" />
<span className="font-medium text-text-primary"> (ASR)</span>
{getTestStatusIcon(testResults.asr)}
{getTestStatusIcon('audio')}
</div>
<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"
value={asrModel}
onChange={(e) => setAsrModel(e.target.value)}
>
{availableModels.asr.map(model => (
<option key={model.value} value={model.value}>
{model.label} [{model.tags.join(', ')}]
</option>
{(availableModels.audio || []).map(model => (
<option key={model.id} value={model.id}>{model.name}</option>
))}
</select>
<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"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="sk-..."
placeholder={isConfigured ? '留空使用已保存的密钥' : 'sk-...'}
/>
<Button
variant="secondary"
@ -464,8 +444,8 @@ export default function AIConfigPage() {
<Button variant="secondary" onClick={handleTestConnection}>
</Button>
<Button onClick={handleSave}>
<Button onClick={handleSave} disabled={saving}>
{saving ? <><Loader2 size={16} className="animate-spin" /> ...</> : '保存配置'}
</Button>
</div>
</div>

View File

@ -1,137 +1,159 @@
'use client'
import { useState } from 'react'
import { Plus, FileText, Upload, Trash2, Edit, Check, Search, X, Eye } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { useState, useEffect, useCallback } from 'react'
import { Plus, FileText, Trash2, Edit, Search, Eye, Loader2 } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Modal } from '@/components/ui/Modal'
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'
// 平台选项
const platformOptions = [
{ id: 'douyin', name: '抖音', icon: '🎵', color: 'bg-[#1a1a1a]' },
{ id: 'xiaohongshu', name: '小红书', icon: '📕', color: 'bg-[#fe2c55]' },
{ id: 'bilibili', name: 'B站', icon: '📺', color: 'bg-[#00a1d6]' },
{ id: 'kuaishou', name: '快手', icon: '⚡', color: 'bg-[#ff4906]' },
{ id: 'weibo', name: '微博', icon: '🔴', color: 'bg-[#e6162d]' },
{ id: 'wechat', name: '微信视频号', icon: '💬', color: 'bg-[#07c160]' },
]
// Brief + Project 联合视图
interface BriefItem {
projectId: string
projectName: string
projectStatus: string
brief: BriefResponse | null
updatedAt: string
}
// 模拟 Brief 列表
const mockBriefs = [
// ==================== Mock 数据 ====================
const mockBriefItems: BriefItem[] = [
{
id: 'brief-001',
name: '2024 夏日护肤活动',
description: '夏日护肤系列产品推广规范',
status: 'active',
platforms: ['douyin', 'xiaohongshu'],
rulesCount: 12,
creatorsCount: 45,
createdAt: '2024-01-15',
projectId: 'PJ000001',
projectName: '2024 夏日护肤活动',
projectStatus: 'active',
brief: {
id: 'BF000001',
project_id: 'PJ000001',
brand_tone: '清新自然',
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',
},
{
id: 'brief-002',
name: '新品口红上市',
description: '春季新品口红营销 Brief',
status: 'active',
platforms: ['xiaohongshu', 'bilibili'],
rulesCount: 8,
creatorsCount: 32,
createdAt: '2024-02-01',
projectId: 'PJ000002',
projectName: '新品口红上市',
projectStatus: 'active',
brief: {
id: 'BF000002',
project_id: 'PJ000002',
brand_tone: '时尚摩登',
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',
},
{
id: 'brief-003',
name: '年货节活动',
description: '春节年货促销活动规范',
status: 'archived',
platforms: ['douyin', 'kuaishou'],
rulesCount: 15,
creatorsCount: 78,
createdAt: '2024-01-01',
projectId: 'PJ000003',
projectName: '年货节活动',
projectStatus: 'completed',
brief: null,
updatedAt: '2024-01-20',
},
]
export default function BriefsPage() {
const [briefs, setBriefs] = useState(mockBriefs)
const [showCreateModal, setShowCreateModal] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
function BriefSkeleton() {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 animate-pulse">
{[1, 2, 3].map(i => (
<div key={i} className="h-64 bg-bg-elevated rounded-xl" />
))}
</div>
)
}
// 新建 Brief 表单
const [newBriefName, setNewBriefName] = useState('')
const [newBriefDesc, setNewBriefDesc] = useState('')
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([])
export default function BriefsPage() {
const toast = useToast()
const [briefItems, setBriefItems] = useState<BriefItem[]>([])
const [loading, setLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState('')
// 查看详情
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) =>
brief.name.toLowerCase().includes(searchQuery.toLowerCase())
const loadData = useCallback(async () => {
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 详情
const viewBriefDetail = (brief: typeof mockBriefs[0]) => {
setSelectedBrief(brief)
const viewBriefDetail = (item: BriefItem) => {
setSelectedItem(item)
setShowDetailModal(true)
}
// 删除 Brief
const handleDeleteBrief = (id: string) => {
setBriefs(briefs.filter(b => b.id !== id))
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<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>
<Button onClick={() => setShowCreateModal(true)}>
<Plus size={16} />
Brief
</Button>
</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" />
<input
type="text"
placeholder="搜索 Brief..."
placeholder="搜索项目名称..."
value={searchQuery}
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"
@ -147,270 +169,176 @@ export default function BriefsPage() {
</div>
{/* Brief 列表 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredBriefs.map((brief) => (
<Card key={brief.id} className="hover:shadow-md transition-shadow border border-border-subtle">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="p-2 bg-accent-indigo/15 rounded-lg">
<FileText size={24} className="text-accent-indigo" />
</div>
{brief.status === 'active' ? (
<SuccessTag>使</SuccessTag>
) : (
<PendingTag></PendingTag>
)}
</div>
<h3 className="font-semibold text-text-primary mb-1">{brief.name}</h3>
<p className="text-sm text-text-tertiary mb-3">{brief.description}</p>
{/* 平台标签 */}
<div className="flex flex-wrap gap-1.5 mb-3">
{brief.platforms.map(platformId => {
const platform = getPlatformInfo(platformId)
return platform ? (
<span
key={platformId}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-bg-elevated text-xs text-text-secondary"
>
<span>{platform.icon}</span>
{platform.name}
</span>
) : null
})}
</div>
<div className="flex gap-4 text-sm text-text-tertiary mb-4">
<span>{brief.rulesCount} </span>
<span>{brief.creatorsCount} </span>
</div>
<div className="flex items-center justify-between pt-3 border-t border-border-subtle">
<span className="text-xs text-text-tertiary">
{brief.updatedAt}
</span>
<div className="flex gap-1">
<button
type="button"
onClick={() => viewBriefDetail(brief)}
className="p-1.5 hover:bg-bg-elevated rounded-lg transition-colors"
title="查看详情"
>
<Eye size={16} className="text-text-tertiary hover:text-accent-indigo" />
</button>
<button
type="button"
className="p-1.5 hover:bg-bg-elevated rounded-lg transition-colors"
title="编辑"
>
<Edit size={16} className="text-text-tertiary hover:text-accent-indigo" />
</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>
</CardContent>
</Card>
))}
{/* 新建卡片 */}
<button
type="button"
onClick={() => setShowCreateModal(true)}
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}
{loading ? (
<BriefSkeleton />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredItems.map((item) => (
<Card key={item.projectId} className="hover:shadow-md transition-shadow border border-border-subtle">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="p-2 bg-accent-indigo/15 rounded-lg">
<FileText size={24} className="text-accent-indigo" />
</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>
{item.brief ? (
<SuccessTag></SuccessTag>
) : (
<PendingTag></PendingTag>
)}
</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>
<h3 className="font-semibold text-text-primary mb-1">{item.projectName}</h3>
<p className="text-sm text-text-tertiary mb-3">
{item.brief ? (
<>
{item.brief.brand_tone && `调性: ${item.brief.brand_tone}`}
{(item.brief.selling_points?.length ?? 0) > 0 && ` · ${item.brief.selling_points!.length} 个卖点`}
</>
) : (
'该项目尚未配置 Brief'
)}
</p>
<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>
{item.brief && (
<div className="flex gap-4 text-sm text-text-tertiary mb-4">
<span>{item.brief.selling_points?.length || 0} </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 className="flex items-center justify-between pt-3 border-t border-border-subtle">
<span className="text-xs text-text-tertiary">
{item.updatedAt?.split('T')[0] || '-'}
</span>
<div className="flex gap-1">
{item.brief && (
<button
type="button"
onClick={() => viewBriefDetail(item)}
className="p-1.5 hover:bg-bg-elevated rounded-lg transition-colors"
title="查看详情"
>
<Eye size={16} className="text-text-tertiary hover:text-accent-indigo" />
</button>
)}
<button
type="button"
onClick={() => {
window.location.href = `/brand/projects/${item.projectId}/config`
}}
className="p-1.5 hover:bg-bg-elevated rounded-lg transition-colors"
title="编辑 Brief"
>
<Edit size={16} className="text-text-tertiary hover:text-accent-indigo" />
</button>
</div>
</div>
</CardContent>
</Card>
))}
{filteredItems.length === 0 && !loading && (
<div className="col-span-3 text-center py-12 text-text-tertiary">
<FileText size={48} className="mx-auto mb-4 opacity-50" />
<p></p>
</div>
)}
</div>
</Modal>
)}
{/* Brief 详情弹窗 */}
<Modal
isOpen={showDetailModal}
onClose={() => {
setShowDetailModal(false)
setSelectedBrief(null)
setSelectedItem(null)
}}
title={selectedBrief?.name || 'Brief 详情'}
title={selectedItem?.projectName ? `Brief - ${selectedItem.projectName}` : 'Brief 详情'}
size="lg"
>
{selectedBrief && (
{selectedItem?.brief && (
<div className="space-y-5">
<div className="flex items-center gap-4 p-4 rounded-xl bg-bg-elevated">
<div className="p-3 bg-accent-indigo/15 rounded-xl">
<FileText size={28} className="text-accent-indigo" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-text-primary">{selectedBrief.name}</h3>
<p className="text-sm text-text-tertiary mt-0.5">{selectedBrief.description}</p>
<h3 className="text-lg font-semibold text-text-primary">{selectedItem.projectName}</h3>
{selectedItem.brief.brand_tone && (
<p className="text-sm text-text-tertiary mt-0.5">: {selectedItem.brief.brand_tone}</p>
)}
</div>
{selectedBrief.status === 'active' ? (
<SuccessTag>使</SuccessTag>
) : (
<PendingTag></PendingTag>
)}
<SuccessTag></SuccessTag>
</div>
{/* 应用的平台规则库 */}
<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>
{/* 卖点列表 */}
{(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>
) : null
})}
))}
</div>
</div>
</div>
)}
{/* 统计数据 */}
<div className="grid grid-cols-3 gap-4">
<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-sm text-text-secondary mt-1"></p>
{/* 违禁词 */}
{(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>
<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-sm text-text-secondary mt-1"></p>
)}
{/* 时长要求 */}
{(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">
<p className="text-2xl font-bold text-accent-indigo">{selectedItem.brief.min_duration || '-'}</p>
<p className="text-sm text-text-secondary mt-1"></p>
</div>
<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">{selectedItem.brief.max_duration || '-'}</p>
<p className="text-sm text-text-secondary mt-1"></p>
</div>
</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>
)}
{/* 其他要求 */}
{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>
)}
{/* 时间信息 */}
<div className="flex items-center justify-between p-4 rounded-xl bg-bg-elevated text-sm">
<div>
<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>
<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>
@ -418,7 +346,10 @@ export default function BriefsPage() {
<Button variant="ghost" onClick={() => setShowDetailModal(false)}>
</Button>
<Button>
<Button onClick={() => {
setShowDetailModal(false)
window.location.href = `/brand/projects/${selectedItem.projectId}/config`
}}>
Brief
</Button>
</div>

View File

@ -1,49 +1,41 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
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 { getPlatformInfo } from '@/lib/platforms'
import { useToast } from '@/components/ui/Toast'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import type { TaskResponse } from '@/types/task'
// 模拟待审核内容列表
const mockReviewItems = [
// ==================== Mock 数据 ====================
const mockReviewItems: TaskResponse[] = [
{
id: 'review-001',
title: '春季护肤新品体验分享',
creator: '小美',
agency: '代理商A',
platform: 'douyin',
reviewer: '张三',
reviewTime: '2小时前',
agencyOpinion: '内容符合Brief要求卖点覆盖完整建议通过。',
agencyStatus: 'passed',
aiScore: 12,
aiChecks: [
{ label: '合规检测', status: 'passed', description: '未检测到违禁词、竞品Logo等违规内容' },
{ label: '卖点覆盖', status: 'passed', description: '核心卖点覆盖率 95%' },
{ label: '品牌调性', status: 'passed', description: '视觉风格符合品牌调性' },
],
currentStep: 4, // 1-已提交, 2-AI审核, 3-代理商审核, 4-品牌终审
},
{
id: 'review-002',
title: '夏日清爽护肤推荐',
creator: '小红',
agency: '代理商B',
platform: 'xiaohongshu',
reviewer: '李四',
reviewTime: '5小时前',
agencyOpinion: '内容质量良好,但部分镜头略暗,建议后期调整后通过。',
agencyStatus: 'passed',
aiScore: 28,
aiChecks: [
{ label: '合规检测', status: 'passed', description: '未检测到违规内容' },
{ label: '卖点覆盖', status: 'warning', description: '核心卖点覆盖率 78%,建议增加产品特写' },
{ label: '品牌调性', status: 'passed', description: '视觉风格符合品牌调性' },
],
currentStep: 4,
id: 'TK000001',
name: '春季护肤新品体验分享',
sequence: 1,
stage: 'video_brand_review',
project: { id: 'PJ000001', name: 'XX品牌618推广' },
agency: { id: 'AG000001', name: '代理商A' },
creator: { id: 'CR000001', name: '小美' },
video_file_url: '/demo/video.mp4',
video_file_name: '春季护肤_成片v2.mp4',
video_duration: 135,
video_ai_score: 88,
video_ai_result: {
score: 88,
violations: [],
soft_warnings: [],
summary: '视频整体合规,卖点覆盖完整。',
},
video_agency_status: 'passed',
video_agency_comment: '内容符合Brief要求卖点覆盖完整建议通过。',
appeal_count: 0,
is_appeal: false,
created_at: '2026-02-06T14:00:00Z',
updated_at: '2026-02-06T16:00:00Z',
},
]
@ -98,20 +90,99 @@ 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() {
const router = useRouter()
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 [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 () => {
setIsSubmitting(true)
// 模拟提交
await new Promise(resolve => setTimeout(resolve, 1000))
toast.success('已通过审核')
setIsSubmitting(false)
try {
if (USE_MOCK) {
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('已通过审核')
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)
}
}
const handleReject = async () => {
@ -120,11 +191,25 @@ export default function FinalReviewPage() {
return
}
setIsSubmitting(true)
// 模拟提交
await new Promise(resolve => setTimeout(resolve, 1000))
toast.success('已驳回')
setIsSubmitting(false)
setFeedback('')
try {
if (USE_MOCK) {
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('已驳回')
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 (
@ -134,25 +219,30 @@ export default function FinalReviewPage() {
<div className="flex flex-col gap-1">
<div className="flex items-center gap-3">
<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 ${platform.bgColor} ${platform.textColor} border ${platform.borderColor}`}>
<span>{platform.icon}</span>
{platform.name}
</span>
)}
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-lg text-sm font-medium ${
isVideoReview ? 'bg-purple-500/15 text-purple-400' : 'bg-accent-indigo/15 text-accent-indigo'
}`}>
{isVideoReview ? <Video size={14} /> : <FileText size={14} />}
{isVideoReview ? '视频终审' : '脚本终审'}
</span>
</div>
<p className="text-sm text-text-secondary">
{selectedItem.title} · : {selectedItem.creator}
{selectedItem.name} · : {selectedItem.creator.name}
</p>
</div>
<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 className="flex items-center gap-3">
<span className="text-sm text-text-tertiary">
{selectedIndex + 1} / {tasks.length}
</span>
<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>
</div>
{/* 审核流程进度 */}
@ -161,22 +251,38 @@ export default function FinalReviewPage() {
<span className="text-sm font-semibold text-text-primary"></span>
<span className="text-xs text-accent-indigo font-medium"></span>
</div>
<ReviewProgressBar currentStep={selectedItem.currentStep} />
<ReviewProgressBar currentStep={4} />
</div>
{/* 主内容区 - 两栏布局 */}
<div className="flex gap-6 flex-1 min-h-0">
{/* 左侧 - 视频播放器 */}
{/* 左侧 - 预览 */}
<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="w-[640px] h-[360px] rounded-xl bg-black flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="w-20 h-20 rounded-full bg-[#1A1A1E] flex items-center justify-center">
<Video className="w-10 h-10 text-text-tertiary" />
{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="w-20 h-20 rounded-full bg-[#1A1A1E] flex items-center justify-center">
<Video className="w-10 h-10 text-text-tertiary" />
</div>
<p className="text-sm text-text-tertiary"></p>
</div>
<p className="text-sm text-text-tertiary"></p>
)
) : (
<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>
@ -188,16 +294,20 @@ export default function FinalReviewPage() {
<span className="text-base font-semibold text-text-primary"></span>
<span className={cn(
'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>
</div>
<div className="bg-bg-elevated rounded-[10px] p-3 flex flex-col gap-2">
<span className="text-xs text-text-tertiary">
{selectedItem.agency} - {selectedItem.reviewer} · {selectedItem.reviewTime}
{selectedItem.agency.name}
</span>
<p className="text-[13px] text-text-secondary">{selectedItem.agencyOpinion}</p>
<p className="text-[13px] text-text-secondary">
{agencyComment || '无评论'}
</p>
</div>
</div>
@ -207,29 +317,34 @@ export default function FinalReviewPage() {
<span className="text-base font-semibold text-text-primary">AI </span>
<span className={cn(
'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>
</div>
<div className="flex flex-col gap-3">
{selectedItem.aiChecks.map((check, index) => (
<div key={index} className="bg-bg-elevated rounded-[10px] p-3 flex flex-col gap-2">
<div className="flex items-center gap-2">
<CheckSquare className={cn(
'w-4 h-4',
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>
{aiResult?.violations && aiResult.violations.length > 0 ? (
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">
<CheckSquare className="w-4 h-4 text-accent-coral" />
<span className="text-sm font-semibold text-accent-coral">{v.type}</span>
</div>
<p className="text-[13px] text-text-secondary">{v.content}</p>
{v.suggestion && (
<p className="text-xs text-accent-indigo">{v.suggestion}</p>
)}
</div>
<p className="text-[13px] text-text-secondary">{check.description}</p>
))
) : (
<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>
@ -245,7 +360,7 @@ export default function FinalReviewPage() {
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"
>
<Check className="w-[18px] h-[18px]" />
{isSubmitting ? <Loader2 className="w-[18px] h-[18px] animate-spin" /> : <Check className="w-[18px] h-[18px]" />}
</button>
<button
@ -254,7 +369,7 @@ export default function FinalReviewPage() {
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"
>
<X className="w-[18px] h-[18px]" />
{isSubmitting ? <Loader2 className="w-[18px] h-[18px] animate-spin" /> : <X className="w-[18px] h-[18px]" />}
</button>
</div>
@ -262,7 +377,7 @@ export default function FinalReviewPage() {
{/* 终审意见 */}
<div className="flex flex-col gap-2">
<label className="text-[13px] font-medium text-text-secondary">
</label>
<textarea
value={feedback}

View File

@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
@ -19,12 +19,17 @@ import {
Download,
Eye,
File,
MessageSquareWarning
MessageSquareWarning,
Loader2,
} from 'lucide-react'
import { Modal } from '@/components/ui/Modal'
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 = [
{
id: 'script-001',
@ -59,7 +64,6 @@ const mockScriptTasks = [
},
]
// 模拟视频待审列表
const mockVideoTasks = [
{
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 }) {
if (score >= 85) return <SuccessTag>{score}</SuccessTag>
if (score >= 70) return <WarningTag>{score}</WarningTag>
return <ErrorTag>{score}</ErrorTag>
}
type ScriptTask = typeof mockScriptTasks[0]
type VideoTask = typeof mockVideoTasks[0]
function TaskCard({
task,
type,
onPreview
}: {
task: ScriptTask | VideoTask
task: UITask
type: 'script' | 'video'
onPreview: (task: ScriptTask | VideoTask, type: 'script' | 'video') => void
onPreview: (task: UITask, type: 'script' | 'video') => void
}) {
const toast = useToast()
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-xs text-text-tertiary">
{task.fileSize}
{'duration' in task && ` · ${task.duration}`}
{task.duration && ` · ${task.duration}`}
</p>
</div>
<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() {
const toast = useToast()
const [searchQuery, setSearchQuery] = useState('')
const [activeTab, setActiveTab] = useState<'all' | 'script' | 'video'>('all')
const [previewTask, setPreviewTask] = useState<{ task: ScriptTask | VideoTask; type: 'script' | 'video' } | null>(null)
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.creatorName.toLowerCase().includes(searchQuery.toLowerCase())
)
const filteredVideos = mockVideoTasks.filter(task =>
const filteredVideos = videoTasks.filter(task =>
task.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
task.creatorName.toLowerCase().includes(searchQuery.toLowerCase())
)
// 计算申诉数量
const appealScriptCount = mockScriptTasks.filter(t => t.isAppeal).length
const appealVideoCount = mockVideoTasks.filter(t => t.isAppeal).length
const appealScriptCount = scriptTasks.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 })
}
@ -277,18 +455,27 @@ export default function BrandReviewListPage() {
<p className="text-sm text-text-secondary mt-1"></p>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-text-secondary"></span>
<span className="px-2 py-1 bg-accent-indigo/20 text-accent-indigo rounded font-medium">
{mockScriptTasks.length}
</span>
<span className="px-2 py-1 bg-purple-500/20 text-purple-400 rounded font-medium">
{mockVideoTasks.length}
</span>
{(appealScriptCount + appealVideoCount) > 0 && (
<span className="px-2 py-1 bg-accent-amber/20 text-accent-amber rounded font-medium flex items-center gap-1">
<MessageSquareWarning size={14} />
{appealScriptCount + appealVideoCount}
{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="px-2 py-1 bg-accent-indigo/20 text-accent-indigo rounded font-medium">
{scriptTasks.length}
</span>
<span className="px-2 py-1 bg-purple-500/20 text-purple-400 rounded font-medium">
{videoTasks.length}
</span>
{(appealScriptCount + appealVideoCount) > 0 && (
<span className="px-2 py-1 bg-accent-amber/20 text-accent-amber rounded font-medium flex items-center gap-1">
<MessageSquareWarning size={14} />
{appealScriptCount + appealVideoCount}
</span>
)}
</>
)}
</div>
</div>
@ -336,6 +523,16 @@ export default function BrandReviewListPage() {
</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">
{/* 脚本待审列表 */}
@ -346,12 +543,14 @@ export default function BrandReviewListPage() {
<FileText size={18} className="text-accent-indigo" />
<span className="ml-auto text-sm font-normal text-text-secondary">
{filteredScripts.length}
{loading ? '...' : `${filteredScripts.length} 条待审`}
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{filteredScripts.length > 0 ? (
{loading ? (
<TaskListSkeleton count={2} />
) : filteredScripts.length > 0 ? (
filteredScripts.map((task) => (
<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" />
<span className="ml-auto text-sm font-normal text-text-secondary">
{filteredVideos.length}
{loading ? '...' : `${filteredVideos.length} 条待审`}
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{filteredVideos.length > 0 ? (
{loading ? (
<TaskListSkeleton count={3} />
) : filteredVideos.length > 0 ? (
filteredVideos.map((task) => (
<TaskCard key={task.id} task={task} type="video" onPreview={handlePreview} />
))
@ -437,10 +638,10 @@ export default function BrandReviewListPage() {
<span>{previewTask?.task.fileName}</span>
<span className="mx-2">·</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>{(previewTask.task as VideoTask).duration}</span>
<span>{previewTask.task.duration}</span>
</>
)}
</div>

View File

@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
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 { ReviewSteps, getBrandReviewSteps } from '@/components/ui/ReviewSteps'
import { useToast } from '@/components/ui/Toast'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import {
ArrowLeft,
FileText,
@ -21,11 +23,13 @@ import {
Download,
Shield,
MessageSquare,
MessageSquareWarning
MessageSquareWarning,
Loader2,
} from 'lucide-react'
import { FilePreview, FileInfoCard, FilePreviewModal, type FileInfo } from '@/components/ui/FilePreview'
import type { TaskResponse } from '@/types/task'
// 模拟脚本任务数据
// Mock 脚本任务数据USE_MOCK 模式使用)
const mockScriptTask = {
id: 'script-001',
title: '夏日护肤推广脚本',
@ -35,7 +39,6 @@ const mockScriptTask = {
submittedAt: '2026-02-06 14:30',
aiScore: 88,
status: 'brand_reviewing',
// 文件信息
file: {
id: 'file-001',
fileName: '夏日护肤推广_脚本v2.docx',
@ -44,7 +47,6 @@ const mockScriptTask = {
fileUrl: '/demo/scripts/script-001.docx',
uploadedAt: '2026-02-06 14:30',
} as FileInfo,
// 申诉信息
isAppeal: false,
appealReason: '',
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 }) {
const steps = getBrandReviewSteps(taskStatus)
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() {
const router = useRouter()
const params = useParams()
const toast = useToast()
const taskId = params.id as string
const [showApproveModal, setShowApproveModal] = useState(false)
const [showRejectModal, setShowRejectModal] = useState(false)
const [rejectReason, setRejectReason] = useState('')
const [viewMode, setViewMode] = useState<'file' | 'parsed'>('file')
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 = () => {
setShowApproveModal(false)
toast.success('审核通过')
router.push('/brand/review')
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)
toast.success('审核通过')
router.push('/brand/review')
return
}
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 = () => {
const handleReject = async () => {
if (!rejectReason.trim()) {
toast.error('请填写驳回原因')
return
}
setShowRejectModal(false)
toast.success('已驳回')
router.push('/brand/review')
if (USE_MOCK) {
setShowRejectModal(false)
toast.success('已驳回')
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 (
@ -199,10 +374,12 @@ export default function BrandScriptReviewPage() {
{/* 左侧:脚本内容 */}
<div className="lg:col-span-2 space-y-4">
{/* 文件信息卡片 */}
<FileInfoCard
file={task.file}
onPreview={() => setShowFilePreview(true)}
/>
{task.file.fileUrl && (
<FileInfoCard
file={task.file}
onPreview={() => setShowFilePreview(true)}
/>
)}
{viewMode === 'file' ? (
<Card>
@ -213,7 +390,11 @@ export default function BrandScriptReviewPage() {
</CardTitle>
</CardHeader>
<CardContent>
<FilePreview file={task.file} />
{task.file.fileUrl ? (
<FilePreview file={task.file} />
) : (
<p className="text-sm text-text-tertiary text-center py-8"></p>
)}
</CardContent>
</Card>
) : (
@ -226,22 +407,31 @@ export default function BrandScriptReviewPage() {
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="p-4 bg-bg-elevated rounded-lg">
<div className="text-xs text-accent-indigo font-medium mb-2"></div>
<p className="text-text-primary">{task.scriptContent.opening}</p>
</div>
<div className="p-4 bg-bg-elevated rounded-lg">
<div className="text-xs text-purple-400 font-medium mb-2"></div>
<p className="text-text-primary">{task.scriptContent.productIntro}</p>
</div>
<div className="p-4 bg-bg-elevated rounded-lg">
<div className="text-xs text-orange-400 font-medium mb-2">使</div>
<p className="text-text-primary">{task.scriptContent.demo}</p>
</div>
<div className="p-4 bg-bg-elevated rounded-lg">
<div className="text-xs text-accent-green font-medium mb-2"></div>
<p className="text-text-primary">{task.scriptContent.closing}</p>
</div>
{USE_MOCK && 'scriptContent' in task ? (
<>
<div className="p-4 bg-bg-elevated rounded-lg">
<div className="text-xs text-accent-indigo font-medium mb-2"></div>
<p className="text-text-primary">{(task as typeof mockScriptTask).scriptContent.opening}</p>
</div>
<div className="p-4 bg-bg-elevated rounded-lg">
<div className="text-xs text-purple-400 font-medium mb-2"></div>
<p className="text-text-primary">{(task as typeof mockScriptTask).scriptContent.productIntro}</p>
</div>
<div className="p-4 bg-bg-elevated rounded-lg">
<div className="text-xs text-orange-400 font-medium mb-2">使</div>
<p className="text-text-primary">{(task as typeof mockScriptTask).scriptContent.demo}</p>
</div>
<div className="p-4 bg-bg-elevated rounded-lg">
<div className="text-xs text-accent-green font-medium mb-2"></div>
<p className="text-text-primary">{(task as typeof mockScriptTask).scriptContent.closing}</p>
</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>
</Card>
)}
@ -255,23 +445,35 @@ export default function BrandScriptReviewPage() {
</CardTitle>
</CardHeader>
<CardContent>
<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'}`}>
{task.agencyReview.result === 'approved' ? (
<CheckCircle size={20} className="text-accent-green" />
) : (
<XCircle size={20} className="text-accent-coral" />
)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-text-primary">{task.agencyReview.reviewer}</span>
<SuccessTag></SuccessTag>
{task.agencyReview.comment ? (
<div className="flex items-start gap-4">
<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 === 'passed' || task.agencyReview.result === 'approved' ? (
<CheckCircle size={20} className="text-accent-green" />
) : (
<XCircle size={20} className="text-accent-coral" />
)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-text-primary">{task.agencyReview.reviewer}</span>
{(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>
<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>
)}
</div>
<p className="text-text-secondary text-sm">{task.agencyReview.comment}</p>
<p className="text-xs text-text-tertiary mt-2">{task.agencyReview.reviewedAt}</p>
</div>
</div>
) : (
<p className="text-sm text-text-tertiary text-center py-4"></p>
)}
</CardContent>
</Card>
</div>
@ -304,7 +506,7 @@ export default function BrandScriptReviewPage() {
<div className="flex items-center gap-2 mb-1">
<WarningTag>{v.type}</WarningTag>
</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>
</div>
))}
@ -314,54 +516,81 @@ export default function BrandScriptReviewPage() {
</CardContent>
</Card>
{/* 合规检查 */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Shield size={16} className="text-accent-indigo" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{task.aiAnalysis.complianceChecks.map((check, idx) => (
<div key={idx} className="flex items-start gap-2 p-2 rounded-lg bg-bg-elevated">
{check.passed ? (
<CheckCircle size={16} className="text-accent-green flex-shrink-0 mt-0.5" />
) : (
<XCircle size={16} className="text-accent-coral flex-shrink-0 mt-0.5" />
)}
<div className="flex-1">
<span className="text-sm text-text-primary">{check.item}</span>
{check.note && (
<p className="text-xs text-text-tertiary mt-0.5">{check.note}</p>
)}
{/* 软性提醒 */}
{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>
</div>
))}
</CardContent>
</Card>
))}
</CardContent>
</Card>
)}
{/* 合规检查 - 仅 mock 模式显示 */}
{USE_MOCK && 'complianceChecks' in task.aiAnalysis && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Shield size={16} className="text-accent-indigo" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{(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">
{check.passed ? (
<CheckCircle size={16} className="text-accent-green flex-shrink-0 mt-0.5" />
) : (
<XCircle size={16} className="text-accent-coral flex-shrink-0 mt-0.5" />
)}
<div className="flex-1">
<span className="text-sm text-text-primary">{check.item}</span>
{check.note && (
<p className="text-xs text-text-tertiary mt-0.5">{check.note}</p>
)}
</div>
</div>
))}
</CardContent>
</Card>
)}
{/* 卖点覆盖 */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<CheckCircle size={16} className="text-accent-green" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{task.aiAnalysis.sellingPoints.map((sp, idx) => (
<div key={idx} className="flex items-center gap-2 p-2 rounded-lg bg-bg-elevated">
{sp.covered ? (
<CheckCircle size={16} className="text-accent-green" />
) : (
<XCircle size={16} className="text-accent-coral" />
)}
<span className="text-sm text-text-primary">{sp.point}</span>
</div>
))}
</CardContent>
</Card>
{task.aiAnalysis.sellingPoints.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<CheckCircle size={16} className="text-accent-green" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{task.aiAnalysis.sellingPoints.map((sp, idx) => (
<div key={idx} className="flex items-center gap-2 p-2 rounded-lg bg-bg-elevated">
{sp.covered ? (
<CheckCircle size={16} className="text-accent-green" />
) : (
<XCircle size={16} className="text-accent-coral" />
)}
<span className="text-sm text-text-primary">{sp.point}</span>
</div>
))}
</CardContent>
</Card>
)}
</div>
</div>
@ -373,10 +602,12 @@ export default function BrandScriptReviewPage() {
{task.projectName}
</div>
<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 variant="success" onClick={() => setShowApproveModal(true)}>
<Button variant="success" onClick={() => setShowApproveModal(true)} disabled={submitting}>
{submitting ? <Loader2 size={16} className="animate-spin" /> : null}
</Button>
</div>
@ -408,8 +639,11 @@ export default function BrandScriptReviewPage() {
/>
</div>
<div className="flex gap-3 justify-end">
<Button variant="ghost" onClick={() => setShowRejectModal(false)}></Button>
<Button variant="danger" onClick={handleReject}></Button>
<Button variant="ghost" onClick={() => setShowRejectModal(false)} disabled={submitting}></Button>
<Button variant="danger" onClick={handleReject} disabled={submitting}>
{submitting ? <Loader2 size={16} className="animate-spin mr-1" /> : null}
</Button>
</div>
</div>
</Modal>

View File

@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
@ -22,22 +22,92 @@ import {
XCircle,
MessageSquare,
ExternalLink,
MessageSquareWarning
MessageSquareWarning,
Loader2,
} from 'lucide-react'
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 = {
// ==================== AI 审核结果类型 ====================
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',
title: '夏日护肤推广',
creatorName: '小美护肤',
agencyName: '星耀传媒',
projectName: 'XX品牌618推广',
submittedAt: '2026-02-06 15:00',
duration: 135, // 秒
duration: 135,
aiScore: 85,
status: 'brand_reviewing',
// 文件信息
file: {
id: 'file-video-001',
fileName: '夏日护肤_成片v2.mp4',
@ -47,8 +117,7 @@ const mockVideoTask = {
uploadedAt: '2026-02-06 15:00',
duration: '02:15',
thumbnail: '/demo/videos/video-001-thumb.jpg',
} as FileInfo,
// 申诉信息
},
isAppeal: false,
appealReason: '',
agencyReview: {
@ -90,12 +159,88 @@ const mockVideoTask = {
],
}
// ==================== 工具函数 ====================
function formatTimestamp(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
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 }) {
const steps = getBrandReviewSteps(taskStatus)
const currentStep = steps.find(s => s.status === 'current')
@ -121,10 +266,48 @@ function RiskLevelTag({ level }: { level: string }) {
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() {
const router = useRouter()
const params = useParams()
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 [showApproveModal, setShowApproveModal] = useState(false)
const [showRejectModal, setShowRejectModal] = useState(false)
@ -133,28 +316,91 @@ export default function BrandVideoReviewPage() {
const [showFilePreview, setShowFilePreview] = useState(false)
const [videoError, setVideoError] = useState(false)
const task = mockVideoTask
// 加载任务数据
const loadTask = useCallback(async () => {
if (!taskId) return
const handleApprove = () => {
setShowApproveModal(false)
toast.success('审核通过!')
router.push('/brand/review')
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)
toast.success('审核通过!')
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()) {
toast.error('请填写驳回原因')
return
}
setShowRejectModal(false)
toast.success('已驳回')
router.push('/brand/review')
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)
toast.success('已驳回')
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 = [
...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 })),
].sort((a, b) => a.time - b.time)
@ -249,41 +495,43 @@ export default function BrandVideoReviewPage() {
)}
</div>
{/* 智能进度条 */}
<div className="p-4 border-t border-border-subtle">
<div className="text-sm font-medium text-text-primary mb-3"></div>
<div className="relative h-3 bg-bg-elevated rounded-full">
{/* 时间标记点 */}
{timelineMarkers.map((marker, idx) => (
<button
key={idx}
type="button"
className={`absolute top-1/2 -translate-y-1/2 w-4 h-4 rounded-full border-2 border-bg-card shadow-md cursor-pointer transition-transform hover:scale-125 ${
marker.type === 'hard' ? 'bg-accent-coral' : marker.type === 'soft' ? 'bg-orange-500' : 'bg-accent-green'
}`}
style={{ left: `${(marker.time / task.duration) * 100}%` }}
title={`${formatTimestamp(marker.time)} - ${marker.type === 'hard' ? '硬性问题' : marker.type === 'soft' ? '舆情提示' : '卖点覆盖'}`}
/>
))}
{task.duration > 0 && (
<div className="p-4 border-t border-border-subtle">
<div className="text-sm font-medium text-text-primary mb-3"></div>
<div className="relative h-3 bg-bg-elevated rounded-full">
{/* 时间标记点 */}
{timelineMarkers.map((marker, idx) => (
<button
key={idx}
type="button"
className={`absolute top-1/2 -translate-y-1/2 w-4 h-4 rounded-full border-2 border-bg-card shadow-md cursor-pointer transition-transform hover:scale-125 ${
marker.type === 'hard' ? 'bg-accent-coral' : marker.type === 'soft' ? 'bg-orange-500' : 'bg-accent-green'
}`}
style={{ left: `${(marker.time / task.duration) * 100}%` }}
title={`${formatTimestamp(marker.time)} - ${marker.type === 'hard' ? '硬性问题' : marker.type === 'soft' ? '舆情提示' : '卖点覆盖'}`}
/>
))}
</div>
<div className="flex justify-between text-xs text-text-tertiary mt-1">
<span>0:00</span>
<span>{formatTimestamp(task.duration)}</span>
</div>
<div className="flex gap-4 mt-3 text-xs text-text-secondary">
<span className="flex items-center gap-1">
<span className="w-3 h-3 bg-accent-coral rounded-full" />
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 bg-orange-500 rounded-full" />
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 bg-accent-green rounded-full" />
</span>
</div>
</div>
<div className="flex justify-between text-xs text-text-tertiary mt-1">
<span>0:00</span>
<span>{formatTimestamp(task.duration)}</span>
</div>
<div className="flex gap-4 mt-3 text-xs text-text-secondary">
<span className="flex items-center gap-1">
<span className="w-3 h-3 bg-accent-coral rounded-full" />
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 bg-orange-500 rounded-full" />
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 bg-accent-green rounded-full" />
</span>
</div>
</div>
)}
</CardContent>
</Card>
@ -307,10 +555,16 @@ export default function BrandVideoReviewPage() {
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-text-primary">{task.agencyReview.reviewer}</span>
<SuccessTag></SuccessTag>
{task.agencyReview.result === 'approved' ? (
<SuccessTag></SuccessTag>
) : (
<ErrorTag></ErrorTag>
)}
</div>
<p className="text-text-secondary text-sm">{task.agencyReview.comment}</p>
<p className="text-xs text-text-tertiary mt-2">{task.agencyReview.reviewedAt}</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>
)}
</div>
</div>
</CardContent>
@ -326,7 +580,7 @@ export default function BrandVideoReviewPage() {
</span>
</div>
<p className="text-text-secondary text-sm">
{task.hardViolations.length}{task.sentimentWarnings.length}
{task.aiSummary || `视频整体合规,发现${task.hardViolations.length}处硬性问题和${task.sentimentWarnings.length}处舆情提示,代理商已确认处理。`}
</p>
</CardContent>
</Card>
@ -343,6 +597,9 @@ export default function BrandVideoReviewPage() {
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{task.hardViolations.length === 0 && (
<p className="text-sm text-text-tertiary py-2"></p>
)}
{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 className="flex items-start gap-2">
@ -355,9 +612,11 @@ export default function BrandVideoReviewPage() {
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<ErrorTag>{v.type}</ErrorTag>
<span className="text-xs text-text-tertiary">{formatTimestamp(v.timestamp)}</span>
{v.timestamp > 0 && (
<span className="text-xs text-text-tertiary">{formatTimestamp(v.timestamp)}</span>
)}
</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>
</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 className="flex items-center gap-2 mb-1">
<WarningTag>{w.type}</WarningTag>
<span className="text-xs text-text-tertiary">{formatTimestamp(w.timestamp)}</span>
{w.timestamp > 0 && (
<span className="text-xs text-text-tertiary">{formatTimestamp(w.timestamp)}</span>
)}
</div>
<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>
))}
</CardContent>
@ -391,31 +652,33 @@ export default function BrandVideoReviewPage() {
)}
{/* 卖点覆盖 */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<CheckCircle size={16} className="text-accent-green" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{task.sellingPointsCovered.map((sp, idx) => (
<div key={idx} className="flex items-center justify-between p-2 rounded-lg bg-bg-elevated">
<div className="flex items-center gap-2">
{sp.covered ? (
<CheckCircle size={16} className="text-accent-green" />
) : (
<XCircle size={16} className="text-accent-coral" />
{task.sellingPointsCovered.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<CheckCircle size={16} className="text-accent-green" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{task.sellingPointsCovered.map((sp, idx) => (
<div key={idx} className="flex items-center justify-between p-2 rounded-lg bg-bg-elevated">
<div className="flex items-center gap-2">
{sp.covered ? (
<CheckCircle size={16} className="text-accent-green" />
) : (
<XCircle size={16} className="text-accent-coral" />
)}
<span className="text-sm text-text-primary">{sp.point}</span>
</div>
{sp.covered && sp.timestamp > 0 && (
<span className="text-xs text-text-tertiary">{formatTimestamp(sp.timestamp)}</span>
)}
<span className="text-sm text-text-primary">{sp.point}</span>
</div>
{sp.covered && (
<span className="text-xs text-text-tertiary">{formatTimestamp(sp.timestamp)}</span>
)}
</div>
))}
</CardContent>
</Card>
))}
</CardContent>
</Card>
)}
</div>
</div>
@ -427,10 +690,12 @@ export default function BrandVideoReviewPage() {
{Object.values(checkedViolations).filter(Boolean).length}/{task.hardViolations.length}
</div>
<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 variant="success" onClick={() => setShowApproveModal(true)}>
<Button variant="success" onClick={() => setShowApproveModal(true)} disabled={submitting}>
{submitting ? <Loader2 size={16} className="animate-spin mr-1" /> : null}
</Button>
</div>
@ -445,7 +710,7 @@ export default function BrandVideoReviewPage() {
onConfirm={handleApprove}
title="确认通过"
message="确定要通过此视频的审核吗?通过后达人将收到通知。"
confirmText="确认通过"
confirmText={submitting ? '提交中...' : '确认通过'}
/>
{/* 驳回弹窗 */}
@ -455,7 +720,7 @@ export default function BrandVideoReviewPage() {
<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>
{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 && (
<div className="text-sm text-text-tertiary"></div>
@ -471,8 +736,11 @@ export default function BrandVideoReviewPage() {
/>
</div>
<div className="flex gap-3 justify-end">
<Button variant="ghost" onClick={() => setShowRejectModal(false)}></Button>
<Button variant="danger" onClick={handleReject}></Button>
<Button variant="ghost" onClick={() => setShowRejectModal(false)} disabled={submitting}></Button>
<Button variant="danger" onClick={handleReject} disabled={submitting}>
{submitting ? <Loader2 size={16} className="animate-spin mr-1" /> : null}
</Button>
</div>
</div>
</Modal>

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
'use client'
import React, { useState } from 'react'
import React, { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import {
ArrowLeft,
@ -10,10 +10,15 @@ import {
XCircle,
Send,
Info,
Loader2
} from 'lucide-react'
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
import { Button } from '@/components/ui/Button'
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'
@ -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 }) {
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({
task,
onRequestIncrease,
requesting,
}: {
task: TaskAppealQuota
onRequestIncrease: (taskId: string) => void
requesting: boolean
}) {
const canRequest = task.requestStatus === 'none' || task.requestStatus === 'rejected'
@ -149,10 +206,11 @@ function TaskQuotaCard({
variant="secondary"
size="sm"
onClick={() => onRequestIncrease(task.id)}
disabled={requesting}
className="gap-1.5"
>
<Send size={14} />
{requesting ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
{requesting ? '申请中...' : '申请增加'}
</Button>
) : task.requestStatus === 'pending' ? (
<span className="text-xs text-accent-amber">...</span>
@ -164,30 +222,87 @@ function TaskQuotaCard({
export default function AppealQuotaPage() {
const router = useRouter()
const [tasks, setTasks] = useState(mockTaskQuotas)
const [showSuccessToast, setShowSuccessToast] = useState(false)
const toast = useToast()
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) => {
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
const handleRequestIncrease = async (taskId: string) => {
if (USE_MOCK) {
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
)
)
)
setShowSuccessToast(true)
setTimeout(() => setShowSuccessToast(false), 3000)
toast.success('申请已发送,等待代理商处理')
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,20 +331,31 @@ export default function AppealQuotaPage() {
</div>
{/* 统计卡片 */}
<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">
<span className="text-2xl font-bold text-accent-indigo">{totalRemaining}</span>
<span className="text-xs text-text-tertiary"></span>
{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="bg-bg-card rounded-xl p-4 card-shadow flex flex-col items-center gap-1">
<span className="text-2xl font-bold text-text-secondary">{totalUsed}</span>
<span className="text-xs text-text-tertiary">使</span>
) : (
<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">
<span className="text-2xl font-bold text-accent-indigo">{totalRemaining}</span>
<span className="text-xs text-text-tertiary"></span>
</div>
<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-text-secondary">{totalUsed}</span>
<span className="text-xs text-text-tertiary">使</span>
</div>
<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-amber">{pendingRequests}</span>
<span className="text-xs text-text-tertiary"></span>
</div>
</div>
<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-amber">{pendingRequests}</span>
<span className="text-xs text-text-tertiary"></span>
</div>
</div>
)}
{/* 规则说明 */}
<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">
<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>
{tasks.map(task => (
<TaskQuotaCard
key={task.id}
task={task}
onRequestIncrease={handleRequestIncrease}
/>
))}
{loading ? (
<>
<QuotaSkeleton />
<QuotaSkeleton />
<QuotaSkeleton />
</>
) : tasks.length > 0 ? (
tasks.map(task => (
<TaskQuotaCard
key={task.id}
task={task}
onRequestIncrease={handleRequestIncrease}
requesting={requestingTaskId === task.id}
/>
))
) : (
<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>
</div>
)}
</div>
</div>
{/* 成功提示 */}
{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>
)}
</ResponsiveLayout>
)
}

View File

@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { useParams, useRouter } from 'next/navigation'
import {
ArrowLeft,
@ -11,10 +11,15 @@ import {
FileText,
Image,
Send,
AlertTriangle
AlertTriangle,
Loader2
} from 'lucide-react'
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
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'
@ -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 }> = {
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' },
}
// 骨架屏组件
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() {
const params = useParams()
const router = useRouter()
const toast = useToast()
const appealId = params.id as string
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) {
return (
@ -288,13 +457,23 @@ export default function AppealDetailPage() {
value={newComment}
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"
disabled={submitting}
/>
<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'
)}
>
<Send className="w-4 h-4" />
{submitting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4" />
)}
{submitting ? '发送中...' : '发送'}
</button>
</div>
</div>

View File

@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import {
ArrowLeft,
@ -9,10 +9,15 @@ import {
FileText,
Image,
AlertTriangle,
CheckCircle
CheckCircle,
Loader2
} from 'lucide-react'
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
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 = [
@ -23,9 +28,19 @@ const appealReasons = [
{ id: 'other', label: '其他原因', description: '其他需要说明的情况' },
]
// Mock 任务信息类型
type TaskInfo = {
title: string
issue: string
issueDesc: string
type: string
appealRemaining: number
agencyName: string
}
// 任务信息模拟从URL参数获取
const getTaskInfo = (taskId: string) => {
const tasks: Record<string, { title: string; issue: string; issueDesc: string; type: string; appealRemaining: number; agencyName: string }> = {
const getTaskInfo = (taskId: string): TaskInfo => {
const tasks: Record<string, TaskInfo> = {
'task-003': {
title: 'ZZ饮品夏日',
issue: '检测到竞品提及',
@ -70,12 +85,99 @@ const getTaskInfo = (taskId: string) => {
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() {
const router = useRouter()
const searchParams = useSearchParams()
const toast = useToast()
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 [content, setContent] = useState('')
const [attachments, setAttachments] = useState<{ name: string; type: 'image' | 'document' }[]>([])
@ -84,7 +186,40 @@ export default function NewAppealPage() {
const [isRequestingQuota, setIsRequestingQuota] = 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 files = e.target.files
@ -104,26 +239,69 @@ export default function NewAppealPage() {
const handleSubmit = async () => {
if (!selectedReason || !content.trim()) return
setIsSubmitting(true)
// 模拟提交
await new Promise(resolve => setTimeout(resolve, 1500))
setIsSubmitting(false)
setIsSubmitted(true)
if (USE_MOCK) {
setIsSubmitting(true)
await new Promise(resolve => setTimeout(resolve, 1500))
setIsSubmitting(false)
setIsSubmitted(true)
setTimeout(() => {
router.push('/creator/appeals')
}, 2000)
return
}
// 2秒后跳转到申诉列表
setTimeout(() => {
router.push('/creator/appeals')
}, 2000)
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 handleRequestQuota = async () => {
setIsRequestingQuota(true)
await new Promise(resolve => setTimeout(resolve, 1000))
setIsRequestingQuota(false)
setQuotaRequested(true)
if (USE_MOCK) {
setIsRequestingQuota(true)
await new Promise(resolve => setTimeout(resolve, 1000))
setIsRequestingQuota(false)
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 (
<ResponsiveLayout role="creator">
<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')} />
<span className={cn('text-sm font-medium', hasAppealQuota ? 'text-accent-indigo' : 'text-accent-coral')}>
{taskInfo.appealRemaining}
{info.appealRemaining}
</span>
</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>
<div className="bg-bg-elevated rounded-xl p-4">
<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">
{taskInfo.type === 'ai' ? 'AI审核' : taskInfo.type === 'agency' ? '代理商审核' : '品牌方审核'}
{info.type === 'ai' ? 'AI审核' : info.type === 'agency' ? '代理商审核' : '品牌方审核'}
</span>
</div>
<div className="flex items-start gap-2">
<AlertTriangle className="w-4 h-4 text-accent-coral flex-shrink-0 mt-0.5" />
<div>
<span className="text-sm font-medium text-text-primary">{taskInfo.issue}</span>
<p className="text-xs text-text-secondary mt-1">{taskInfo.issueDesc}</p>
<span className="text-sm font-medium text-text-primary">{info.issue}</span>
<p className="text-xs text-text-secondary mt-1">{info.issueDesc}</p>
</div>
</div>
</div>
@ -208,7 +389,7 @@ export default function NewAppealPage() {
<div className="flex-1">
<h3 className="text-base font-semibold text-accent-coral mb-2"></h3>
<p className="text-sm text-text-secondary mb-4">
{taskInfo.agencyName}
{info.agencyName}
</p>
{quotaRequested ? (
<div className="flex items-center gap-2 text-accent-green">
@ -220,8 +401,9 @@ export default function NewAppealPage() {
type="button"
onClick={handleRequestQuota}
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 ? '申请中...' : '申请增加申诉次数'}
</button>
)}
@ -322,12 +504,13 @@ export default function NewAppealPage() {
onClick={handleSubmit}
disabled={!canSubmit || isSubmitting}
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
? 'bg-accent-indigo text-white'
: 'bg-bg-elevated text-text-tertiary'
)}
>
{isSubmitting && <Loader2 className="w-5 h-5 animate-spin" />}
{isSubmitting ? '提交中...' : '提交申诉'}
</button>
</div>
@ -341,7 +524,7 @@ export default function NewAppealPage() {
<div className="flex flex-col gap-4 mb-6">
<div className="flex items-center justify-between">
<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 className="flex items-center justify-between">
<span className="text-sm text-text-tertiary"></span>
@ -378,12 +561,13 @@ export default function NewAppealPage() {
onClick={handleSubmit}
disabled={!canSubmit || isSubmitting}
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
? 'bg-accent-indigo text-white hover:bg-accent-indigo/90'
: 'bg-bg-elevated text-text-tertiary cursor-not-allowed'
)}
>
{isSubmitting && <Loader2 className="w-5 h-5 animate-spin" />}
{isSubmitting ? '提交中...' : '提交申诉'}
</button>
</div>

View File

@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import {
MessageCircle,
@ -10,10 +10,15 @@ import {
ChevronRight,
AlertTriangle,
Filter,
Search
Search,
Loader2
} from 'lucide-react'
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
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'
@ -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 }> = {
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' },
}
// 骨架屏组件
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 }) {
const status = statusConfig[appeal.status]
@ -174,9 +244,39 @@ function AppealQuotaEntryCard({ onClick }: { onClick: () => void }) {
export default function CreatorAppealsPage() {
const router = useRouter()
const toast = useToast()
const [filter, setFilter] = useState<AppealStatus | 'all'>('all')
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 => {
@ -239,8 +339,16 @@ export default function CreatorAppealsPage() {
{/* 申诉列表 */}
<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>
{filteredAppeals.length > 0 ? (
<h2 className="text-lg font-semibold text-text-primary">
{!loading && `(${filteredAppeals.length})`}
</h2>
{loading ? (
<>
<AppealSkeleton />
<AppealSkeleton />
<AppealSkeleton />
</>
) : filteredAppeals.length > 0 ? (
filteredAppeals.map((appeal) => (
<AppealCard
key={appeal.id}

View File

@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import {
ArrowLeft,
@ -9,10 +9,15 @@ import {
Clock,
Video,
Filter,
ChevronRight
ChevronRight,
Loader2
} from 'lucide-react'
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
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'
@ -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 }> = {
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 },
}
// 骨架屏
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 }) {
const status = statusConfig[task.status]
@ -127,9 +169,37 @@ function HistoryCard({ task, onClick }: { task: HistoryTask; onClick: () => void
export default function CreatorHistoryPage() {
const router = useRouter()
const toast = useToast()
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 (
<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 flex-col items-center gap-1 flex-1">
<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 className="text-xs text-text-tertiary"></span>
</div>
<div className="w-px h-10 bg-border-subtle" />
<div className="flex flex-col items-center gap-1 flex-1">
<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 className="text-xs text-text-tertiary"></span>
</div>
<div className="w-px h-10 bg-border-subtle" />
<div className="flex flex-col items-center gap-1 flex-1">
<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 className="text-xs text-text-tertiary"></span>
</div>
@ -189,13 +259,23 @@ export default function CreatorHistoryPage() {
{/* 任务列表 */}
<div className="flex flex-col gap-4 flex-1 overflow-y-auto pr-2">
{filteredHistory.map((task) => (
<HistoryCard
key={task.id}
task={task}
onClick={() => router.push(`/creator/task/${task.id}`)}
/>
))}
{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
key={task.id}
task={task}
onClick={() => router.push(`/creator/task/${task.id}`)}
/>
))
)}
</div>
</div>
</ResponsiveLayout>

View File

@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { useToast } from '@/components/ui/Toast'
import {
@ -14,11 +14,16 @@ import {
Building2,
Calendar,
Clock,
ChevronRight
ChevronRight,
Loader2
} from 'lucide-react'
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
import { Modal } from '@/components/ui/Modal'
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文档类型
type AgencyBriefFile = {
@ -29,6 +34,19 @@ type AgencyBriefFile = {
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 = {
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() {
const router = useRouter()
const params = useParams()
const toast = useToast()
const taskId = params.id as string
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) => {
toast.info(`下载文件: ${file.name}`)
@ -83,8 +241,16 @@ export default function TaskBriefPage() {
toast.info('下载全部文件')
}
const requiredPoints = mockAgencyBrief.sellingPoints.filter(sp => sp.required)
const optionalPoints = mockAgencyBrief.sellingPoints.filter(sp => !sp.required)
if (loading || !viewModel) {
return (
<ResponsiveLayout role="creator">
<BriefSkeleton />
</ResponsiveLayout>
)
}
const requiredPoints = viewModel.sellingPoints.filter(sp => sp.required)
const optionalPoints = viewModel.sellingPoints.filter(sp => !sp.required)
return (
<ResponsiveLayout role="creator">
@ -102,7 +268,7 @@ export default function TaskBriefPage() {
</button>
</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>
</div>
<Button onClick={() => router.push(`/creator/task/${params.id}`)}>
@ -121,7 +287,7 @@ export default function TaskBriefPage() {
</div>
<div>
<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 className="flex items-center gap-3">
@ -130,7 +296,7 @@ export default function TaskBriefPage() {
</div>
<div>
<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 className="flex items-center gap-3">
@ -139,142 +305,152 @@ export default function TaskBriefPage() {
</div>
<div>
<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 className="flex items-center gap-3">
<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" />
{viewModel.deadline && (
<div className="flex items-center gap-3">
<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" />
</div>
<div>
<p className="text-xs text-text-tertiary"></p>
<p className="text-sm font-medium text-text-primary">{viewModel.deadline}</p>
</div>
</div>
<div>
<p className="text-xs text-text-tertiary"></p>
<p className="text-sm font-medium text-text-primary">{mockTaskInfo.deadline}</p>
</div>
</div>
)}
</div>
</div>
{/* 主要内容区域 - 可滚动 */}
<div className="flex-1 overflow-y-auto space-y-6">
{/* Brief文档列表 */}
<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 gap-2">
<File className="w-5 h-5 text-accent-indigo" />
<h3 className="text-base font-semibold text-text-primary">Brief </h3>
<span className="text-sm text-text-tertiary">({mockAgencyBrief.files.length})</span>
</div>
<Button variant="secondary" size="sm" onClick={handleDownloadAll}>
<Download className="w-4 h-4" />
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{mockAgencyBrief.files.map((file) => (
<div
key={file.id}
className="flex items-center justify-between p-4 bg-bg-elevated rounded-xl hover:bg-bg-page transition-colors"
>
<div className="flex items-center gap-3 min-w-0">
<div className="w-11 h-11 rounded-xl bg-accent-indigo/15 flex items-center justify-center flex-shrink-0">
<FileText className="w-5 h-5 text-accent-indigo" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{file.name}</p>
<p className="text-xs text-text-tertiary">{file.size}</p>
{file.description && (
<p className="text-xs text-text-secondary mt-0.5 truncate">{file.description}</p>
)}
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
<button
type="button"
onClick={() => setPreviewFile(file)}
className="p-2.5 hover:bg-bg-card rounded-lg transition-colors"
>
<Eye className="w-4 h-4 text-text-secondary" />
</button>
<button
type="button"
onClick={() => handleDownload(file)}
className="p-2.5 hover:bg-bg-card rounded-lg transition-colors"
>
<Download className="w-4 h-4 text-text-secondary" />
</button>
</div>
{viewModel.files.length > 0 && (
<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 gap-2">
<File className="w-5 h-5 text-accent-indigo" />
<h3 className="text-base font-semibold text-text-primary">Brief </h3>
<span className="text-sm text-text-tertiary">({viewModel.files.length})</span>
</div>
))}
<Button variant="secondary" size="sm" onClick={handleDownloadAll}>
<Download className="w-4 h-4" />
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{viewModel.files.map((file) => (
<div
key={file.id}
className="flex items-center justify-between p-4 bg-bg-elevated rounded-xl hover:bg-bg-page transition-colors"
>
<div className="flex items-center gap-3 min-w-0">
<div className="w-11 h-11 rounded-xl bg-accent-indigo/15 flex items-center justify-center flex-shrink-0">
<FileText className="w-5 h-5 text-accent-indigo" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{file.name}</p>
<p className="text-xs text-text-tertiary">{file.size}</p>
{file.description && (
<p className="text-xs text-text-secondary mt-0.5 truncate">{file.description}</p>
)}
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
<button
type="button"
onClick={() => setPreviewFile(file)}
className="p-2.5 hover:bg-bg-card rounded-lg transition-colors"
>
<Eye className="w-4 h-4 text-text-secondary" />
</button>
<button
type="button"
onClick={() => handleDownload(file)}
className="p-2.5 hover:bg-bg-card rounded-lg transition-colors"
>
<Download className="w-4 h-4 text-text-secondary" />
</button>
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* 内容要求 */}
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
<div className="flex items-center gap-2 mb-4">
<FileText className="w-5 h-5 text-accent-amber" />
<h3 className="text-base font-semibold text-text-primary"></h3>
{viewModel.contentRequirements.length > 0 && (
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
<div className="flex items-center gap-2 mb-4">
<FileText className="w-5 h-5 text-accent-amber" />
<h3 className="text-base font-semibold text-text-primary"></h3>
</div>
<ul className="space-y-2">
{viewModel.contentRequirements.map((req, index) => (
<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" />
{req}
</li>
))}
</ul>
</div>
<ul className="space-y-2">
{mockAgencyBrief.contentRequirements.map((req, index) => (
<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" />
{req}
</li>
))}
</ul>
</div>
)}
{/* 卖点要求 */}
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
<div className="flex items-center gap-2 mb-4">
<Target className="w-5 h-5 text-accent-green" />
<h3 className="text-base font-semibold text-text-primary"></h3>
</div>
<div className="space-y-3">
{requiredPoints.length > 0 && (
<div className="p-4 bg-accent-coral/10 rounded-xl border border-accent-coral/30">
<p className="text-xs text-accent-coral font-semibold mb-2"></p>
<div className="flex flex-wrap gap-2">
{requiredPoints.map((sp) => (
<span key={sp.id} className="px-3 py-1.5 text-sm bg-accent-coral/20 text-accent-coral rounded-lg font-medium">
{sp.content}
</span>
))}
{viewModel.sellingPoints.length > 0 && (
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
<div className="flex items-center gap-2 mb-4">
<Target className="w-5 h-5 text-accent-green" />
<h3 className="text-base font-semibold text-text-primary"></h3>
</div>
<div className="space-y-3">
{requiredPoints.length > 0 && (
<div className="p-4 bg-accent-coral/10 rounded-xl border border-accent-coral/30">
<p className="text-xs text-accent-coral font-semibold mb-2"></p>
<div className="flex flex-wrap gap-2">
{requiredPoints.map((sp) => (
<span key={sp.id} className="px-3 py-1.5 text-sm bg-accent-coral/20 text-accent-coral rounded-lg font-medium">
{sp.content}
</span>
))}
</div>
</div>
</div>
)}
{optionalPoints.length > 0 && (
<div className="p-4 bg-bg-elevated rounded-xl">
<p className="text-xs text-text-tertiary font-semibold mb-2"></p>
<div className="flex flex-wrap gap-2">
{optionalPoints.map((sp) => (
<span key={sp.id} className="px-3 py-1.5 text-sm bg-bg-page text-text-secondary rounded-lg">
{sp.content}
</span>
))}
)}
{optionalPoints.length > 0 && (
<div className="p-4 bg-bg-elevated rounded-xl">
<p className="text-xs text-text-tertiary font-semibold mb-2"></p>
<div className="flex flex-wrap gap-2">
{optionalPoints.map((sp) => (
<span key={sp.id} className="px-3 py-1.5 text-sm bg-bg-page text-text-secondary rounded-lg">
{sp.content}
</span>
))}
</div>
</div>
</div>
)}
)}
</div>
</div>
</div>
)}
{/* 违禁词 */}
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
<div className="flex items-center gap-2 mb-4">
<Ban className="w-5 h-5 text-accent-coral" />
<h3 className="text-base font-semibold text-text-primary">使</h3>
{viewModel.blacklistWords.length > 0 && (
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
<div className="flex items-center gap-2 mb-4">
<Ban className="w-5 h-5 text-accent-coral" />
<h3 className="text-base font-semibold text-text-primary">使</h3>
</div>
<div className="flex flex-wrap gap-2">
{viewModel.blacklistWords.map((bw) => (
<span
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"
>
{bw.word}<span className="text-xs opacity-75 ml-1">{bw.reason}</span>
</span>
))}
</div>
</div>
<div className="flex flex-wrap gap-2">
{mockAgencyBrief.blacklistWords.map((bw) => (
<span
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"
>
{bw.word}<span className="text-xs opacity-75 ml-1">{bw.reason}</span>
</span>
))}
</div>
</div>
)}
{/* 底部操作按钮 */}
<div className="flex justify-center py-4">