diff --git a/frontend/app/agency/page.tsx b/frontend/app/agency/page.tsx index 9fa3958..f207120 100644 --- a/frontend/app/agency/page.tsx +++ b/frontend/app/agency/page.tsx @@ -1,137 +1,86 @@ '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' -import { SuccessTag, PendingTag, WarningTag, ErrorTag } from '@/components/ui/Tag' import { AlertTriangle, Clock, CheckCircle, - XCircle, ChevronRight, FileVideo, MessageSquare, - TrendingUp + TrendingUp, + Loader2 } from 'lucide-react' import { getPlatformInfo } from '@/lib/platforms' +import { api } from '@/lib/api' +import { USE_MOCK } from '@/contexts/AuthContext' +import { useSSE } from '@/contexts/SSEContext' +import type { AgencyDashboard as AgencyDashboardType } from '@/types/dashboard' +import type { TaskResponse } from '@/types/task' +import type { ProjectResponse } from '@/types/project' -// 模拟统计数据 -const stats = { - pendingReview: { - script: 8, - video: 4, - }, - pendingAppeal: 3, - todayPassed: { - script: 18, - video: 10, - }, - inProgress: { - script: 25, - video: 20, - }, +// ==================== Mock 数据 ==================== +const mockStats: AgencyDashboardType = { + pending_review: { script: 8, video: 4 }, + pending_appeal: 3, + today_passed: { script: 18, video: 10 }, + in_progress: { script: 25, video: 20 }, + total_creators: 15, + total_tasks: 80, } -// 模拟紧急待办 -const urgentTodos = [ +const mockPendingTasks: TaskResponse[] = [ { - id: 'urgent-001', - type: 'violation', - title: '达人A视频 - 竞品露出', - description: 'XX品牌618推广', - time: '2小时前', - level: 'high', + id: 'task-001', name: '夏日护肤推广', sequence: 1, + stage: 'script_agency_review', + project: { id: 'proj-001', name: 'XX品牌618推广', brand_name: 'XX品牌' }, + agency: { id: 'ag-001', name: '优创代理' }, + creator: { id: 'cr-001', name: '小美护肤' }, + script_ai_score: 85, appeal_count: 0, is_appeal: false, + created_at: '2026-02-04T14:30:00Z', updated_at: '2026-02-04T14:30:00Z', }, { - id: 'urgent-002', - type: 'appeal', - title: '达人B申诉 - 待仲裁', - description: '对违禁词检测结果有异议', - time: '30分钟前', - level: 'medium', + id: 'task-002', name: '新品口红试色', sequence: 2, + stage: 'video_agency_review', + project: { id: 'proj-001', name: 'XX品牌618推广', brand_name: 'XX品牌' }, + agency: { id: 'ag-001', name: '优创代理' }, + creator: { id: 'cr-002', name: '美妆达人Lisa' }, + video_ai_score: 72, appeal_count: 0, is_appeal: true, + created_at: '2026-02-04T13:45:00Z', updated_at: '2026-02-04T13:45:00Z', }, { - id: 'urgent-003', - type: 'ai_done', - title: '达人C视频 - AI审核完成', - description: '新品口红试色', - time: '10分钟前', - level: 'low', + id: 'task-003', name: '健身器材开箱', sequence: 3, + stage: 'script_agency_review', + project: { id: 'proj-002', name: 'XX运动品牌', brand_name: 'XX运动' }, + agency: { id: 'ag-001', name: '优创代理' }, + creator: { id: 'cr-003', name: '健身教练王' }, + script_ai_score: 68, appeal_count: 0, is_appeal: false, + created_at: '2026-02-04T14:50:00Z', updated_at: '2026-02-04T14:50:00Z', }, ] -// 模拟项目概览 -const projectOverview = [ +const mockProjects: ProjectResponse[] = [ { - id: 'proj-001', - name: 'XX品牌618推广', - platform: 'douyin', - total: 20, - submitted: 15, - passed: 10, - reviewingScript: 2, - reviewingVideo: 1, - needRevision: 2, + id: 'proj-001', name: 'XX品牌618推广', brand_id: 'br-001', brand_name: 'XX品牌', + status: 'active', deadline: '2026-06-18', agencies: [], task_count: 20, + created_at: '2026-01-01T00:00:00Z', updated_at: '2026-02-01T00:00:00Z', }, { - id: 'proj-002', - name: '新品口红系列', - platform: 'xiaohongshu', - total: 12, - submitted: 8, - passed: 6, - reviewingScript: 1, - reviewingVideo: 0, - needRevision: 1, + id: 'proj-002', name: '新品口红系列', brand_id: 'br-001', brand_name: 'XX品牌', + status: 'active', deadline: '2026-03-15', agencies: [], task_count: 12, + created_at: '2026-01-15T00:00:00Z', updated_at: '2026-02-01T00:00:00Z', }, { - id: 'proj-003', - name: '护肤品秋季活动', - platform: 'bilibili', - total: 15, - submitted: 12, - passed: 9, - reviewingScript: 1, - reviewingVideo: 1, - needRevision: 1, + id: 'proj-003', name: '护肤品秋季活动', brand_id: 'br-002', brand_name: 'YY品牌', + status: 'active', deadline: '2026-09-01', agencies: [], task_count: 15, + created_at: '2026-02-01T00:00:00Z', updated_at: '2026-02-05T00:00:00Z', }, ] -// 模拟待审核任务列表 -const pendingTasks = [ - { - id: 'task-001', - videoTitle: '夏日护肤推广', - creatorName: '小美护肤', - brandName: 'XX品牌', - platform: 'douyin', - aiScore: 85, - submittedAt: '2026-02-04 14:30', - hasHighRisk: false, - }, - { - id: 'task-002', - videoTitle: '新品口红试色', - creatorName: '美妆达人Lisa', - brandName: 'XX品牌', - platform: 'xiaohongshu', - aiScore: 72, - submittedAt: '2026-02-04 13:45', - hasHighRisk: true, - }, - { - id: 'task-003', - videoTitle: '健身器材开箱', - creatorName: '健身教练王', - brandName: 'XX运动', - platform: 'bilibili', - aiScore: 68, - submittedAt: '2026-02-04 14:50', - hasHighRisk: true, - }, -] +// ==================== 组件 ==================== function UrgentLevelIcon({ level }: { level: string }) { if (level === 'high') return @@ -139,7 +88,108 @@ function UrgentLevelIcon({ level }: { level: string }) { return } +function getTaskUrgencyLevel(task: TaskResponse): string { + const aiScore = task.stage.startsWith('script') ? task.script_ai_score : task.video_ai_score + if (aiScore != null && aiScore < 60) return 'high' + if (task.is_appeal) return 'medium' + return 'low' +} + +function getTaskUrgencyTitle(task: TaskResponse): string { + const type = task.stage.includes('video') ? '视频' : '脚本' + return `${task.creator.name}${type} - ${task.name}` +} + +function getTaskTimeAgo(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime() + const hours = Math.floor(diff / 3600000) + if (hours < 1) return `${Math.floor(diff / 60000)}分钟前` + if (hours < 24) return `${hours}小时前` + return `${Math.floor(hours / 24)}天前` +} + +function DashboardSkeleton() { + return ( +
+
+
+
+
+
+ {[1, 2, 3, 4].map(i => ( +
+ ))} +
+
+
+
+
+
+
+ ) +} + export default function AgencyDashboard() { + const [stats, setStats] = useState(null) + const [pendingTasks, setPendingTasks] = useState([]) + const [projects, setProjects] = useState([]) + const [loading, setLoading] = useState(true) + const { subscribe } = useSSE() + + const loadData = useCallback(async () => { + if (USE_MOCK) { + setStats(mockStats) + setPendingTasks(mockPendingTasks) + setProjects(mockProjects) + setLoading(false) + return + } + + try { + const [dashboardData, tasksData, projectsData] = await Promise.all([ + api.getAgencyDashboard(), + api.listPendingReviews(1, 5), + api.listProjects(1, 3), + ]) + setStats(dashboardData) + // listPendingReviews returns TaskSummary, but we need TaskResponse for the table + // Fall back to listTasks for the pending table + const fullTasks = await api.listTasks(1, 5, 'script_agency_review') + setPendingTasks(fullTasks.items) + setProjects(projectsData.items) + } catch (err) { + console.error('Failed to load agency dashboard:', err) + // fallback to empty + setStats({ pending_review: { script: 0, video: 0 }, pending_appeal: 0, today_passed: { script: 0, video: 0 }, in_progress: { script: 0, video: 0 }, total_creators: 0, total_tasks: 0 }) + setPendingTasks([]) + setProjects([]) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + loadData() + }, [loadData]) + + useEffect(() => { + const unsub1 = subscribe('task_updated', () => loadData()) + const unsub2 = subscribe('new_task', () => loadData()) + const unsub3 = subscribe('review_completed', () => loadData()) + return () => { unsub1(); unsub2(); unsub3() } + }, [subscribe, loadData]) + + if (loading || !stats) return + + // Build urgent todos from pending tasks (top 3) + const urgentTodos = pendingTasks.slice(0, 3).map(task => ({ + id: task.id, + title: getTaskUrgencyTitle(task), + description: task.project.name, + time: getTaskTimeAgo(task.updated_at), + level: getTaskUrgencyLevel(task), + })) + return (
{/* 页面标题 */} @@ -155,10 +205,10 @@ export default function AgencyDashboard() {
待审核
-
{stats.pendingReview.script + stats.pendingReview.video}
+
{stats.pending_review.script + stats.pending_review.video}
- 脚本 {stats.pendingReview.script} - 视频 {stats.pendingReview.video} + 脚本 {stats.pending_review.script} + 视频 {stats.pending_review.video}
@@ -172,7 +222,7 @@ export default function AgencyDashboard() {
待仲裁
-
{stats.pendingAppeal}
+
{stats.pending_appeal}
@@ -185,10 +235,10 @@ export default function AgencyDashboard() {
今日通过
-
{stats.todayPassed.script + stats.todayPassed.video}
+
{stats.today_passed.script + stats.today_passed.video}
- 脚本 {stats.todayPassed.script} - 视频 {stats.todayPassed.video} + 脚本 {stats.today_passed.script} + 视频 {stats.today_passed.video}
@@ -202,10 +252,10 @@ export default function AgencyDashboard() {
进行中
-
{stats.inProgress.script + stats.inProgress.video}
+
{stats.in_progress.script + stats.in_progress.video}
- 脚本 {stats.inProgress.script} - 视频 {stats.inProgress.video} + 脚本 {stats.in_progress.script} + 视频 {stats.in_progress.video}
@@ -226,10 +276,10 @@ export default function AgencyDashboard() { - {urgentTodos.map((todo) => ( + {urgentTodos.length > 0 ? urgentTodos.map((todo) => (
@@ -242,7 +292,9 @@ export default function AgencyDashboard() {
- ))} + )) : ( +
暂无紧急待办
+ )}
@@ -256,68 +308,27 @@ export default function AgencyDashboard() {
- {projectOverview.map((project) => { - const totalReviewing = project.reviewingScript + project.reviewingVideo - const projectPlatform = getPlatformInfo(project.platform) - return ( -
-
-
- {project.name} - {projectPlatform && ( - - {projectPlatform.icon} - {projectPlatform.name} - - )} -
- - {project.submitted}/{project.total} 已提交 - -
-
-
-
-
-
-
-
- - - 通过 {project.passed} - - - - 脚本审核 {project.reviewingScript} - - - - 视频审核 {project.reviewingVideo} - - - - 需修改 {project.needRevision} - + {projects.length > 0 ? projects.map((project) => ( +
+
+
+ {project.name} + {project.brand_name && ( + ({project.brand_name}) + )}
+ + {project.task_count} 个任务 +
- ) - })} +
+ 状态: {project.status === 'active' ? '进行中' : project.status === 'completed' ? '已完成' : '已归档'} + {project.deadline && 截止: {new Date(project.deadline).toLocaleDateString('zh-CN')}} +
+
+ )) : ( +
暂无项目
+ )}
@@ -341,8 +352,8 @@ export default function AgencyDashboard() { - - + + @@ -351,38 +362,44 @@ export default function AgencyDashboard() { - {pendingTasks.map((task) => { - const platform = getPlatformInfo(task.platform) + {pendingTasks.length > 0 ? pendingTasks.map((task) => { + const isVideo = task.stage.includes('video') + const aiScore = isVideo ? task.video_ai_score : task.script_ai_score return ( - - - - + + + + ) - })} + }) : ( + + + + )}
视频平台任务类型 达人 品牌 AI评分
-
{task.videoTitle}
- {task.hasHighRisk && ( - - 高风险 +
{task.name}
+ {task.is_appeal && ( + + 申诉 )}
- {platform && ( - - {platform.icon} - {platform.name} - - )} - {task.creatorName}{task.brandName} - = 80 ? 'text-accent-green' : task.aiScore >= 60 ? 'text-yellow-400' : 'text-accent-coral' + - {task.aiScore}分 + {isVideo ? '视频' : '脚本'} {task.submittedAt}{task.creator.name}{task.project.brand_name || task.project.name} + {aiScore != null ? ( + = 80 ? 'text-accent-green' : aiScore >= 60 ? 'text-yellow-400' : 'text-accent-coral' + }`}> + {aiScore}分 + + ) : ( + - + )} + + {new Date(task.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })} + @@ -390,7 +407,11 @@ export default function AgencyDashboard() {
暂无待审核任务
diff --git a/frontend/app/agency/review/[id]/page.tsx b/frontend/app/agency/review/[id]/page.tsx index 2ee3fdf..14b8ec7 100644 --- a/frontend/app/agency/review/[id]/page.tsx +++ b/frontend/app/agency/review/[id]/page.tsx @@ -1,57 +1,103 @@ '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 { ArrowLeft, Play, Pause, AlertTriangle, Shield, Radio } from 'lucide-react' +import { ArrowLeft, Play, Pause, AlertTriangle, Shield, Radio, Loader2 } from 'lucide-react' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card' import { Button } from '@/components/ui/Button' import { SuccessTag, WarningTag, ErrorTag } from '@/components/ui/Tag' import { Modal, ConfirmModal } from '@/components/ui/Modal' import { ReviewSteps, getAgencyReviewSteps } from '@/components/ui/ReviewSteps' +import { api } from '@/lib/api' +import { USE_MOCK } from '@/contexts/AuthContext' +import { useSSE } from '@/contexts/SSEContext' +import type { TaskResponse, AIReviewResult } from '@/types/task' -// 模拟审核任务数据 -const mockTask = { +// ==================== Mock 数据 ==================== +const mockTask: TaskResponse = { id: 'task-001', - videoTitle: '夏日护肤推广', - creatorName: '小美护肤', - brandName: 'XX护肤品牌', - platform: '抖音', - aiScore: 85, - aiSummary: '视频整体合规,发现2处硬性问题和1处舆情提示需人工确认', - reviewSteps: [ - { key: 'submitted', label: '已提交', status: 'done' as const, time: '2/3 10:30' }, - { key: 'ai_review', label: 'AI审核', status: 'done' as const, time: '2/3 10:35' }, - { key: 'agent_review', label: '代理商审核', status: 'current' as const }, - { key: 'final', label: '最终结果', status: 'pending' as const }, - ], - hardViolations: [ - { - id: 'v1', - type: '违禁词', - content: '效果最好', - timestamp: 15.5, - source: 'speech', - riskLevel: 'high', - aiConfidence: 0.95, - suggestion: '建议替换为"效果显著"', - }, - { - id: 'v2', - type: '竞品露出', - content: '疑似竞品Logo', - timestamp: 42.0, - source: 'visual', - riskLevel: 'high', - aiConfidence: 0.72, - suggestion: '需人工确认是否为竞品露出', - }, - ], - sentimentWarnings: [ - { id: 's1', type: '油腻预警', timestamp: 42.0, content: '达人表情过于夸张,建议检查', riskLevel: 'medium' }, - ], + name: '夏日护肤推广', + sequence: 1, + stage: 'script_agency_review', + project: { id: 'proj-001', name: 'XX品牌618推广', brand_name: 'XX护肤品牌' }, + agency: { id: 'ag-001', name: '优创代理' }, + creator: { id: 'cr-001', name: '小美护肤' }, + script_ai_score: 85, + script_ai_result: { + score: 85, + violations: [ + { + type: '违禁词', + content: '效果最好', + severity: 'high', + suggestion: '建议替换为"效果显著"', + timestamp: 15.5, + source: 'speech', + }, + { + type: '竞品露出', + content: '疑似竞品Logo', + severity: 'high', + suggestion: '需人工确认是否为竞品露出', + timestamp: 42.0, + source: 'visual', + }, + ], + soft_warnings: [ + { type: '油腻预警', content: '达人表情过于夸张,建议检查', suggestion: '软性风险仅作提示' }, + ], + summary: '视频整体合规,发现2处硬性问题和1处舆情提示需人工确认', + }, + video_ai_score: 85, + video_ai_result: { + score: 85, + violations: [ + { + type: '违禁词', + content: '效果最好', + severity: 'high', + suggestion: '建议替换为"效果显著"', + timestamp: 15.5, + source: 'speech', + }, + { + type: '竞品露出', + content: '疑似竞品Logo', + severity: 'high', + suggestion: '需人工确认是否为竞品露出', + timestamp: 42.0, + source: 'visual', + }, + ], + soft_warnings: [ + { type: '油腻预警', content: '达人表情过于夸张,建议检查', suggestion: '软性风险仅作提示' }, + ], + summary: '视频整体合规,发现2处硬性问题和1处舆情提示需人工确认', + }, + appeal_count: 0, + is_appeal: false, + created_at: '2026-02-03T10:30:00Z', + updated_at: '2026-02-03T10:35:00Z', } +// ==================== 工具函数 ==================== + +function getReviewStepStatus(task: TaskResponse): string { + if (task.stage.includes('agency_review')) return 'agent_reviewing' + if (task.stage.includes('brand_review')) return 'brand_reviewing' + if (task.stage === 'completed') return 'completed' + return 'agent_reviewing' +} + +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 ReviewProgressBar({ taskStatus }: { taskStatus: string }) { const steps = getAgencyReviewSteps(taskStatus) const currentStep = steps.find(s => s.status === 'current') @@ -77,16 +123,43 @@ function RiskLevelTag({ level }: { level: string }) { return 低风险 } -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 ReviewSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) } +// ==================== 主页面 ==================== + export default function ReviewPage() { const router = useRouter() const params = useParams() const toast = useToast() + const taskId = params.id as string + const { subscribe } = useSSE() + + const [task, setTask] = useState(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) @@ -96,37 +169,127 @@ export default function ReviewPage() { const [saveAsException, setSaveAsException] = useState(false) const [checkedViolations, setCheckedViolations] = useState>({}) - const task = mockTask + const loadTask = useCallback(async () => { + if (USE_MOCK) { + setTask(mockTask) + setLoading(false) + return + } - const handleApprove = () => { - setShowApproveModal(false) - router.push('/agency') + try { + const data = await api.getTask(taskId) + setTask(data) + } catch (err) { + console.error('Failed to load task:', err) + toast.error('加载任务失败') + } finally { + setLoading(false) + } + }, [taskId, toast]) + + useEffect(() => { + loadTask() + }, [loadTask]) + + useEffect(() => { + const unsub1 = subscribe('task_updated', (data: any) => { + if (data?.task_id === taskId) loadTask() + }) + const unsub2 = subscribe('review_completed', (data: any) => { + if (data?.task_id === taskId) loadTask() + }) + return () => { unsub1(); unsub2() } + }, [subscribe, taskId, loadTask]) + + if (loading || !task) return + + // Determine if this is script or video review + const isVideoReview = task.stage.includes('video') + const aiResult: AIReviewResult | null | undefined = isVideoReview ? task.video_ai_result : task.script_ai_result + const aiScore = isVideoReview ? task.video_ai_score : task.script_ai_score + + const violations = aiResult?.violations || [] + const softWarnings = aiResult?.soft_warnings || [] + const aiSummary = aiResult?.summary || '暂无 AI 分析总结' + + const handleApprove = async () => { + setSubmitting(true) + try { + if (!USE_MOCK) { + if (isVideoReview) { + await api.reviewVideo(taskId, { action: 'pass' }) + } else { + await api.reviewScript(taskId, { action: 'pass' }) + } + } + toast.success('审核已通过') + setShowApproveModal(false) + router.push('/agency/review') + } catch (err) { + console.error('Failed to approve:', err) + toast.error('操作失败,请重试') + } finally { + setSubmitting(false) + } } - const handleReject = () => { + const handleReject = async () => { if (!rejectReason.trim()) { toast.error('请填写驳回原因') return } - setShowRejectModal(false) - router.push('/agency') + setSubmitting(true) + try { + if (!USE_MOCK) { + if (isVideoReview) { + await api.reviewVideo(taskId, { action: 'reject', comment: rejectReason }) + } else { + await api.reviewScript(taskId, { action: 'reject', comment: rejectReason }) + } + } + toast.success('已驳回') + setShowRejectModal(false) + router.push('/agency/review') + } catch (err) { + console.error('Failed to reject:', err) + toast.error('操作失败,请重试') + } finally { + setSubmitting(false) + } } - const handleForcePass = () => { + const handleForcePass = async () => { if (!forcePassReason.trim()) { toast.error('请填写强制通过原因') return } - setShowForcePassModal(false) - router.push('/agency') + setSubmitting(true) + try { + if (!USE_MOCK) { + if (isVideoReview) { + await api.reviewVideo(taskId, { action: 'force_pass', comment: forcePassReason }) + } else { + await api.reviewScript(taskId, { action: 'force_pass', comment: forcePassReason }) + } + } + toast.success('已强制通过') + setShowForcePassModal(false) + router.push('/agency/review') + } catch (err) { + console.error('Failed to force pass:', err) + toast.error('操作失败,请重试') + } finally { + setSubmitting(false) + } } - // 计算问题时间点用于进度条展示 + // 时间线标记 const timelineMarkers = [ - ...task.hardViolations.map(v => ({ time: v.timestamp, type: 'hard' as const })), - ...task.sentimentWarnings.map(w => ({ time: w.timestamp, type: 'soft' as const })), + ...violations.filter(v => v.timestamp != null).map(v => ({ time: v.timestamp!, type: 'hard' as const })), ].sort((a, b) => a.time - b.time) + const maxTime = Math.max(120, ...timelineMarkers.map(m => m.time + 10)) + return (
{/* 顶部导航 */} @@ -135,64 +298,92 @@ export default function ReviewPage() {
-

{task.videoTitle}

-

{task.creatorName} · {task.brandName} · {task.platform}

+

{task.name}

+

+ {task.creator.name} · {task.project.brand_name || task.project.name} · {isVideoReview ? '视频审核' : '脚本审核'} +

+ {task.is_appeal && ( + + 申诉重审 + + )}
+ {/* 申诉理由 */} + {task.is_appeal && task.appeal_reason && ( + + +

申诉理由

+

{task.appeal_reason}

+
+
+ )} + {/* 审核流程进度条 */} - +
- {/* 左侧:视频播放器 (3/5) */} + {/* 左侧:视频/脚本播放器 (3/5) */}
-
- -
- {/* 智能进度条 */} -
-
智能进度条(点击跳转)
-
- {/* 时间标记点 */} - {timelineMarkers.map((marker, idx) => ( -
-
- 0:00 - 2:00 + ) : ( +
+
+

脚本预览区域

+

{task.script_file_name || '脚本文件'}

+
-
- - - 硬性问题 - - - - 舆情提示 - - - - 卖点覆盖 - + )} + + {/* 智能进度条(仅视频且有时间标记时显示) */} + {isVideoReview && timelineMarkers.length > 0 && ( +
+
智能进度条(点击跳转)
+
+ {timelineMarkers.map((marker, idx) => ( +
+
+ 0:00 + {formatTimestamp(maxTime)} +
+
+ + + 硬性问题 + + + + 舆情提示 + + + + 卖点覆盖 + +
-
+ )} @@ -201,11 +392,13 @@ export default function ReviewPage() {
AI 分析总结 - = 80 ? 'text-accent-green' : 'text-yellow-400'}`}> - {task.aiScore}分 - + {aiScore != null && ( + = 80 ? 'text-accent-green' : aiScore >= 60 ? 'text-yellow-400' : 'text-accent-coral'}`}> + {aiScore}分 + + )}
-

{task.aiSummary}

+

{aiSummary}

@@ -217,35 +410,42 @@ export default function ReviewPage() { - 硬性合规 ({task.hardViolations.length}) + 硬性合规 ({violations.length}) - {task.hardViolations.map((v) => ( -
-
- setCheckedViolations((prev) => ({ ...prev, [v.id]: !prev[v.id] }))} - className="mt-1 accent-accent-indigo" - /> -
-
- {v.type} - {formatTimestamp(v.timestamp)} + {violations.length > 0 ? violations.map((v, idx) => { + const key = `v-${idx}` + return ( +
+
+ setCheckedViolations((prev) => ({ ...prev, [key]: !prev[key] }))} + className="mt-1 accent-accent-indigo" + /> +
+
+ {v.type} + {v.timestamp != null && ( + {formatTimestamp(v.timestamp)} + )} +
+

「{v.content}」

+

{v.suggestion}

-

「{v.content}」

-

{v.suggestion}

-
- ))} + ) + }) : ( +
无硬性违规
+ )} {/* 舆情雷达 */} - {task.sentimentWarnings.length > 0 && ( + {softWarnings.length > 0 && ( @@ -254,14 +454,13 @@ export default function ReviewPage() { - {task.sentimentWarnings.map((w) => ( -
+ {softWarnings.map((w, idx) => ( +
{w.type} - {formatTimestamp(w.timestamp)}

{w.content}

-

⚠️ 软性风险仅作提示,不强制拦截

+

软性风险仅作提示,不强制拦截

))} @@ -275,16 +474,17 @@ export default function ReviewPage() {
- 已检查 {Object.values(checkedViolations).filter(Boolean).length}/{task.hardViolations.length} 个问题 + 已检查 {Object.values(checkedViolations).filter(Boolean).length}/{violations.length} 个问题
- - -
@@ -298,7 +498,7 @@ export default function ReviewPage() { onClose={() => setShowApproveModal(false)} onConfirm={handleApprove} title="确认通过" - message="确定要通过此视频的审核吗?通过后达人将收到通知。" + message={`确定要通过此${isVideoReview ? '视频' : '脚本'}的审核吗?通过后达人将收到通知。`} confirmText="确认通过" /> @@ -307,9 +507,11 @@ export default function ReviewPage() {

请填写驳回原因,已勾选的问题将自动打包发送给达人。

-

已选问题 ({Object.values(checkedViolations).filter(Boolean).length})

- {task.hardViolations.filter(v => checkedViolations[v.id]).map(v => ( -
• {v.type}: {v.content}
+

+ 已选问题 ({Object.values(checkedViolations).filter(Boolean).length}) +

+ {violations.filter((_, idx) => checkedViolations[`v-${idx}`]).map((v, idx) => ( +
- {v.type}: {v.content}
))} {Object.values(checkedViolations).filter(Boolean).length === 0 && (
未选择任何问题
@@ -325,8 +527,11 @@ export default function ReviewPage() { />
- - + +
@@ -359,8 +564,11 @@ export default function ReviewPage() { 保存为特例(需品牌方确认后生效)
- - + +
diff --git a/frontend/app/agency/review/page.tsx b/frontend/app/agency/review/page.tsx index 796d42b..4d44dc8 100644 --- a/frontend/app/agency/review/page.tsx +++ b/frontend/app/agency/review/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState } from 'react' +import { useState, useEffect, useCallback } from 'react' import Link from 'next/link' import { useToast } from '@/components/ui/Toast' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card' @@ -10,169 +10,136 @@ import { FileText, Video, Search, - Filter, Clock, - User, - AlertTriangle, - ChevronRight, - Download, Eye, File, - MessageSquareWarning + Download, + 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 { useSSE } from '@/contexts/SSEContext' +import type { TaskResponse } from '@/types/task' -// 模拟脚本待审列表 -const mockScriptTasks = [ +// ==================== Mock 数据 ==================== +const mockScriptTasks: TaskResponse[] = [ { - id: 'script-001', - title: '夏日护肤推广脚本', - fileName: '夏日护肤推广_脚本v2.docx', - fileSize: '245 KB', - creatorName: '小美护肤', - projectName: 'XX品牌618推广', - platform: 'douyin', - aiScore: 88, - riskLevel: 'low' as const, - submittedAt: '2026-02-06 14:30', - hasHighRisk: false, - isAppeal: false, // 是否为申诉 + id: 'script-001', name: '夏日护肤推广脚本', sequence: 1, + stage: 'script_agency_review', + project: { id: 'proj-001', name: 'XX品牌618推广', brand_name: 'XX品牌' }, + agency: { id: 'ag-001', name: '优创代理' }, + creator: { id: 'cr-001', name: '小美护肤' }, + script_file_name: '夏日护肤推广_脚本v2.docx', + script_ai_score: 88, + script_ai_result: { score: 88, violations: [], soft_warnings: [] }, + appeal_count: 0, is_appeal: false, + created_at: '2026-02-06T14:30:00Z', updated_at: '2026-02-06T14:30:00Z', }, { - id: 'script-002', - title: '新品口红试色脚本', - fileName: '口红试色_脚本v1.docx', - fileSize: '312 KB', - creatorName: '美妆Lisa', - projectName: 'XX品牌618推广', - platform: 'xiaohongshu', - aiScore: 72, - riskLevel: 'medium' as const, - submittedAt: '2026-02-06 12:15', - hasHighRisk: true, - isAppeal: true, // 申诉重审 - appealReason: '已修改违规用词,请求重新审核', + id: 'script-002', name: '新品口红试色脚本', sequence: 2, + stage: 'script_agency_review', + project: { id: 'proj-001', name: 'XX品牌618推广', brand_name: 'XX品牌' }, + agency: { id: 'ag-001', name: '优创代理' }, + creator: { id: 'cr-002', name: '美妆Lisa' }, + script_file_name: '口红试色_脚本v1.docx', + script_ai_score: 72, + script_ai_result: { score: 72, violations: [{ type: '违禁词', content: '最好', severity: 'medium', suggestion: '替换' }], soft_warnings: [] }, + appeal_count: 1, is_appeal: true, appeal_reason: '已修改违规用词,请求重新审核', + created_at: '2026-02-06T12:15:00Z', updated_at: '2026-02-06T12:15:00Z', }, { - id: 'script-003', - title: '健身器材推荐脚本', - fileName: '健身器材_推荐脚本.pdf', - fileSize: '189 KB', - creatorName: '健身教练王', - projectName: 'XX运动品牌', - platform: 'bilibili', - aiScore: 95, - riskLevel: 'low' as const, - submittedAt: '2026-02-06 10:00', - hasHighRisk: false, - isAppeal: false, - }, - { - id: 'script-004', - title: '618大促预热脚本', - fileName: '618预热_脚本final.docx', - fileSize: '278 KB', - creatorName: '达人D', - projectName: 'XX品牌618推广', - platform: 'kuaishou', - aiScore: 62, - riskLevel: 'high' as const, - submittedAt: '2026-02-06 09:00', - hasHighRisk: true, - isAppeal: true, - appealReason: '对驳回原因有异议,内容符合要求', + id: 'script-003', name: '健身器材推荐脚本', sequence: 3, + stage: 'script_agency_review', + project: { id: 'proj-002', name: 'XX运动品牌', brand_name: 'XX运动' }, + agency: { id: 'ag-001', name: '优创代理' }, + creator: { id: 'cr-003', name: '健身教练王' }, + script_file_name: '健身器材_推荐脚本.pdf', + script_ai_score: 95, + script_ai_result: { score: 95, violations: [], soft_warnings: [] }, + appeal_count: 0, is_appeal: false, + created_at: '2026-02-06T10:00:00Z', updated_at: '2026-02-06T10:00:00Z', }, ] -// 模拟视频待审列表 -const mockVideoTasks = [ +const mockVideoTasks: TaskResponse[] = [ { - id: 'video-001', - title: '夏日护肤推广', - fileName: '夏日护肤_成片v2.mp4', - fileSize: '128 MB', - creatorName: '小美护肤', - projectName: 'XX品牌618推广', - platform: 'douyin', - aiScore: 85, - riskLevel: 'low' as const, - duration: '02:15', - submittedAt: '2026-02-06 15:00', - hasHighRisk: false, - isAppeal: false, + id: 'video-001', name: '夏日护肤推广', sequence: 1, + stage: 'video_agency_review', + project: { id: 'proj-001', name: 'XX品牌618推广', brand_name: 'XX品牌' }, + agency: { id: 'ag-001', name: '优创代理' }, + creator: { id: 'cr-001', name: '小美护肤' }, + video_file_name: '夏日护肤_成片v2.mp4', + video_duration: 135, video_ai_score: 85, + video_ai_result: { score: 85, violations: [], soft_warnings: [] }, + appeal_count: 0, is_appeal: false, + created_at: '2026-02-06T15:00:00Z', updated_at: '2026-02-06T15:00:00Z', }, { - id: 'video-002', - title: '新品口红试色', - fileName: '口红试色_终版.mp4', - fileSize: '256 MB', - creatorName: '美妆Lisa', - projectName: 'XX品牌618推广', - platform: 'xiaohongshu', - aiScore: 68, - riskLevel: 'medium' as const, - duration: '03:42', - submittedAt: '2026-02-06 13:45', - hasHighRisk: true, - isAppeal: true, - appealReason: '已按要求重新剪辑,删除了争议片段', + id: 'video-002', name: '新品口红试色', sequence: 2, + stage: 'video_agency_review', + project: { id: 'proj-001', name: 'XX品牌618推广', brand_name: 'XX品牌' }, + agency: { id: 'ag-001', name: '优创代理' }, + creator: { id: 'cr-002', name: '美妆Lisa' }, + video_file_name: '口红试色_终版.mp4', + video_duration: 222, video_ai_score: 68, + video_ai_result: { score: 68, violations: [{ type: '竞品', content: '疑似竞品', severity: 'high', suggestion: '确认' }], soft_warnings: [] }, + appeal_count: 1, is_appeal: true, appeal_reason: '已按要求重新剪辑,删除了争议片段', + created_at: '2026-02-06T13:45:00Z', updated_at: '2026-02-06T13:45:00Z', }, { - id: 'video-003', - title: '美妆新品体验', - fileName: '美妆体验_v3.mp4', - fileSize: '198 MB', - creatorName: '达人C', - projectName: 'XX品牌618推广', - platform: 'bilibili', - aiScore: 58, - riskLevel: 'high' as const, - duration: '04:20', - submittedAt: '2026-02-06 11:30', - hasHighRisk: true, - isAppeal: false, - }, - { - id: 'video-004', - title: '618大促预热', - fileName: '618预热_final.mp4', - fileSize: '167 MB', - creatorName: '达人D', - projectName: 'XX品牌618推广', - platform: 'wechat', - aiScore: 91, - riskLevel: 'low' as const, - duration: '01:45', - submittedAt: '2026-02-06 10:15', - hasHighRisk: false, - isAppeal: false, + id: 'video-003', name: '美妆新品体验', sequence: 3, + stage: 'video_agency_review', + project: { id: 'proj-001', name: 'XX品牌618推广', brand_name: 'XX品牌' }, + agency: { id: 'ag-001', name: '优创代理' }, + creator: { id: 'cr-003', name: '达人C' }, + video_file_name: '美妆体验_v3.mp4', + video_duration: 260, video_ai_score: 58, + video_ai_result: { score: 58, violations: [{ type: '违禁词', content: '最好', severity: 'high', suggestion: '替换' }], soft_warnings: [] }, + appeal_count: 0, is_appeal: false, + created_at: '2026-02-06T11:30:00Z', updated_at: '2026-02-06T11:30:00Z', }, ] -// 风险等级配置 +// ==================== 工具函数 ==================== + +function getRiskLevel(task: TaskResponse, type: 'script' | 'video'): 'low' | 'medium' | 'high' { + const score = type === 'script' ? task.script_ai_score : task.video_ai_score + if (score == null) return 'low' + if (score >= 85) return 'low' + if (score >= 70) return 'medium' + return 'high' +} + const riskLevelConfig = { low: { label: 'AI通过', color: 'bg-accent-green', textColor: 'text-accent-green' }, medium: { label: '风险:中', color: 'bg-accent-amber', textColor: 'text-accent-amber' }, high: { label: '风险:高', color: 'bg-accent-coral', textColor: 'text-accent-coral' }, } +function formatDuration(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 ScoreTag({ score }: { score: number }) { if (score >= 85) return {score}分 if (score >= 70) return {score}分 return {score}分 } -type ScriptTask = typeof mockScriptTasks[0] -type VideoTask = typeof mockVideoTasks[0] +// ==================== 卡片组件 ==================== -function ScriptTaskCard({ task, onPreview, toast }: { task: ScriptTask; onPreview: (task: ScriptTask) => void; toast: ReturnType }) { - const riskConfig = riskLevelConfig[task.riskLevel] - const platform = getPlatformInfo(task.platform) +function ScriptTaskCard({ task, onPreview, toast }: { task: TaskResponse; onPreview: (task: TaskResponse) => void; toast: ReturnType }) { + const riskLevel = getRiskLevel(task, 'script') + const riskConfig = riskLevelConfig[riskLevel] const handleDownload = (e: React.MouseEvent) => { e.stopPropagation() - toast.info(`下载文件: ${task.fileName}`) + toast.info(`下载文件: ${task.script_file_name || '脚本文件'}`) } const handlePreview = (e: React.MouseEvent) => { @@ -182,78 +149,59 @@ function ScriptTaskCard({ task, onPreview, toast }: { task: ScriptTask; onPrevie return (
- {/* 平台顶部条 */} - {platform && ( -
- {platform.icon} - {platform.name} - {/* 申诉标识 */} - {task.isAppeal && ( - - - 申诉 - - )} -
- )} + {/* 顶部条 */} +
+ {task.project.brand_name || task.project.name} + {task.is_appeal && ( + + + 申诉 + + )} +
- {/* 顶部:达人名 · 任务名 + 状态标签 */}
- {task.creatorName} · {task.title} + {task.creator.name} · {task.name}
{riskConfig.label}
- {/* 申诉理由 */} - {task.isAppeal && task.appealReason && ( + {task.is_appeal && task.appeal_reason && (

申诉理由

-

{task.appealReason}

+

{task.appeal_reason}

)} - {/* 文件信息 */}
-

{task.fileName}

-

{task.fileSize}

+

{task.script_file_name || '脚本文件'}

- -
- {/* 底部:时间 + 审核按钮 */}
- {task.submittedAt} + {new Date(task.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })} - +
@@ -262,13 +210,13 @@ function ScriptTaskCard({ task, onPreview, toast }: { task: ScriptTask; onPrevie ) } -function VideoTaskCard({ task, onPreview, toast }: { task: VideoTask; onPreview: (task: VideoTask) => void; toast: ReturnType }) { - const riskConfig = riskLevelConfig[task.riskLevel] - const platform = getPlatformInfo(task.platform) +function VideoTaskCard({ task, onPreview, toast }: { task: TaskResponse; onPreview: (task: TaskResponse) => void; toast: ReturnType }) { + const riskLevel = getRiskLevel(task, 'video') + const riskConfig = riskLevelConfig[riskLevel] const handleDownload = (e: React.MouseEvent) => { e.stopPropagation() - toast.info(`下载文件: ${task.fileName}`) + toast.info(`下载文件: ${task.video_file_name || '视频文件'}`) } const handlePreview = (e: React.MouseEvent) => { @@ -278,78 +226,61 @@ function VideoTaskCard({ task, onPreview, toast }: { task: VideoTask; onPreview: return (
- {/* 平台顶部条 */} - {platform && ( -
- {platform.icon} - {platform.name} - {/* 申诉标识 */} - {task.isAppeal && ( - - - 申诉 - - )} -
- )} +
+ {task.project.brand_name || task.project.name} + {task.is_appeal && ( + + + 申诉 + + )} +
- {/* 顶部:达人名 · 任务名 + 状态标签 */}
- {task.creatorName} · {task.title} + {task.creator.name} · {task.name}
{riskConfig.label}
- {/* 申诉理由 */} - {task.isAppeal && task.appealReason && ( + {task.is_appeal && task.appeal_reason && (

申诉理由

-

{task.appealReason}

+

{task.appeal_reason}

)} - {/* 文件信息 */}
-

{task.fileName}

-

{task.fileSize} · {task.duration}

+

{task.video_file_name || '视频文件'}

+ {task.video_duration && ( +

{formatDuration(task.video_duration)}

+ )}
- -
- {/* 底部:时间 + 审核按钮 */}
- {task.submittedAt} + {new Date(task.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })} - +
@@ -358,26 +289,103 @@ function VideoTaskCard({ task, onPreview, toast }: { task: VideoTask; onPreview: ) } +// ==================== 骨架屏 ==================== + +function ReviewListSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+ {[1, 2].map(i => ( +
+
+ {[1, 2, 3].map(j => ( +
+ ))} +
+ ))} +
+
+ ) +} + +// ==================== 主页面 ==================== + export default function AgencyReviewListPage() { const [searchQuery, setSearchQuery] = useState('') const [activeTab, setActiveTab] = useState<'all' | 'script' | 'video'>('all') - const [previewScript, setPreviewScript] = useState(null) - const [previewVideo, setPreviewVideo] = useState(null) + const [previewTask, setPreviewTask] = useState(null) + const [previewType, setPreviewType] = useState<'script' | 'video'>('script') + const [scriptTasks, setScriptTasks] = useState([]) + const [videoTasks, setVideoTasks] = useState([]) + const [loading, setLoading] = useState(true) const toast = useToast() + const { subscribe } = useSSE() - const filteredScripts = mockScriptTasks.filter(task => - task.title.toLowerCase().includes(searchQuery.toLowerCase()) || - task.creatorName.toLowerCase().includes(searchQuery.toLowerCase()) + const loadData = useCallback(async () => { + if (USE_MOCK) { + setScriptTasks(mockScriptTasks) + setVideoTasks(mockVideoTasks) + setLoading(false) + return + } + + try { + const [scriptData, videoData] = await Promise.all([ + api.listTasks(1, 50, 'script_agency_review'), + api.listTasks(1, 50, 'video_agency_review'), + ]) + setScriptTasks(scriptData.items) + setVideoTasks(videoData.items) + } catch (err) { + console.error('Failed to load review tasks:', err) + toast.error('加载审核任务失败') + } finally { + setLoading(false) + } + }, [toast]) + + useEffect(() => { + loadData() + }, [loadData]) + + useEffect(() => { + const unsub1 = subscribe('task_updated', () => loadData()) + const unsub2 = subscribe('new_task', () => loadData()) + return () => { unsub1(); unsub2() } + }, [subscribe, loadData]) + + if (loading) return + + const filteredScripts = scriptTasks.filter(task => + task.name.toLowerCase().includes(searchQuery.toLowerCase()) || + task.creator.name.toLowerCase().includes(searchQuery.toLowerCase()) ) - const filteredVideos = mockVideoTasks.filter(task => - task.title.toLowerCase().includes(searchQuery.toLowerCase()) || - task.creatorName.toLowerCase().includes(searchQuery.toLowerCase()) + const filteredVideos = videoTasks.filter(task => + task.name.toLowerCase().includes(searchQuery.toLowerCase()) || + task.creator.name.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.is_appeal).length + const appealVideoCount = videoTasks.filter(t => t.is_appeal).length + + const handleScriptPreview = (task: TaskResponse) => { + setPreviewTask(task) + setPreviewType('script') + } + + const handleVideoPreview = (task: TaskResponse) => { + setPreviewTask(task) + setPreviewType('video') + } return (
@@ -390,10 +398,10 @@ export default function AgencyReviewListPage() {
待审核: - {mockScriptTasks.length} 脚本 + {scriptTasks.length} 脚本 - {mockVideoTasks.length} 视频 + {videoTasks.length} 视频 {(appealScriptCount + appealVideoCount) > 0 && ( @@ -449,7 +457,6 @@ export default function AgencyReviewListPage() { {/* 任务列表 */}
- {/* 脚本待审列表 */} {(activeTab === 'all' || activeTab === 'script') && ( @@ -464,7 +471,7 @@ export default function AgencyReviewListPage() { {filteredScripts.length > 0 ? ( filteredScripts.map((task) => ( - + )) ) : (
@@ -476,7 +483,6 @@ export default function AgencyReviewListPage() { )} - {/* 视频待审列表 */} {(activeTab === 'all' || activeTab === 'video') && ( @@ -491,7 +497,7 @@ export default function AgencyReviewListPage() { {filteredVideos.length > 0 ? ( filteredVideos.map((task) => ( - + )) ) : (
@@ -504,86 +510,57 @@ export default function AgencyReviewListPage() { )}
- {/* 脚本预览弹窗 */} + {/* 预览弹窗 */} setPreviewScript(null)} - title={previewScript?.fileName || '脚本预览'} + isOpen={!!previewTask} + onClose={() => setPreviewTask(null)} + title={previewType === 'script' ? (previewTask?.script_file_name || '脚本预览') : (previewTask?.video_file_name || '视频预览')} size="lg" >
- {previewScript?.isAppeal && previewScript?.appealReason && ( + {previewTask?.is_appeal && previewTask?.appeal_reason && (

申诉理由

-

{previewScript.appealReason}

+

{previewTask.appeal_reason}

)} -
-
- -

脚本预览区域

-

实际开发中将嵌入文档预览组件

-
-
-
-
- {previewScript?.fileName} - · - {previewScript?.fileSize} -
-
- - -
-
-
-
- {/* 视频预览弹窗 */} - setPreviewVideo(null)} - title={previewVideo?.fileName || '视频预览'} - size="lg" - > -
- {previewVideo?.isAppeal && previewVideo?.appealReason && ( -
-

- - 申诉理由 -

-

{previewVideo.appealReason}

+ {previewType === 'script' ? ( +
+
+ +

脚本预览区域

+

实际开发中将嵌入文档预览组件

+
+
+ ) : ( +
+
+
)} -
-
-
-
+
- {previewVideo?.fileName} - · - {previewVideo?.fileSize} - · - {previewVideo?.duration} + {previewType === 'script' ? previewTask?.script_file_name : previewTask?.video_file_name} + {previewType === 'video' && previewTask?.video_duration && ( + <> + · + {formatDuration(previewTask.video_duration)} + + )}
- - diff --git a/frontend/app/brand/page.tsx b/frontend/app/brand/page.tsx index 5ff3f27..fac5058 100644 --- a/frontend/app/brand/page.tsx +++ b/frontend/app/brand/page.tsx @@ -1,10 +1,9 @@ '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 { Card, CardContent } from '@/components/ui/Card' import { Button } from '@/components/ui/Button' -import { Input } from '@/components/ui/Input' import { SuccessTag, PendingTag, WarningTag } from '@/components/ui/Tag' import { Modal } from '@/components/ui/Modal' import { @@ -16,123 +15,65 @@ import { ChevronRight, Calendar, Users, - Pencil + Pencil, + Loader2 } from 'lucide-react' +import { api } from '@/lib/api' +import { USE_MOCK } from '@/contexts/AuthContext' +import { useSSE } from '@/contexts/SSEContext' +import { useToast } from '@/components/ui/Toast' +import type { ProjectResponse } from '@/types/project' -// 平台选项 - 抖音用青色(品牌渐变色之一),深色主题下更清晰 -const platformOptions = [ - { id: 'douyin', name: '抖音', icon: '🎵', bgColor: 'bg-[#25F4EE]/15', textColor: 'text-[#25F4EE]', borderColor: 'border-[#25F4EE]/30' }, - { id: 'xiaohongshu', name: '小红书', icon: '📕', bgColor: 'bg-[#fe2c55]/15', textColor: 'text-[#fe2c55]', borderColor: 'border-[#fe2c55]/30' }, - { id: 'bilibili', name: 'B站', icon: '📺', bgColor: 'bg-[#00a1d6]/15', textColor: 'text-[#00a1d6]', borderColor: 'border-[#00a1d6]/30' }, - { id: 'kuaishou', name: '快手', icon: '⚡', bgColor: 'bg-[#ff4906]/15', textColor: 'text-[#ff4906]', borderColor: 'border-[#ff4906]/30' }, - { id: 'weibo', name: '微博', icon: '🔴', bgColor: 'bg-[#e6162d]/15', textColor: 'text-[#e6162d]', borderColor: 'border-[#e6162d]/30' }, - { id: 'wechat', name: '微信视频号', icon: '💬', bgColor: 'bg-[#07c160]/15', textColor: 'text-[#07c160]', borderColor: 'border-[#07c160]/30' }, -] - -// 项目类型定义 -interface Project { - id: string - name: string - status: string - platform: string - deadline: string - scriptCount: { total: number; passed: number; pending: number; rejected: number } - videoCount: { total: number; passed: number; pending: number; rejected: number } - agencyCount: number - creatorCount: number -} - -// 模拟项目数据 -const initialProjects: Project[] = [ +// ==================== Mock 数据 ==================== +const mockProjects: ProjectResponse[] = [ { - id: 'proj-001', - name: 'XX品牌618推广', - status: 'active', - platform: 'douyin', - deadline: '2026-06-18', - scriptCount: { total: 20, passed: 15, pending: 3, rejected: 2 }, - videoCount: { total: 20, passed: 12, pending: 5, rejected: 3 }, - agencyCount: 3, - creatorCount: 15, + id: 'proj-001', name: 'XX品牌618推广', brand_id: 'br-001', brand_name: 'XX品牌', + status: 'active', deadline: '2026-06-18', agencies: [], + task_count: 20, created_at: '2026-02-01T00:00:00Z', updated_at: '2026-02-05T00:00:00Z', }, { - id: 'proj-002', - name: '新品口红系列', - status: 'active', - platform: 'xiaohongshu', - deadline: '2026-03-15', - scriptCount: { total: 12, passed: 10, pending: 1, rejected: 1 }, - videoCount: { total: 12, passed: 8, pending: 3, rejected: 1 }, - agencyCount: 2, - creatorCount: 8, + id: 'proj-002', name: '新品口红系列', brand_id: 'br-001', brand_name: 'XX品牌', + status: 'active', deadline: '2026-03-15', agencies: [], + task_count: 12, created_at: '2026-01-15T00:00:00Z', updated_at: '2026-02-01T00:00:00Z', }, { - id: 'proj-003', - name: '护肤品秋季活动', - status: 'completed', - platform: 'bilibili', - deadline: '2025-11-30', - scriptCount: { total: 15, passed: 15, pending: 0, rejected: 0 }, - videoCount: { total: 15, passed: 15, pending: 0, rejected: 0 }, - agencyCount: 2, - creatorCount: 10, + id: 'proj-003', name: '护肤品秋季活动', brand_id: 'br-001', brand_name: 'XX品牌', + status: 'completed', deadline: '2025-11-30', agencies: [], + task_count: 15, created_at: '2025-08-01T00:00:00Z', updated_at: '2025-11-30T00:00:00Z', }, { - id: 'proj-004', - name: '双11预热活动', - status: 'active', - platform: 'kuaishou', - deadline: '2026-11-11', - scriptCount: { total: 18, passed: 8, pending: 6, rejected: 4 }, - videoCount: { total: 18, passed: 5, pending: 10, rejected: 3 }, - agencyCount: 4, - creatorCount: 20, + id: 'proj-004', name: '双11预热活动', brand_id: 'br-001', brand_name: 'XX品牌', + status: 'active', deadline: '2026-11-11', agencies: [], + task_count: 18, created_at: '2026-01-10T00:00:00Z', updated_at: '2026-02-04T00:00:00Z', }, ] -// 获取平台信息 -function getPlatformInfo(platformId: string) { - return platformOptions.find(p => p.id === platformId) -} +// ==================== 组件 ==================== function StatusTag({ status }: { status: string }) { if (status === 'active') return 进行中 if (status === 'completed') return 已完成 + if (status === 'archived') return 已归档 return 暂停 } -function ProjectCard({ project, onEditDeadline }: { project: Project; onEditDeadline: (project: Project) => void }) { - const scriptProgress = Math.round((project.scriptCount.passed / project.scriptCount.total) * 100) - const videoProgress = Math.round((project.videoCount.passed / project.videoCount.total) * 100) - const platform = getPlatformInfo(project.platform) - +function ProjectCard({ project, onEditDeadline }: { project: ProjectResponse; onEditDeadline: (project: ProjectResponse) => void }) { return ( - {/* 平台顶部条 */} - {platform && ( -
-
- {platform.icon} - {platform.name} -
- -
- )} +
+ {project.brand_name || '品牌项目'} + +
- {/* 项目头部 */}

{project.name}

- 截止 {project.deadline} + 截止 {project.deadline ? new Date(project.deadline).toLocaleDateString('zh-CN') : '未设置'}
- {/* 脚本进度 */} -
-
- - - 脚本审核 - - - {project.scriptCount.passed}/{project.scriptCount.total} - -
-
-
-
-
- 通过 {project.scriptCount.passed} - 待审 {project.scriptCount.pending} - 驳回 {project.scriptCount.rejected} -
+
+ {project.task_count} 个任务 + {project.agencies.length} 个代理商
- {/* 视频进度 */} -
-
- - - - {project.videoCount.passed}/{project.videoCount.total} - -
-
-
-
-
- 通过 {project.videoCount.passed} - 待审 {project.videoCount.pending} - 驳回 {project.videoCount.rejected} -
-
- - {/* 参与方统计 */}
-
- - - {project.agencyCount} 代理商 - - {project.creatorCount} 达人 +
+ 创建于 {new Date(project.created_at).toLocaleDateString('zh-CN')}
@@ -206,46 +99,99 @@ function ProjectCard({ project, onEditDeadline }: { project: Project; onEditDead ) } +function ProjectsSkeleton() { + return ( +
+
+
+
+
+
+
+ {[1, 2, 3, 4].map(i => ( +
+ ))} +
+
+ ) +} + export default function BrandProjectsPage() { const [searchQuery, setSearchQuery] = useState('') const [statusFilter, setStatusFilter] = useState('all') - const [platformFilter, setPlatformFilter] = useState('all') - const [projects, setProjects] = useState(initialProjects) + const [projects, setProjects] = useState([]) + const [loading, setLoading] = useState(true) + const toast = useToast() + const { subscribe } = useSSE() - // 编辑截止日期相关状态 + // 编辑截止日期 const [showDeadlineModal, setShowDeadlineModal] = useState(false) - const [editingProject, setEditingProject] = useState(null) + const [editingProject, setEditingProject] = useState(null) const [newDeadline, setNewDeadline] = useState('') - // 打开编辑截止日期弹窗 - const handleEditDeadline = (project: Project) => { + const loadProjects = useCallback(async () => { + if (USE_MOCK) { + setProjects(mockProjects) + setLoading(false) + return + } + + try { + const statusParam = statusFilter !== 'all' ? statusFilter : undefined + const data = await api.listProjects(1, 50, statusParam) + setProjects(data.items) + } catch (err) { + console.error('Failed to load projects:', err) + toast.error('加载项目列表失败') + } finally { + setLoading(false) + } + }, [statusFilter, toast]) + + useEffect(() => { + loadProjects() + }, [loadProjects]) + + useEffect(() => { + const unsub = subscribe('task_updated', () => loadProjects()) + return unsub + }, [subscribe, loadProjects]) + + const handleEditDeadline = (project: ProjectResponse) => { setEditingProject(project) - setNewDeadline(project.deadline) + setNewDeadline(project.deadline || '') setShowDeadlineModal(true) } - // 保存截止日期 - const handleSaveDeadline = () => { + const handleSaveDeadline = async () => { if (!editingProject || !newDeadline) return - setProjects(prev => prev.map(p => - p.id === editingProject.id ? { ...p, deadline: newDeadline } : p - )) + try { + if (!USE_MOCK) { + await api.updateProject(editingProject.id, { deadline: newDeadline }) + } + setProjects(prev => prev.map(p => + p.id === editingProject.id ? { ...p, deadline: newDeadline } : p + )) + toast.success('截止日期已更新') + } catch (err) { + console.error('Failed to update deadline:', err) + toast.error('更新失败') + } setShowDeadlineModal(false) setEditingProject(null) - setNewDeadline('') } + if (loading) return + const filteredProjects = projects.filter(project => { const matchesSearch = project.name.toLowerCase().includes(searchQuery.toLowerCase()) const matchesStatus = statusFilter === 'all' || project.status === statusFilter - const matchesPlatform = platformFilter === 'all' || project.platform === platformFilter - return matchesSearch && matchesStatus && matchesPlatform + return matchesSearch && matchesStatus }) return (
- {/* 页面标题和操作 */}

项目看板

@@ -259,7 +205,6 @@ export default function BrandProjectsPage() {
- {/* 搜索和筛选 */}
@@ -273,16 +218,6 @@ export default function BrandProjectsPage() {
-
- {/* 平台快捷筛选 */} -
- - {platformOptions.map(platform => ( - - ))} -
- - {/* 项目卡片网格 */}
{filteredProjects.map((project) => ( @@ -348,14 +252,9 @@ export default function BrandProjectsPage() {
)} - {/* 编辑截止日期弹窗 */} { - setShowDeadlineModal(false) - setEditingProject(null) - setNewDeadline('') - }} + onClose={() => { setShowDeadlineModal(false); setEditingProject(null) }} title="修改截止日期" >
@@ -365,11 +264,8 @@ export default function BrandProjectsPage() {

{editingProject.name}

)} -
- +
-
- -
diff --git a/frontend/app/brand/projects/[id]/config/page.tsx b/frontend/app/brand/projects/[id]/config/page.tsx index 2a751d2..d9c33e4 100644 --- a/frontend/app/brand/projects/[id]/config/page.tsx +++ b/frontend/app/brand/projects/[id]/config/page.tsx @@ -1,75 +1,83 @@ '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' +import { Card, CardContent } from '@/components/ui/Card' import { Button } from '@/components/ui/Button' import { Input } from '@/components/ui/Input' import { ArrowLeft, FileText, Shield, - Settings, Plus, Trash2, AlertTriangle, CheckCircle, - Video, Bot, Users, Save, Upload, - Download, ChevronDown, - ChevronUp + ChevronUp, + Loader2 } from 'lucide-react' +import { api } from '@/lib/api' +import { USE_MOCK } from '@/contexts/AuthContext' +import { useOSSUpload } from '@/hooks/useOSSUpload' +import type { BriefResponse, BriefCreateRequest, SellingPoint, BlacklistWord, BriefAttachment } from '@/types/brief' -// 模拟数据 -const mockData = { - project: { - id: 'proj-001', - name: 'XX品牌618推广', - }, - brief: { - title: 'XX品牌618推广Brief', - description: '本次618大促营销活动,需要达人围绕夏日护肤、美妆新品进行内容创作。', - requirements: [ - '视频时长:60-90秒', - '必须展示产品使用过程', - '需要口播品牌slogan:"XX品牌,夏日焕新"', - '背景音乐需使用品牌指定曲库', +// ==================== Mock 数据 ==================== +const mockBrief: BriefResponse = { + id: 'bf-001', + project_id: 'proj-001', + project_name: 'XX品牌618推广', + selling_points: [ + { content: '视频时长:60-90秒', required: true }, + { content: '必须展示产品使用过程', required: true }, + { content: '需要口播品牌slogan:"XX品牌,夏日焕新"', required: true }, + { content: '背景音乐需使用品牌指定曲库', required: false }, + ], + blacklist_words: [ + { word: '最好', reason: '违反广告法' }, + { word: '第一', reason: '违反广告法' }, + { word: '绝对', reason: '夸大宣传' }, + { word: '100%', reason: '夸大宣传' }, + ], + competitors: ['竞品A', '竞品B', '竞品C'], + brand_tone: '年轻、活力、清新', + min_duration: 60, + max_duration: 90, + other_requirements: '本次618大促营销活动,需要达人围绕夏日护肤、美妆新品进行内容创作。', + attachments: [ + { id: 'att-001', name: '品牌视觉指南.pdf', url: 'https://example.com/brand-guide.pdf' }, + { id: 'att-002', name: '产品资料包.zip', url: 'https://example.com/product-pack.zip' }, + ], + created_at: '2026-02-01T00:00:00Z', + updated_at: '2026-02-05T00:00:00Z', +} + +const mockRules = { + aiReview: { + enabled: true, + strictness: 'medium', + checkItems: [ + { id: 'forbidden_words', name: '违禁词检测', enabled: true }, + { id: 'competitor', name: '竞品提及检测', enabled: true }, + { id: 'brand_tone', name: '品牌调性检测', enabled: true }, + { id: 'duration', name: '视频时长检测', enabled: true }, + { id: 'music', name: '背景音乐检测', enabled: false }, ], - keywords: ['夏日护肤', '清爽', '补水', '防晒', '焕新'], - forbiddenWords: ['最好', '第一', '绝对', '100%'], - referenceLinks: [ - { title: '品牌视觉指南', url: 'https://example.com/brand-guide.pdf' }, - { title: '产品资料包', url: 'https://example.com/product-pack.zip' }, - ], - deadline: '2026-06-10', }, - rules: { - aiReview: { - enabled: true, - strictness: 'medium', // low, medium, high - checkItems: [ - { id: 'forbidden_words', name: '违禁词检测', enabled: true }, - { id: 'competitor', name: '竞品提及检测', enabled: true }, - { id: 'brand_tone', name: '品牌调性检测', enabled: true }, - { id: 'duration', name: '视频时长检测', enabled: true }, - { id: 'music', name: '背景音乐检测', enabled: false }, - ], - }, - manualReview: { - scriptRequired: true, - videoRequired: true, - agencyCanApprove: true, // 代理商是否有终审权限 - brandFinalReview: true, // 品牌方是否需要终审 - }, - appealRules: { - maxAppeals: 3, // 最大申诉次数 - appealDeadline: 48, // 申诉处理时限(小时) - }, + manualReview: { + scriptRequired: true, + videoRequired: true, + agencyCanApprove: true, + brandFinalReview: true, + }, + appealRules: { + maxAppeals: 3, + appealDeadline: 48, }, } @@ -80,64 +88,203 @@ const strictnessOptions = [ { value: 'high', label: '严格', description: '严格检测,可能有较多误判' }, ] +function ConfigSkeleton() { + return ( +
+
+
+
+
+
+
+
+ {[1, 2, 3, 4].map(i => ( +
+ ))} +
+ ) +} + export default function ProjectConfigPage() { const router = useRouter() const params = useParams() const toast = useToast() const projectId = params.id as string + const { upload, isUploading, progress: uploadProgress } = useOSSUpload('general') + + // Brief state + const [briefExists, setBriefExists] = useState(false) + const [loading, setLoading] = useState(true) + const [projectName, setProjectName] = useState('') + + // Brief form fields + const [brandTone, setBrandTone] = useState('') + const [otherRequirements, setOtherRequirements] = useState('') + const [minDuration, setMinDuration] = useState() + const [maxDuration, setMaxDuration] = useState() + const [sellingPoints, setSellingPoints] = useState([]) + const [blacklistWords, setBlacklistWords] = useState([]) + const [competitors, setCompetitors] = useState([]) + const [attachments, setAttachments] = useState([]) + + // Rules state (local only — no per-project backend API yet) + const [rules, setRules] = useState(mockRules) - const [brief, setBrief] = useState(mockData.brief) - const [rules, setRules] = useState(mockData.rules) const [isSaving, setIsSaving] = useState(false) const [activeSection, setActiveSection] = useState('brief') - // 新增需求 - const [newRequirement, setNewRequirement] = useState('') - // 新增关键词 - const [newKeyword, setNewKeyword] = useState('') - // 新增违禁词 - const [newForbiddenWord, setNewForbiddenWord] = useState('') + // Input fields + const [newSellingPoint, setNewSellingPoint] = useState('') + const [newBlacklistWord, setNewBlacklistWord] = useState('') + const [newBlacklistReason, setNewBlacklistReason] = useState('') + const [newCompetitor, setNewCompetitor] = useState('') - const handleSave = async () => { + const populateBrief = (data: BriefResponse) => { + setProjectName(data.project_name || '') + setBrandTone(data.brand_tone || '') + setOtherRequirements(data.other_requirements || '') + setMinDuration(data.min_duration ?? undefined) + setMaxDuration(data.max_duration ?? undefined) + setSellingPoints(data.selling_points || []) + setBlacklistWords(data.blacklist_words || []) + setCompetitors(data.competitors || []) + setAttachments(data.attachments || []) + } + + const loadBrief = useCallback(async () => { + if (USE_MOCK) { + populateBrief(mockBrief) + setBriefExists(true) + setLoading(false) + return + } + + try { + const data = await api.getBrief(projectId) + populateBrief(data) + setBriefExists(true) + } catch (err: any) { + if (err?.response?.status === 404) { + setBriefExists(false) + } else { + console.error('Failed to load brief:', err) + toast.error('加载Brief失败') + } + } finally { + setLoading(false) + } + }, [projectId, toast]) + + useEffect(() => { + loadBrief() + }, [loadBrief]) + + const handleSaveBrief = async () => { setIsSaving(true) - await new Promise(resolve => setTimeout(resolve, 1000)) - setIsSaving(false) - toast.success('配置已保存') - } + try { + const briefData: BriefCreateRequest = { + selling_points: sellingPoints, + blacklist_words: blacklistWords, + competitors, + brand_tone: brandTone || undefined, + min_duration: minDuration, + max_duration: maxDuration, + other_requirements: otherRequirements || undefined, + attachments, + } - const addRequirement = () => { - if (newRequirement.trim()) { - setBrief({ ...brief, requirements: [...brief.requirements, newRequirement.trim()] }) - setNewRequirement('') + if (USE_MOCK) { + await new Promise(resolve => setTimeout(resolve, 1000)) + } else if (briefExists) { + await api.updateBrief(projectId, briefData) + } else { + await api.createBrief(projectId, briefData) + setBriefExists(true) + } + + toast.success('Brief配置已保存') + } catch (err) { + console.error('Failed to save brief:', err) + toast.error('保存失败,请重试') + } finally { + setIsSaving(false) } } - const removeRequirement = (index: number) => { - setBrief({ ...brief, requirements: brief.requirements.filter((_, i) => i !== index) }) - } - - const addKeyword = () => { - if (newKeyword.trim() && !brief.keywords.includes(newKeyword.trim())) { - setBrief({ ...brief, keywords: [...brief.keywords, newKeyword.trim()] }) - setNewKeyword('') + // Selling points + const addSellingPoint = () => { + if (newSellingPoint.trim()) { + setSellingPoints([...sellingPoints, { content: newSellingPoint.trim(), required: false }]) + setNewSellingPoint('') } } - const removeKeyword = (keyword: string) => { - setBrief({ ...brief, keywords: brief.keywords.filter(k => k !== keyword) }) + const removeSellingPoint = (index: number) => { + setSellingPoints(sellingPoints.filter((_, i) => i !== index)) } - const addForbiddenWord = () => { - if (newForbiddenWord.trim() && !brief.forbiddenWords.includes(newForbiddenWord.trim())) { - setBrief({ ...brief, forbiddenWords: [...brief.forbiddenWords, newForbiddenWord.trim()] }) - setNewForbiddenWord('') + const toggleSellingPointRequired = (index: number) => { + setSellingPoints(sellingPoints.map((sp, i) => + i === index ? { ...sp, required: !sp.required } : sp + )) + } + + // Blacklist words + const addBlacklistWord = () => { + if (newBlacklistWord.trim()) { + setBlacklistWords([...blacklistWords, { word: newBlacklistWord.trim(), reason: newBlacklistReason.trim() || '品牌规范' }]) + setNewBlacklistWord('') + setNewBlacklistReason('') } } - const removeForbiddenWord = (word: string) => { - setBrief({ ...brief, forbiddenWords: brief.forbiddenWords.filter(w => w !== word) }) + const removeBlacklistWord = (index: number) => { + setBlacklistWords(blacklistWords.filter((_, i) => i !== index)) } + // Competitors + const addCompetitorItem = () => { + if (newCompetitor.trim() && !competitors.includes(newCompetitor.trim())) { + setCompetitors([...competitors, newCompetitor.trim()]) + setNewCompetitor('') + } + } + + const removeCompetitor = (name: string) => { + setCompetitors(competitors.filter(c => c !== name)) + } + + // Attachment upload + const handleAttachmentUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + + if (USE_MOCK) { + setAttachments([...attachments, { + id: `att-${Date.now()}`, + name: file.name, + url: `mock://${file.name}`, + }]) + return + } + + try { + const result = await upload(file) + setAttachments([...attachments, { + id: `att-${Date.now()}`, + name: file.name, + url: result.url, + }]) + } catch { + toast.error('文件上传失败') + } + } + + const removeAttachment = (id: string) => { + setAttachments(attachments.filter(a => a.id !== id)) + } + + // AI check item toggles (local state only) const toggleAiCheckItem = (itemId: string) => { setRules({ ...rules, @@ -168,6 +315,8 @@ export default function ProjectConfigPage() { ) + if (loading) return + return (
{/* 顶部导航 */} @@ -183,12 +332,17 @@ export default function ProjectConfigPage() {

Brief和规则配置

- {mockData.project.name} + {projectName || `项目 ${projectId}`}

-