diff --git a/frontend/app/agency/appeals/[id]/page.tsx b/frontend/app/agency/appeals/[id]/page.tsx index 623ca26..7c9cd4a 100644 --- a/frontend/app/agency/appeals/[id]/page.tsx +++ b/frontend/app/agency/appeals/[id]/page.tsx @@ -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 = { 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 ( +
+ +

加载中...

+
+ ) } return ( @@ -186,7 +346,9 @@ export default function AgencyAppealDetailPage() { {appeal.originalIssue.title}

{appeal.originalIssue.description}

-

位置: {appeal.originalIssue.location}

+ {appeal.originalIssue.location && ( +

位置: {appeal.originalIssue.location}

+ )} @@ -209,6 +371,10 @@ export default function AgencyAppealDetailPage() { 详细说明

{appeal.content}

+
+ 申诉次数 +

{appeal.appealCount} 次

+
{/* 附件 */} {appeal.attachments.length > 0 && ( @@ -303,7 +469,7 @@ export default function AgencyAppealDetailPage() { onClick={handleApprove} disabled={isSubmitting} > - + {isSubmitting ? : } 通过申诉 diff --git a/frontend/app/agency/appeals/page.tsx b/frontend/app/agency/appeals/page.tsx index a258692..02edb2e 100644 --- a/frontend/app/agency/appeals/page.tsx +++ b/frontend/app/agency/appeals/page.tsx @@ -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 = { 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('all') const [searchQuery, setSearchQuery] = useState('') + const [appeals, setAppeals] = useState([]) + 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() { - {filteredAppeals.length > 0 ? ( + {loading ? ( +
+ +

加载中...

+
+ ) : filteredAppeals.length > 0 ? ( filteredAppeals.map((appeal) => ( )) diff --git a/frontend/app/agency/briefs/[id]/page.tsx b/frontend/app/agency/briefs/[id]/page.tsx index 1103ee5..ab9749f 100644 --- a/frontend/app/agency/briefs/[id]/page.tsx +++ b/frontend/app/agency/briefs/[id]/page.tsx @@ -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 ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) +} + 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 + } + return (
{/* 顶部导航 */} @@ -290,7 +475,7 @@ export default function BriefConfigPage() { {isExporting ? '导出中...' : '导出规则'}
@@ -357,6 +542,12 @@ export default function BriefConfigPage() { 查看全部 {brandBrief.files.length} 个文件 → )} + {brandBrief.files.length === 0 && ( +
+ +

暂无 Brief 文件

+
+ )} @@ -381,6 +572,9 @@ export default function BriefConfigPage() { {c} ))} + {brandBrief.brandRules.competitors.length === 0 && ( + 暂无竞品 + )}
@@ -609,7 +803,7 @@ export default function BriefConfigPage() { {agencyConfig.blacklistWords.map((bw) => (
- 「{bw.word}」 + {'\u300C'}{bw.word}{'\u300D'} {bw.reason}
))} + {brandBrief.files.length === 0 && ( +
+ +

暂无文件

+
+ )}
diff --git a/frontend/app/agency/briefs/page.tsx b/frontend/app/agency/briefs/page.tsx index eec8c10..caef115 100644 --- a/frontend/app/agency/briefs/page.tsx +++ b/frontend/app/agency/briefs/page.tsx @@ -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 处理中 } +function BriefsSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {[1, 2, 3].map(i => ( +
+ ))} +
+
+ ) +} + export default function AgencyBriefsPage() { const [searchQuery, setSearchQuery] = useState('') const [statusFilter, setStatusFilter] = useState('all') + const [briefs, setBriefs] = useState([]) + 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 => { + 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 => 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 + } + + 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 (
@@ -143,7 +265,7 @@ export default function AgencyBriefsPage() { {filteredBriefs.map((brief) => { const platform = getPlatformInfo(brief.platform) return ( - + {/* 平台顶部条 */} {platform && ( diff --git a/frontend/app/agency/creators/page.tsx b/frontend/app/agency/creators/page.tsx index f543933..fc98883 100644 --- a/frontend/app/agency/creators/page.tsx +++ b/frontend/app/agency/creators/page.tsx @@ -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 = { + '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([]) - const [creators, setCreators] = useState(mockCreators) + const [creators, setCreators] = useState(USE_MOCK ? mockCreators : []) const [copiedId, setCopiedId] = useState(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>({}) + // 操作菜单状态 const [openMenuId, setOpenMenuId] = useState(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 = {} + 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 ( +
+ {/* 页面标题 */} +
+
+

达人管理

+

管理合作达人,查看任务进度和申诉次数

+
+ +
+ + {/* 统计卡片骨架 */} +
+ {[1, 2, 3, 4].map(i => ( + + +
+
+
+
+
+
+
+ + + ))} +
+ + {/* 搜索骨架 */} +
+ + {/* 表格骨架 */} + + +
+ + 加载达人数据... +
+
+
+
+ ) + } + + const projectList = USE_MOCK ? mockProjects : projects + return (
{/* 页面标题 */} @@ -328,7 +560,7 @@ export default function AgencyCreatorsPage() {

总达人数

-

{mockCreators.length}

+

{totalCreators}

@@ -341,7 +573,7 @@ export default function AgencyCreatorsPage() {

已激活

-

{mockCreators.filter(c => c.status === 'active').length}

+

{activeCreators}

@@ -354,7 +586,7 @@ export default function AgencyCreatorsPage() {

总脚本数

-

{mockCreators.reduce((sum, c) => sum + c.scriptCount.total, 0)}

+

{USE_MOCK ? totalScripts : '-'}

@@ -367,7 +599,7 @@ export default function AgencyCreatorsPage() {

总视频数

-

{mockCreators.reduce((sum, c) => sum + c.videoCount.total, 0)}

+

{USE_MOCK ? totalVideos : '-'}

- + {USE_MOCK ? ( + + ) : ( + 已关联 + )} - {creator.scriptCount.passed} - /{creator.scriptCount.total} + {USE_MOCK ? ( + <> + {creator.scriptCount.passed} + /{creator.scriptCount.total} + + ) : ( + - + )} - {creator.videoCount.passed} - /{creator.videoCount.total} + {USE_MOCK ? ( + <> + {creator.videoCount.passed} + /{creator.videoCount.total} + + ) : ( + - + )} - {creator.status === 'active' && creator.passRate > 0 ? ( + {USE_MOCK && creator.status === 'active' && creator.passRate > 0 ? (
= 90 ? 'text-accent-green' : creator.passRate >= 80 ? 'text-accent-indigo' : 'text-orange-400'}`}> {creator.passRate}% @@ -589,9 +837,14 @@ export default function AgencyCreatorsPage() {
@@ -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" /> -
@@ -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} > - + {submitting ? : } 确认移除
@@ -755,7 +1013,7 @@ export default function AgencyCreatorsPage() {
- {mockProjects.map((project) => ( + {projectList.map((project) => ( ))} + {projectList.length === 0 && ( +

暂无可分配的项目

+ )}
diff --git a/frontend/app/agency/reports/page.tsx b/frontend/app/agency/reports/page.tsx index 2943c73..4de5d1c 100644 --- a/frontend/app/agency/reports/page.tsx +++ b/frontend/app/agency/reports/page.tsx @@ -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(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 ( +
+ +

加载中...

+
+ ) + } + return (
{/* 页面标题 */} @@ -276,6 +328,30 @@ export default function AgencyReportsPage() {
+ {/* Dashboard summary banner (API mode only) */} + {!USE_MOCK && dashboardData && ( +
+
+

待审核 (脚本/视频)

+

+ {dashboardData.pending_review.script} / {dashboardData.pending_review.video} +

+
+
+

待处理申诉

+

{dashboardData.pending_appeal}

+
+
+

达人总数

+

{dashboardData.total_creators}

+
+
+

任务总数

+

{dashboardData.total_tasks}

+
+
+ )} + {/* 核心指标 */}
{isExporting ? ( <> - + 导出中... ) : ( diff --git a/frontend/app/agency/review/history/page.tsx b/frontend/app/agency/review/history/page.tsx index 5f43c56..cf8c968 100644 --- a/frontend/app/agency/review/history/page.tsx +++ b/frontend/app/agency/review/history/page.tsx @@ -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([]) + 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 (
@@ -139,7 +213,7 @@ export default function AgencyReviewHistoryPage() {
-

{mockHistoryData.length}

+

{historyData.length}

总审核数

@@ -236,7 +310,12 @@ export default function AgencyReviewHistoryPage() { - {filteredHistory.length > 0 ? ( + {loading ? ( +
+ +

加载中...

+
+ ) : filteredHistory.length > 0 ? ( filteredHistory.map((item) => (
({ + 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 + 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 ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) +} + 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(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 } return ( @@ -226,21 +373,27 @@ export default function AgencyScriptReviewPage() { + {task.aiSummary ? ( +
+
AI 总结
+

{task.aiSummary}

+
+ ) : null}
开场白
-

{task.scriptContent.opening}

+

{task.scriptContent.opening || '(无内容)'}

产品介绍
-

{task.scriptContent.productIntro}

+

{task.scriptContent.productIntro || '(无内容)'}

使用演示
-

{task.scriptContent.demo}

+

{task.scriptContent.demo || '(无内容)'}

结尾引导
-

{task.scriptContent.closing}

+

{task.scriptContent.closing || '(无内容)'}

@@ -275,7 +428,7 @@ export default function AgencyScriptReviewPage() {
{v.type}
-

「{v.content}」

+

{v.content}

{v.suggestion}

))} @@ -331,6 +484,9 @@ export default function AgencyScriptReviewPage() { {sp.point}
))} + {task.aiAnalysis.sellingPoints.length === 0 && ( +

暂无卖点数据

+ )}
@@ -344,13 +500,16 @@ export default function AgencyScriptReviewPage() { 项目:{task.projectName}
- - -
@@ -382,8 +541,11 @@ export default function AgencyScriptReviewPage() { />
- - + +
@@ -407,8 +569,11 @@ export default function AgencyScriptReviewPage() { />
- - + +
diff --git a/frontend/app/agency/review/video/[id]/page.tsx b/frontend/app/agency/review/video/[id]/page.tsx index f1642d7..e65b23e 100644 --- a/frontend/app/agency/review/video/[id]/page.tsx +++ b/frontend/app/agency/review/video/[id]/page.tsx @@ -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 + 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 低风险 } +function LoadingSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) +} + 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>({}) const [showFilePreview, setShowFilePreview] = useState(false) const [videoError, setVideoError] = useState(false) + const [task, setTask] = useState(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 + } + return (
{/* 顶部导航 */} @@ -298,7 +456,7 @@ export default function AgencyVideoReviewPage() {

- 视频整体合规,发现{task.hardViolations.length}处硬性问题和{task.sentimentWarnings.length}处舆情提示需人工确认 + {task.aiSummary || `视频整体合规,发现${task.hardViolations.length}处硬性问题和${task.sentimentWarnings.length}处舆情提示需人工确认`}

@@ -329,12 +487,15 @@ export default function AgencyVideoReviewPage() { {v.type} {formatTimestamp(v.timestamp)}
-

「{v.content}」

+

{v.content}

{v.suggestion}

))} + {task.hardViolations.length === 0 && ( +

未发现硬性违规

+ )} @@ -355,7 +516,7 @@ export default function AgencyVideoReviewPage() { {formatTimestamp(w.timestamp)}

{w.content}

-

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

+

Soft risk warning only, not enforced

))} @@ -386,6 +547,9 @@ export default function AgencyVideoReviewPage() { )}
))} + {task.sellingPointsCovered.length === 0 && ( +

暂无卖点数据

+ )}
@@ -399,13 +563,16 @@ export default function AgencyVideoReviewPage() { 已检查 {Object.values(checkedViolations).filter(Boolean).length}/{task.hardViolations.length} 个问题
- - -
@@ -430,7 +597,7 @@ export default function AgencyVideoReviewPage() {

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

{task.hardViolations.filter(v => checkedViolations[v.id]).map(v => ( -
• {v.type}: {v.content}
+
* {v.type}: {v.content}
))} {Object.values(checkedViolations).filter(Boolean).length === 0 && (
未选择任何问题
@@ -446,8 +613,11 @@ export default function AgencyVideoReviewPage() { />
- - + +
@@ -480,8 +650,11 @@ export default function AgencyVideoReviewPage() { 保存为特例(需品牌方确认后生效)
- - + +
diff --git a/frontend/app/agency/tasks/[id]/page.tsx b/frontend/app/agency/tasks/[id]/page.tsx index d01778a..8f6d563 100644 --- a/frontend/app/agency/tasks/[id]/page.tsx +++ b/frontend/app/agency/tasks/[id]/page.tsx @@ -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 已通过 if (status === 'rejected') return 已驳回 @@ -39,10 +249,64 @@ function StatusBadge({ status }: { status: string }) { return 处理中 } +function TaskDetailSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) +} + export default function TaskDetailPage() { const router = useRouter() const params = useParams() - const task = mockTaskDetail + const taskId = params.id as string + + const [task, setTask] = useState(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 + } return (
@@ -67,9 +331,17 @@ export default function TaskDetailPage() {
- + {task.videoUrl ? ( +
@@ -80,14 +352,14 @@ export default function TaskDetailPage() {
AI 评分
-
= 80 ? 'text-green-600' : 'text-yellow-600'}`}> - {task.aiScore} +
= 80 ? 'text-green-600' : 'text-yellow-600'}`}> + {task.aiScore ?? '-'}
最终评分
-
= 80 ? 'text-green-600' : 'text-yellow-600'}`}> - {task.finalScore} +
= 80 ? 'text-green-600' : 'text-yellow-600'}`}> + {task.finalScore ?? '-'}
diff --git a/frontend/app/brand/agencies/page.tsx b/frontend/app/brand/agencies/page.tsx index cc0f1f8..ad78ec2 100644 --- a/frontend/app/brand/agencies/page.tsx +++ b/frontend/app/brand/agencies/page.tsx @@ -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 // 代理商ID(AG开头) - 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 可强制通过 + return 标准权限 } -// 模拟项目列表(用于分配代理商) -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 已激活 - if (status === 'pending') return 待接受 - return 已暂停 +function AgencySkeleton() { + return ( +
+
+
+
+
+ ) } export default function AgenciesManagePage() { const toast = useToast() const [searchQuery, setSearchQuery] = useState('') - const [agencies, setAgencies] = useState(initialAgencies) + const [agencies, setAgencies] = useState([]) + const [projects, setProjects] = useState([]) + const [loading, setLoading] = useState(true) const [copiedId, setCopiedId] = useState(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(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([]) + 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() {
{/* 统计卡片 */} -
+
@@ -272,8 +262,8 @@ export default function AgenciesManagePage() {
-

已激活

-

{agencies.filter(a => a.status === 'active').length}

+

可强制通过

+

{agencies.filter(a => a.force_pass_enabled).length}

@@ -285,28 +275,11 @@ export default function AgenciesManagePage() {
-

待接受

-

{agencies.filter(a => a.status === 'pending').length}

-
-
- -
-
-
- - - -
-
-

平均通过率

-

- {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}% -

+

关联项目

+

{projects.length}

- +
@@ -318,7 +291,7 @@ export default function AgenciesManagePage() { 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() { {/* 代理商列表 */} - - - - - - - - - - - - - - - {filteredAgencies.map((agency) => ( - - - - - - - - - + {loading ? ( +
+ ) : ( +
代理商代理商ID状态达人数项目数通过率加入时间操作
-
-
- -
-
-
- {agency.name} - {agency.remark && ( - - 有备注 - - )} -
-
{agency.companyName}
- {agency.remark && ( -

{agency.remark}

- )} -
-
-
-
- - {agency.agencyId} - - -
-
- - {agency.creatorCount}{agency.projectCount} - {agency.status === 'active' ? ( -
- = 90 ? 'text-accent-green' : agency.passRate >= 80 ? 'text-accent-indigo' : 'text-orange-400'}`}> - {agency.passRate}% - - {agency.trend === 'up' && } - {agency.trend === 'down' && } -
- ) : ( - - - )} -
{agency.joinedAt} -
- - {/* 下拉菜单 */} - {openMenuId === agency.id && ( -
- - - -
- )} -
-
+ + + + + + + - ))} - -
代理商代理商ID联系人权限操作
+ + + {filteredAgencies.map((agency) => ( + + +
+
+ +
+ {agency.name} +
+ + +
+ + {agency.id} + + +
+ + + {agency.contact_name || '-'} + + + + + +
+ + {openMenuId === agency.id && ( +
+ + +
+ )} +
+ + + ))} + + + )} - {filteredAgencies.length === 0 && ( + {!loading && filteredAgencies.length === 0 && (

没有找到匹配的代理商

@@ -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" /> -

代理商ID格式:AG + 6位数字

@@ -503,44 +442,9 @@ export default function AgenciesManagePage() { - -
-
- - - {/* 备注弹窗 */} - { setRemarkModal({ open: false, agency: null }); setRemarkText(''); }} - title={`${remarkModal.agency?.remark ? '编辑' : '添加'}备注 - ${remarkModal.agency?.name}`} - > -
-
- -