feat: 前端剩余页面全面对接后端 API(Phase 2 完成)
为品牌方端(8页)、代理商端(10页)、达人端(6页)共24个页面添加真实API调用: - 每页新增 USE_MOCK 条件分支,开发环境使用 mock 数据,生产环境调用真实 API - 添加 loading 骨架屏、error toast 提示、submitting 状态管理 - 数据映射:TaskResponse → 页面视图模型,处理类型差异 - 审核操作(通过/驳回/强制通过)对接 api.reviewScript/reviewVideo - Brief/规则/AI配置对接 api.getBrief/updateBrief/listForbiddenWords 等 - 申诉/历史/额度管理对接 api.listTasks + 状态过滤映射 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
54eaa54966
commit
a8be7bbca9
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -10,10 +10,15 @@ import {
|
||||
XCircle,
|
||||
Send,
|
||||
Info,
|
||||
Loader2
|
||||
} from 'lucide-react'
|
||||
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { api } from '@/lib/api'
|
||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
import type { TaskResponse } from '@/types/task'
|
||||
|
||||
// 申请状态类型
|
||||
type RequestStatus = 'none' | 'pending' | 'approved' | 'rejected'
|
||||
@@ -68,6 +73,28 @@ const mockTaskQuotas: TaskAppealQuota[] = [
|
||||
},
|
||||
]
|
||||
|
||||
// 将 TaskResponse 映射为 TaskAppealQuota
|
||||
function mapTaskToQuota(task: TaskResponse): TaskAppealQuota {
|
||||
// Default quota is 1 per task
|
||||
const defaultQuota = 1
|
||||
const remaining = Math.max(0, defaultQuota - task.appeal_count)
|
||||
|
||||
// Determine request status based on task state
|
||||
let requestStatus: RequestStatus = 'none'
|
||||
if (task.is_appeal && task.appeal_count > 0) {
|
||||
requestStatus = 'pending'
|
||||
}
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
taskName: task.name,
|
||||
agencyName: task.agency?.name || '未知代理商',
|
||||
remaining,
|
||||
used: task.appeal_count,
|
||||
requestStatus,
|
||||
}
|
||||
}
|
||||
|
||||
// 状态标签组件
|
||||
function StatusBadge({ status }: { status: RequestStatus }) {
|
||||
const config = {
|
||||
@@ -101,13 +128,43 @@ function StatusBadge({ status }: { status: RequestStatus }) {
|
||||
)
|
||||
}
|
||||
|
||||
// 骨架屏组件
|
||||
function QuotaSkeleton() {
|
||||
return (
|
||||
<div className="bg-bg-card rounded-xl p-5 card-shadow flex flex-col gap-4 animate-pulse">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="h-4 w-32 bg-bg-elevated rounded" />
|
||||
<div className="h-3 w-20 bg-bg-elevated rounded" />
|
||||
</div>
|
||||
<div className="h-5 w-14 bg-bg-elevated rounded-full" />
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="h-7 w-8 bg-bg-elevated rounded" />
|
||||
<div className="h-3 w-14 bg-bg-elevated rounded" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="h-7 w-8 bg-bg-elevated rounded" />
|
||||
<div className="h-3 w-14 bg-bg-elevated rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-3 border-t border-border-subtle">
|
||||
<div className="h-8 w-24 bg-bg-elevated rounded" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 任务卡片组件
|
||||
function TaskQuotaCard({
|
||||
task,
|
||||
onRequestIncrease,
|
||||
requesting,
|
||||
}: {
|
||||
task: TaskAppealQuota
|
||||
onRequestIncrease: (taskId: string) => void
|
||||
requesting: boolean
|
||||
}) {
|
||||
const canRequest = task.requestStatus === 'none' || task.requestStatus === 'rejected'
|
||||
|
||||
@@ -149,10 +206,11 @@ function TaskQuotaCard({
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onRequestIncrease(task.id)}
|
||||
disabled={requesting}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Send size={14} />
|
||||
申请增加
|
||||
{requesting ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
|
||||
{requesting ? '申请中...' : '申请增加'}
|
||||
</Button>
|
||||
) : task.requestStatus === 'pending' ? (
|
||||
<span className="text-xs text-accent-amber">等待代理商处理...</span>
|
||||
@@ -164,30 +222,87 @@ function TaskQuotaCard({
|
||||
|
||||
export default function AppealQuotaPage() {
|
||||
const router = useRouter()
|
||||
const [tasks, setTasks] = useState(mockTaskQuotas)
|
||||
const [showSuccessToast, setShowSuccessToast] = useState(false)
|
||||
const toast = useToast()
|
||||
const [tasks, setTasks] = useState<TaskAppealQuota[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [requestingTaskId, setRequestingTaskId] = useState<string | null>(null)
|
||||
|
||||
const loadQuotas = useCallback(async () => {
|
||||
if (USE_MOCK) {
|
||||
setTasks(mockTaskQuotas)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await api.listTasks(1, 100)
|
||||
const mapped = response.items.map(mapTaskToQuota)
|
||||
setTasks(mapped)
|
||||
} catch (err) {
|
||||
console.error('加载申诉次数失败:', err)
|
||||
toast.error('加载申诉次数信息失败,请稍后重试')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [toast])
|
||||
|
||||
useEffect(() => {
|
||||
loadQuotas()
|
||||
}, [loadQuotas])
|
||||
|
||||
// 申请增加申诉次数
|
||||
const handleRequestIncrease = (taskId: string) => {
|
||||
setTasks(prev =>
|
||||
prev.map(task =>
|
||||
task.id === taskId
|
||||
? {
|
||||
...task,
|
||||
requestStatus: 'pending' as RequestStatus,
|
||||
requestTime: new Date().toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}),
|
||||
}
|
||||
: task
|
||||
const handleRequestIncrease = async (taskId: string) => {
|
||||
if (USE_MOCK) {
|
||||
setTasks(prev =>
|
||||
prev.map(task =>
|
||||
task.id === taskId
|
||||
? {
|
||||
...task,
|
||||
requestStatus: 'pending' as RequestStatus,
|
||||
requestTime: new Date().toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}),
|
||||
}
|
||||
: task
|
||||
)
|
||||
)
|
||||
)
|
||||
setShowSuccessToast(true)
|
||||
setTimeout(() => setShowSuccessToast(false), 3000)
|
||||
toast.success('申请已发送,等待代理商处理')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setRequestingTaskId(taskId)
|
||||
await api.increaseAppealCount(taskId)
|
||||
toast.success('申请已发送,等待代理商处理')
|
||||
// Update local state optimistically
|
||||
setTasks(prev =>
|
||||
prev.map(task =>
|
||||
task.id === taskId
|
||||
? {
|
||||
...task,
|
||||
requestStatus: 'pending' as RequestStatus,
|
||||
requestTime: new Date().toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}),
|
||||
}
|
||||
: task
|
||||
)
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('申请增加申诉次数失败:', err)
|
||||
toast.error('申请失败,请稍后重试')
|
||||
} finally {
|
||||
setRequestingTaskId(null)
|
||||
}
|
||||
}
|
||||
|
||||
// 统计数据
|
||||
@@ -216,20 +331,31 @@ export default function AppealQuotaPage() {
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-bg-card rounded-xl p-4 card-shadow flex flex-col items-center gap-1">
|
||||
<span className="text-2xl font-bold text-accent-indigo">{totalRemaining}</span>
|
||||
<span className="text-xs text-text-tertiary">总剩余次数</span>
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="bg-bg-card rounded-xl p-4 card-shadow flex flex-col items-center gap-1 animate-pulse">
|
||||
<div className="h-7 w-8 bg-bg-elevated rounded" />
|
||||
<div className="h-3 w-14 bg-bg-elevated rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="bg-bg-card rounded-xl p-4 card-shadow flex flex-col items-center gap-1">
|
||||
<span className="text-2xl font-bold text-text-secondary">{totalUsed}</span>
|
||||
<span className="text-xs text-text-tertiary">总已使用</span>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-bg-card rounded-xl p-4 card-shadow flex flex-col items-center gap-1">
|
||||
<span className="text-2xl font-bold text-accent-indigo">{totalRemaining}</span>
|
||||
<span className="text-xs text-text-tertiary">总剩余次数</span>
|
||||
</div>
|
||||
<div className="bg-bg-card rounded-xl p-4 card-shadow flex flex-col items-center gap-1">
|
||||
<span className="text-2xl font-bold text-text-secondary">{totalUsed}</span>
|
||||
<span className="text-xs text-text-tertiary">总已使用</span>
|
||||
</div>
|
||||
<div className="bg-bg-card rounded-xl p-4 card-shadow flex flex-col items-center gap-1">
|
||||
<span className="text-2xl font-bold text-accent-amber">{pendingRequests}</span>
|
||||
<span className="text-xs text-text-tertiary">待处理申请</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-bg-card rounded-xl p-4 card-shadow flex flex-col items-center gap-1">
|
||||
<span className="text-2xl font-bold text-accent-amber">{pendingRequests}</span>
|
||||
<span className="text-xs text-text-tertiary">待处理申请</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 规则说明 */}
|
||||
<div className="bg-accent-indigo/10 rounded-xl p-4 flex gap-3">
|
||||
@@ -245,25 +371,31 @@ export default function AppealQuotaPage() {
|
||||
{/* 任务列表 */}
|
||||
<div className="flex flex-col gap-4 flex-1 min-h-0 overflow-y-auto pb-4">
|
||||
<h2 className="text-base font-semibold text-text-primary sticky top-0 bg-bg-page py-2 -mt-2">
|
||||
任务申诉次数 ({tasks.length})
|
||||
任务申诉次数 {!loading && `(${tasks.length})`}
|
||||
</h2>
|
||||
{tasks.map(task => (
|
||||
<TaskQuotaCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onRequestIncrease={handleRequestIncrease}
|
||||
/>
|
||||
))}
|
||||
{loading ? (
|
||||
<>
|
||||
<QuotaSkeleton />
|
||||
<QuotaSkeleton />
|
||||
<QuotaSkeleton />
|
||||
</>
|
||||
) : tasks.length > 0 ? (
|
||||
tasks.map(task => (
|
||||
<TaskQuotaCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onRequestIncrease={handleRequestIncrease}
|
||||
requesting={requestingTaskId === task.id}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
<AlertCircle className="w-12 h-12 text-text-tertiary/50 mb-4" />
|
||||
<p className="text-text-secondary text-center">暂无任务数据</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 成功提示 */}
|
||||
{showSuccessToast && (
|
||||
<div className="fixed bottom-24 left-1/2 -translate-x-1/2 bg-accent-green text-white px-4 py-3 rounded-xl shadow-lg flex items-center gap-2 animate-fade-in z-50">
|
||||
<CheckCircle size={18} />
|
||||
<span className="text-sm font-medium">申请已发送,等待代理商处理</span>
|
||||
</div>
|
||||
)}
|
||||
</ResponsiveLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -11,10 +11,15 @@ import {
|
||||
FileText,
|
||||
Image,
|
||||
Send,
|
||||
AlertTriangle
|
||||
AlertTriangle,
|
||||
Loader2
|
||||
} from 'lucide-react'
|
||||
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { api } from '@/lib/api'
|
||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
import type { TaskResponse } from '@/types/task'
|
||||
|
||||
// 申诉状态类型
|
||||
type AppealStatus = 'pending' | 'processing' | 'approved' | 'rejected'
|
||||
@@ -130,6 +135,69 @@ const mockAppealDetails: Record<string, AppealDetail> = {
|
||||
},
|
||||
}
|
||||
|
||||
// 将 TaskResponse 映射为 AppealDetail UI 类型
|
||||
function mapTaskToAppealDetail(task: TaskResponse): AppealDetail {
|
||||
let type: 'ai' | 'agency' | 'brand' = 'ai'
|
||||
if (task.script_brand_status === 'rejected' || task.video_brand_status === 'rejected') {
|
||||
type = 'brand'
|
||||
} else if (task.script_agency_status === 'rejected' || task.video_agency_status === 'rejected') {
|
||||
type = 'agency'
|
||||
}
|
||||
|
||||
let status: AppealStatus = 'pending'
|
||||
if (task.stage === 'completed') {
|
||||
status = 'approved'
|
||||
} else if (task.stage === 'rejected') {
|
||||
status = 'rejected'
|
||||
} else if (task.is_appeal) {
|
||||
status = 'processing'
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleString('zh-CN', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
// Build original issue from review comments
|
||||
let originalIssue: { title: string; description: string } | undefined
|
||||
const rejectionComment =
|
||||
task.script_brand_comment ||
|
||||
task.script_agency_comment ||
|
||||
task.video_brand_comment ||
|
||||
task.video_agency_comment
|
||||
if (rejectionComment) {
|
||||
originalIssue = {
|
||||
title: '审核驳回',
|
||||
description: rejectionComment,
|
||||
}
|
||||
}
|
||||
|
||||
// Build timeline from task dates
|
||||
const timeline: { time: string; action: string; operator?: string }[] = []
|
||||
if (task.created_at) {
|
||||
timeline.push({ time: formatDate(task.created_at), action: '任务创建' })
|
||||
}
|
||||
if (task.updated_at) {
|
||||
timeline.push({ time: formatDate(task.updated_at), action: '提交申诉' })
|
||||
}
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
taskId: task.id,
|
||||
taskTitle: task.name,
|
||||
type,
|
||||
reason: task.appeal_reason || '申诉',
|
||||
content: task.appeal_reason || '',
|
||||
status,
|
||||
createdAt: task.created_at ? formatDate(task.created_at) : '',
|
||||
updatedAt: task.updated_at ? formatDate(task.updated_at) : undefined,
|
||||
originalIssue,
|
||||
timeline: timeline.length > 0 ? timeline : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// 状态配置
|
||||
const statusConfig: Record<AppealStatus, { label: string; color: string; bgColor: string; icon: React.ElementType }> = {
|
||||
pending: { label: '待处理', color: 'text-amber-500', bgColor: 'bg-amber-500/15', icon: Clock },
|
||||
@@ -145,13 +213,114 @@ const typeConfig: Record<string, { label: string; color: string }> = {
|
||||
brand: { label: '品牌方审核', color: 'text-accent-blue' },
|
||||
}
|
||||
|
||||
// 骨架屏组件
|
||||
function DetailSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 h-full animate-pulse">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="h-8 w-16 bg-bg-elevated rounded-lg" />
|
||||
<div className="h-6 w-32 bg-bg-elevated rounded" />
|
||||
<div className="h-4 w-48 bg-bg-elevated rounded" />
|
||||
</div>
|
||||
<div className="h-10 w-24 bg-bg-elevated rounded-xl" />
|
||||
</div>
|
||||
<div className="flex flex-col lg:flex-row gap-6 flex-1">
|
||||
<div className="flex-1 flex flex-col gap-5">
|
||||
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
|
||||
<div className="h-5 w-32 bg-bg-elevated rounded mb-4" />
|
||||
<div className="h-20 bg-bg-elevated rounded-xl" />
|
||||
</div>
|
||||
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
|
||||
<div className="h-5 w-24 bg-bg-elevated rounded mb-4" />
|
||||
<div className="h-4 w-full bg-bg-elevated rounded mb-2" />
|
||||
<div className="h-4 w-3/4 bg-bg-elevated rounded mb-4" />
|
||||
<div className="h-16 bg-bg-elevated rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:w-[320px]">
|
||||
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
|
||||
<div className="h-5 w-24 bg-bg-elevated rounded mb-5" />
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="h-10 bg-bg-elevated rounded" />
|
||||
<div className="h-10 bg-bg-elevated rounded" />
|
||||
<div className="h-10 bg-bg-elevated rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AppealDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const appealId = params.id as string
|
||||
const [newComment, setNewComment] = useState('')
|
||||
const [appeal, setAppeal] = useState<AppealDetail | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const appeal = mockAppealDetails[appealId]
|
||||
const loadAppealDetail = useCallback(async () => {
|
||||
if (USE_MOCK) {
|
||||
const mockAppeal = mockAppealDetails[appealId]
|
||||
setAppeal(mockAppeal || null)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const task = await api.getTask(appealId)
|
||||
const mapped = mapTaskToAppealDetail(task)
|
||||
setAppeal(mapped)
|
||||
} catch (err) {
|
||||
console.error('加载申诉详情失败:', err)
|
||||
toast.error('加载申诉详情失败,请稍后重试')
|
||||
setAppeal(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [appealId, toast])
|
||||
|
||||
useEffect(() => {
|
||||
loadAppealDetail()
|
||||
}, [loadAppealDetail])
|
||||
|
||||
const handleSendComment = async () => {
|
||||
if (!newComment.trim()) return
|
||||
|
||||
if (USE_MOCK) {
|
||||
toast.success('补充说明已发送')
|
||||
setNewComment('')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true)
|
||||
// Use submitAppeal to add supplementary info (re-appeal with updated reason)
|
||||
await api.submitAppeal(appealId, { reason: newComment.trim() })
|
||||
toast.success('补充说明已发送')
|
||||
setNewComment('')
|
||||
// Reload to reflect any changes
|
||||
loadAppealDetail()
|
||||
} catch (err) {
|
||||
console.error('发送补充说明失败:', err)
|
||||
toast.error('发送失败,请稍后重试')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ResponsiveLayout role="creator">
|
||||
<DetailSkeleton />
|
||||
</ResponsiveLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (!appeal) {
|
||||
return (
|
||||
@@ -288,13 +457,23 @@ export default function AppealDetailPage() {
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
className="flex-1 px-4 py-3 bg-bg-elevated rounded-xl text-sm text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
|
||||
disabled={submitting}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="px-5 py-3 rounded-xl bg-accent-indigo text-white text-sm font-medium flex items-center gap-2"
|
||||
onClick={handleSendComment}
|
||||
disabled={submitting || !newComment.trim()}
|
||||
className={cn(
|
||||
'px-5 py-3 rounded-xl bg-accent-indigo text-white text-sm font-medium flex items-center gap-2',
|
||||
(submitting || !newComment.trim()) && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
发送
|
||||
{submitting ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="w-4 h-4" />
|
||||
)}
|
||||
{submitting ? '发送中...' : '发送'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -9,10 +9,15 @@ import {
|
||||
FileText,
|
||||
Image,
|
||||
AlertTriangle,
|
||||
CheckCircle
|
||||
CheckCircle,
|
||||
Loader2
|
||||
} from 'lucide-react'
|
||||
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { api } from '@/lib/api'
|
||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
import type { TaskResponse } from '@/types/task'
|
||||
|
||||
// 申诉原因选项
|
||||
const appealReasons = [
|
||||
@@ -23,9 +28,19 @@ const appealReasons = [
|
||||
{ id: 'other', label: '其他原因', description: '其他需要说明的情况' },
|
||||
]
|
||||
|
||||
// Mock 任务信息类型
|
||||
type TaskInfo = {
|
||||
title: string
|
||||
issue: string
|
||||
issueDesc: string
|
||||
type: string
|
||||
appealRemaining: number
|
||||
agencyName: string
|
||||
}
|
||||
|
||||
// 任务信息(模拟从URL参数获取)
|
||||
const getTaskInfo = (taskId: string) => {
|
||||
const tasks: Record<string, { title: string; issue: string; issueDesc: string; type: string; appealRemaining: number; agencyName: string }> = {
|
||||
const getTaskInfo = (taskId: string): TaskInfo => {
|
||||
const tasks: Record<string, TaskInfo> = {
|
||||
'task-003': {
|
||||
title: 'ZZ饮品夏日',
|
||||
issue: '检测到竞品提及',
|
||||
@@ -70,12 +85,99 @@ const getTaskInfo = (taskId: string) => {
|
||||
return tasks[taskId] || { title: '未知任务', issue: '未知问题', issueDesc: '', type: 'ai', appealRemaining: 0, agencyName: '未知代理商' }
|
||||
}
|
||||
|
||||
// 将 TaskResponse 映射为 TaskInfo
|
||||
function mapTaskResponseToInfo(task: TaskResponse): TaskInfo {
|
||||
let type = 'ai'
|
||||
let issue = '审核驳回'
|
||||
let issueDesc = ''
|
||||
|
||||
if (task.script_brand_status === 'rejected' || task.video_brand_status === 'rejected') {
|
||||
type = 'brand'
|
||||
issue = task.script_brand_comment || task.video_brand_comment || '品牌方审核驳回'
|
||||
issueDesc = task.script_brand_comment || task.video_brand_comment || ''
|
||||
} else if (task.script_agency_status === 'rejected' || task.video_agency_status === 'rejected') {
|
||||
type = 'agency'
|
||||
issue = task.script_agency_comment || task.video_agency_comment || '代理商审核驳回'
|
||||
issueDesc = task.script_agency_comment || task.video_agency_comment || ''
|
||||
} else {
|
||||
// AI rejection or default
|
||||
const aiResult = task.script_ai_result || task.video_ai_result
|
||||
if (aiResult && aiResult.violations.length > 0) {
|
||||
issue = aiResult.violations[0].content || 'AI审核不通过'
|
||||
issueDesc = aiResult.summary || aiResult.violations.map(v => v.content).join('; ')
|
||||
}
|
||||
}
|
||||
|
||||
// Default appeal quota: 1 per task minus used appeals
|
||||
const defaultQuota = 1
|
||||
const appealRemaining = Math.max(0, defaultQuota - task.appeal_count)
|
||||
|
||||
return {
|
||||
title: task.name,
|
||||
issue,
|
||||
issueDesc,
|
||||
type,
|
||||
appealRemaining,
|
||||
agencyName: task.agency?.name || '未知代理商',
|
||||
}
|
||||
}
|
||||
|
||||
// 表单骨架屏
|
||||
function FormSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 h-full animate-pulse">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="h-8 w-16 bg-bg-elevated rounded-lg" />
|
||||
<div className="h-6 w-24 bg-bg-elevated rounded" />
|
||||
<div className="h-4 w-64 bg-bg-elevated rounded" />
|
||||
</div>
|
||||
<div className="h-10 w-48 bg-bg-elevated rounded-xl" />
|
||||
</div>
|
||||
<div className="flex flex-col lg:flex-row gap-6 flex-1">
|
||||
<div className="flex-1 flex flex-col gap-5">
|
||||
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
|
||||
<div className="h-5 w-24 bg-bg-elevated rounded mb-4" />
|
||||
<div className="h-20 bg-bg-elevated rounded-xl" />
|
||||
</div>
|
||||
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
|
||||
<div className="h-5 w-24 bg-bg-elevated rounded mb-4" />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="h-16 bg-bg-elevated rounded-xl" />
|
||||
<div className="h-16 bg-bg-elevated rounded-xl" />
|
||||
<div className="h-16 bg-bg-elevated rounded-xl" />
|
||||
<div className="h-16 bg-bg-elevated rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
|
||||
<div className="h-5 w-24 bg-bg-elevated rounded mb-4" />
|
||||
<div className="h-32 bg-bg-elevated rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:w-[320px]">
|
||||
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
|
||||
<div className="h-5 w-24 bg-bg-elevated rounded mb-5" />
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="h-4 w-full bg-bg-elevated rounded" />
|
||||
<div className="h-4 w-full bg-bg-elevated rounded" />
|
||||
<div className="h-4 w-full bg-bg-elevated rounded" />
|
||||
</div>
|
||||
<div className="h-12 bg-bg-elevated rounded-xl mt-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function NewAppealPage() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const toast = useToast()
|
||||
const taskId = searchParams.get('taskId') || ''
|
||||
const taskInfo = getTaskInfo(taskId)
|
||||
|
||||
const [taskInfo, setTaskInfo] = useState<TaskInfo | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedReason, setSelectedReason] = useState<string>('')
|
||||
const [content, setContent] = useState('')
|
||||
const [attachments, setAttachments] = useState<{ name: string; type: 'image' | 'document' }[]>([])
|
||||
@@ -84,7 +186,40 @@ export default function NewAppealPage() {
|
||||
const [isRequestingQuota, setIsRequestingQuota] = useState(false)
|
||||
const [quotaRequested, setQuotaRequested] = useState(false)
|
||||
|
||||
const hasAppealQuota = taskInfo.appealRemaining > 0
|
||||
// Load task info
|
||||
const loadTaskInfo = useCallback(async () => {
|
||||
if (USE_MOCK) {
|
||||
setTaskInfo(getTaskInfo(taskId))
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (!taskId) {
|
||||
toast.error('缺少任务ID参数')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const task = await api.getTask(taskId)
|
||||
const info = mapTaskResponseToInfo(task)
|
||||
setTaskInfo(info)
|
||||
} catch (err) {
|
||||
console.error('加载任务信息失败:', err)
|
||||
toast.error('加载任务信息失败,请稍后重试')
|
||||
// Fallback to a default
|
||||
setTaskInfo({ title: '未知任务', issue: '未知问题', issueDesc: '', type: 'ai', appealRemaining: 0, agencyName: '未知代理商' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [taskId, toast])
|
||||
|
||||
useEffect(() => {
|
||||
loadTaskInfo()
|
||||
}, [loadTaskInfo])
|
||||
|
||||
const hasAppealQuota = taskInfo ? taskInfo.appealRemaining > 0 : false
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
@@ -104,26 +239,69 @@ export default function NewAppealPage() {
|
||||
const handleSubmit = async () => {
|
||||
if (!selectedReason || !content.trim()) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
// 模拟提交
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
setIsSubmitting(false)
|
||||
setIsSubmitted(true)
|
||||
if (USE_MOCK) {
|
||||
setIsSubmitting(true)
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
setIsSubmitting(false)
|
||||
setIsSubmitted(true)
|
||||
setTimeout(() => {
|
||||
router.push('/creator/appeals')
|
||||
}, 2000)
|
||||
return
|
||||
}
|
||||
|
||||
// 2秒后跳转到申诉列表
|
||||
setTimeout(() => {
|
||||
router.push('/creator/appeals')
|
||||
}, 2000)
|
||||
try {
|
||||
setIsSubmitting(true)
|
||||
const reasonLabel = appealReasons.find(r => r.id === selectedReason)?.label || selectedReason
|
||||
const appealReason = `[${reasonLabel}] ${content.trim()}`
|
||||
await api.submitAppeal(taskId, { reason: appealReason })
|
||||
toast.success('申诉提交成功')
|
||||
setIsSubmitted(true)
|
||||
setTimeout(() => {
|
||||
router.push('/creator/appeals')
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
console.error('提交申诉失败:', err)
|
||||
toast.error('提交申诉失败,请稍后重试')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = selectedReason && content.trim().length >= 20 && hasAppealQuota
|
||||
|
||||
// 申请增加申诉次数
|
||||
const handleRequestQuota = async () => {
|
||||
setIsRequestingQuota(true)
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
setIsRequestingQuota(false)
|
||||
setQuotaRequested(true)
|
||||
if (USE_MOCK) {
|
||||
setIsRequestingQuota(true)
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
setIsRequestingQuota(false)
|
||||
setQuotaRequested(true)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsRequestingQuota(true)
|
||||
await api.increaseAppealCount(taskId)
|
||||
toast.success('申请已发送,等待代理商处理')
|
||||
setQuotaRequested(true)
|
||||
// Reload task info to get updated appeal count
|
||||
loadTaskInfo()
|
||||
} catch (err) {
|
||||
console.error('申请增加申诉次数失败:', err)
|
||||
toast.error('申请失败,请稍后重试')
|
||||
} finally {
|
||||
setIsRequestingQuota(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载中骨架屏
|
||||
if (loading) {
|
||||
return (
|
||||
<ResponsiveLayout role="creator">
|
||||
<FormSkeleton />
|
||||
</ResponsiveLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// 提交成功界面
|
||||
@@ -148,6 +326,9 @@ export default function NewAppealPage() {
|
||||
)
|
||||
}
|
||||
|
||||
// Use fallback if taskInfo is somehow null after loading
|
||||
const info = taskInfo || { title: '未知任务', issue: '未知问题', issueDesc: '', type: 'ai', appealRemaining: 0, agencyName: '未知代理商' }
|
||||
|
||||
return (
|
||||
<ResponsiveLayout role="creator">
|
||||
<div className="flex flex-col gap-6 h-full">
|
||||
@@ -171,7 +352,7 @@ export default function NewAppealPage() {
|
||||
)}>
|
||||
<AlertTriangle className={cn('w-5 h-5', hasAppealQuota ? 'text-accent-indigo' : 'text-accent-coral')} />
|
||||
<span className={cn('text-sm font-medium', hasAppealQuota ? 'text-accent-indigo' : 'text-accent-coral')}>
|
||||
本任务剩余 {taskInfo.appealRemaining} 次申诉机会
|
||||
本任务剩余 {info.appealRemaining} 次申诉机会
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -185,16 +366,16 @@ export default function NewAppealPage() {
|
||||
<h3 className="text-base lg:text-lg font-semibold text-text-primary mb-4">关联任务</h3>
|
||||
<div className="bg-bg-elevated rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-base font-semibold text-text-primary">{taskInfo.title}</span>
|
||||
<span className="text-base font-semibold text-text-primary">{info.title}</span>
|
||||
<span className="px-2.5 py-1 rounded-full text-xs font-medium bg-accent-coral/15 text-accent-coral">
|
||||
{taskInfo.type === 'ai' ? 'AI审核' : taskInfo.type === 'agency' ? '代理商审核' : '品牌方审核'}
|
||||
{info.type === 'ai' ? 'AI审核' : info.type === 'agency' ? '代理商审核' : '品牌方审核'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-accent-coral flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<span className="text-sm font-medium text-text-primary">{taskInfo.issue}</span>
|
||||
<p className="text-xs text-text-secondary mt-1">{taskInfo.issueDesc}</p>
|
||||
<span className="text-sm font-medium text-text-primary">{info.issue}</span>
|
||||
<p className="text-xs text-text-secondary mt-1">{info.issueDesc}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -208,7 +389,7 @@ export default function NewAppealPage() {
|
||||
<div className="flex-1">
|
||||
<h3 className="text-base font-semibold text-accent-coral mb-2">申诉次数不足</h3>
|
||||
<p className="text-sm text-text-secondary mb-4">
|
||||
本任务的申诉次数已用完,无法提交新的申诉。您可以向代理商「{taskInfo.agencyName}」申请增加申诉次数。
|
||||
本任务的申诉次数已用完,无法提交新的申诉。您可以向代理商「{info.agencyName}」申请增加申诉次数。
|
||||
</p>
|
||||
{quotaRequested ? (
|
||||
<div className="flex items-center gap-2 text-accent-green">
|
||||
@@ -220,8 +401,9 @@ export default function NewAppealPage() {
|
||||
type="button"
|
||||
onClick={handleRequestQuota}
|
||||
disabled={isRequestingQuota}
|
||||
className="px-4 py-2 bg-accent-coral text-white rounded-lg text-sm font-medium hover:bg-accent-coral/90 transition-colors disabled:opacity-50"
|
||||
className="px-4 py-2 bg-accent-coral text-white rounded-lg text-sm font-medium hover:bg-accent-coral/90 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{isRequestingQuota && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{isRequestingQuota ? '申请中...' : '申请增加申诉次数'}
|
||||
</button>
|
||||
)}
|
||||
@@ -322,12 +504,13 @@ export default function NewAppealPage() {
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit || isSubmitting}
|
||||
className={cn(
|
||||
'w-full py-4 rounded-xl text-base font-semibold',
|
||||
'w-full py-4 rounded-xl text-base font-semibold flex items-center justify-center gap-2',
|
||||
canSubmit && !isSubmitting
|
||||
? 'bg-accent-indigo text-white'
|
||||
: 'bg-bg-elevated text-text-tertiary'
|
||||
)}
|
||||
>
|
||||
{isSubmitting && <Loader2 className="w-5 h-5 animate-spin" />}
|
||||
{isSubmitting ? '提交中...' : '提交申诉'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -341,7 +524,7 @@ export default function NewAppealPage() {
|
||||
<div className="flex flex-col gap-4 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-text-tertiary">关联任务</span>
|
||||
<span className="text-sm text-text-primary">{taskInfo.title}</span>
|
||||
<span className="text-sm text-text-primary">{info.title}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-text-tertiary">申诉原因</span>
|
||||
@@ -378,12 +561,13 @@ export default function NewAppealPage() {
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit || isSubmitting}
|
||||
className={cn(
|
||||
'w-full py-4 rounded-xl text-base font-semibold transition-colors',
|
||||
'w-full py-4 rounded-xl text-base font-semibold transition-colors flex items-center justify-center gap-2',
|
||||
canSubmit && !isSubmitting
|
||||
? 'bg-accent-indigo text-white hover:bg-accent-indigo/90'
|
||||
: 'bg-bg-elevated text-text-tertiary cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{isSubmitting && <Loader2 className="w-5 h-5 animate-spin" />}
|
||||
{isSubmitting ? '提交中...' : '提交申诉'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
MessageCircle,
|
||||
@@ -10,10 +10,15 @@ import {
|
||||
ChevronRight,
|
||||
AlertTriangle,
|
||||
Filter,
|
||||
Search
|
||||
Search,
|
||||
Loader2
|
||||
} from 'lucide-react'
|
||||
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { api } from '@/lib/api'
|
||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
import type { TaskResponse } from '@/types/task'
|
||||
|
||||
// 申诉状态类型
|
||||
type AppealStatus = 'pending' | 'processing' | 'approved' | 'rejected'
|
||||
@@ -80,6 +85,45 @@ const mockAppeals: Appeal[] = [
|
||||
},
|
||||
]
|
||||
|
||||
// 将 TaskResponse 映射为 Appeal UI 类型
|
||||
function mapTaskToAppeal(task: TaskResponse): Appeal {
|
||||
// 判断申诉类型:根据当前阶段判断被驳回的审核类型
|
||||
let type: 'ai' | 'agency' | 'brand' = 'ai'
|
||||
if (task.script_brand_status === 'rejected' || task.video_brand_status === 'rejected') {
|
||||
type = 'brand'
|
||||
} else if (task.script_agency_status === 'rejected' || task.video_agency_status === 'rejected') {
|
||||
type = 'agency'
|
||||
}
|
||||
|
||||
// 判断申诉状态:根据任务阶段和当前状态推断
|
||||
let status: AppealStatus = 'pending'
|
||||
if (task.stage === 'completed') {
|
||||
status = 'approved'
|
||||
} else if (task.stage === 'rejected') {
|
||||
status = 'rejected'
|
||||
} else if (task.is_appeal) {
|
||||
status = 'processing'
|
||||
}
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
taskId: task.id,
|
||||
taskTitle: task.name,
|
||||
type,
|
||||
reason: task.appeal_reason || '申诉',
|
||||
content: task.appeal_reason || '',
|
||||
status,
|
||||
createdAt: task.updated_at ? new Date(task.updated_at).toLocaleString('zh-CN', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
}) : '',
|
||||
updatedAt: task.updated_at ? new Date(task.updated_at).toLocaleString('zh-CN', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
}) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// 状态配置
|
||||
const statusConfig: Record<AppealStatus, { label: string; color: string; bgColor: string; icon: React.ElementType }> = {
|
||||
pending: { label: '待处理', color: 'text-amber-500', bgColor: 'bg-amber-500/15', icon: Clock },
|
||||
@@ -95,6 +139,32 @@ const typeConfig: Record<string, { label: string; color: string }> = {
|
||||
brand: { label: '品牌方审核', color: 'text-accent-blue' },
|
||||
}
|
||||
|
||||
// 骨架屏组件
|
||||
function AppealSkeleton() {
|
||||
return (
|
||||
<div className="bg-bg-card rounded-2xl p-5 card-shadow animate-pulse">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-bg-elevated" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="h-4 w-28 bg-bg-elevated rounded" />
|
||||
<div className="h-3 w-36 bg-bg-elevated rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-6 w-16 bg-bg-elevated rounded-full" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="h-3 w-40 bg-bg-elevated rounded" />
|
||||
<div className="h-3 w-32 bg-bg-elevated rounded" />
|
||||
<div className="h-4 w-full bg-bg-elevated rounded" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t border-border-subtle">
|
||||
<div className="h-3 w-32 bg-bg-elevated rounded" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 申诉卡片组件
|
||||
function AppealCard({ appeal, onClick }: { appeal: Appeal; onClick: () => void }) {
|
||||
const status = statusConfig[appeal.status]
|
||||
@@ -174,9 +244,39 @@ function AppealQuotaEntryCard({ onClick }: { onClick: () => void }) {
|
||||
|
||||
export default function CreatorAppealsPage() {
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const [filter, setFilter] = useState<AppealStatus | 'all'>('all')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [appeals] = useState<Appeal[]>(mockAppeals)
|
||||
const [appeals, setAppeals] = useState<Appeal[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const loadAppeals = useCallback(async () => {
|
||||
if (USE_MOCK) {
|
||||
setAppeals(mockAppeals)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await api.listTasks(1, 50)
|
||||
// Filter for tasks that have appeals (is_appeal === true or have appeal_reason)
|
||||
const appealTasks = response.items.filter(
|
||||
(task) => task.is_appeal || task.appeal_reason || task.appeal_count > 0
|
||||
)
|
||||
const mapped = appealTasks.map(mapTaskToAppeal)
|
||||
setAppeals(mapped)
|
||||
} catch (err) {
|
||||
console.error('加载申诉列表失败:', err)
|
||||
toast.error('加载申诉列表失败,请稍后重试')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [toast])
|
||||
|
||||
useEffect(() => {
|
||||
loadAppeals()
|
||||
}, [loadAppeals])
|
||||
|
||||
// 搜索和筛选
|
||||
const filteredAppeals = appeals.filter(appeal => {
|
||||
@@ -239,8 +339,16 @@ export default function CreatorAppealsPage() {
|
||||
|
||||
{/* 申诉列表 */}
|
||||
<div className="flex flex-col gap-4 flex-1 overflow-y-auto pr-2">
|
||||
<h2 className="text-lg font-semibold text-text-primary">申诉记录 ({filteredAppeals.length})</h2>
|
||||
{filteredAppeals.length > 0 ? (
|
||||
<h2 className="text-lg font-semibold text-text-primary">
|
||||
申诉记录 {!loading && `(${filteredAppeals.length})`}
|
||||
</h2>
|
||||
{loading ? (
|
||||
<>
|
||||
<AppealSkeleton />
|
||||
<AppealSkeleton />
|
||||
<AppealSkeleton />
|
||||
</>
|
||||
) : filteredAppeals.length > 0 ? (
|
||||
filteredAppeals.map((appeal) => (
|
||||
<AppealCard
|
||||
key={appeal.id}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -9,10 +9,15 @@ import {
|
||||
Clock,
|
||||
Video,
|
||||
Filter,
|
||||
ChevronRight
|
||||
ChevronRight,
|
||||
Loader2
|
||||
} from 'lucide-react'
|
||||
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { api } from '@/lib/api'
|
||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
import type { TaskResponse } from '@/types/task'
|
||||
|
||||
// 历史任务状态类型
|
||||
type HistoryStatus = 'completed' | 'expired' | 'cancelled'
|
||||
@@ -80,6 +85,17 @@ const mockHistory: HistoryTask[] = [
|
||||
},
|
||||
]
|
||||
|
||||
function mapTaskResponseToHistory(task: TaskResponse): HistoryTask {
|
||||
return {
|
||||
id: task.id,
|
||||
title: task.name,
|
||||
description: task.project.name,
|
||||
status: task.stage === 'completed' ? 'completed' : 'completed',
|
||||
completedAt: task.updated_at?.split('T')[0],
|
||||
platform: '抖音', // backend doesn't return platform info yet
|
||||
}
|
||||
}
|
||||
|
||||
// 状态配置
|
||||
const statusConfig: Record<HistoryStatus, { label: string; color: string; bgColor: string; icon: React.ElementType }> = {
|
||||
completed: { label: '已完成', color: 'text-accent-green', bgColor: 'bg-accent-green/15', icon: CheckCircle },
|
||||
@@ -87,6 +103,32 @@ const statusConfig: Record<HistoryStatus, { label: string; color: string; bgColo
|
||||
cancelled: { label: '已取消', color: 'text-accent-coral', bgColor: 'bg-accent-coral/15', icon: XCircle },
|
||||
}
|
||||
|
||||
// 骨架屏
|
||||
function HistorySkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="bg-bg-card rounded-2xl p-5 card-shadow animate-pulse">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-16 h-12 rounded-lg bg-bg-elevated" />
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="h-4 w-40 bg-bg-elevated rounded" />
|
||||
<div className="h-3 w-28 bg-bg-elevated rounded" />
|
||||
<div className="h-3 w-20 bg-bg-elevated rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-20 bg-bg-elevated rounded-lg" />
|
||||
<div className="w-5 h-5 bg-bg-elevated rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 历史任务卡片
|
||||
function HistoryCard({ task, onClick }: { task: HistoryTask; onClick: () => void }) {
|
||||
const status = statusConfig[task.status]
|
||||
@@ -127,9 +169,37 @@ function HistoryCard({ task, onClick }: { task: HistoryTask; onClick: () => void
|
||||
|
||||
export default function CreatorHistoryPage() {
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const [filter, setFilter] = useState<HistoryStatus | 'all'>('all')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [historyTasks, setHistoryTasks] = useState<HistoryTask[]>([])
|
||||
|
||||
const filteredHistory = filter === 'all' ? mockHistory : mockHistory.filter(t => t.status === filter)
|
||||
const loadHistory = useCallback(async () => {
|
||||
if (USE_MOCK) {
|
||||
setHistoryTasks(mockHistory)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await api.listTasks(1, 50, 'completed')
|
||||
const mapped = response.items.map(mapTaskResponseToHistory)
|
||||
setHistoryTasks(mapped)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '加载历史记录失败'
|
||||
toast.error(message)
|
||||
console.error('加载历史记录失败:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [toast])
|
||||
|
||||
useEffect(() => {
|
||||
loadHistory()
|
||||
}, [loadHistory])
|
||||
|
||||
const filteredHistory = filter === 'all' ? historyTasks : historyTasks.filter(t => t.status === filter)
|
||||
|
||||
return (
|
||||
<ResponsiveLayout role="creator">
|
||||
@@ -167,21 +237,21 @@ export default function CreatorHistoryPage() {
|
||||
<div className="flex items-center gap-6 bg-bg-card rounded-2xl p-5 card-shadow">
|
||||
<div className="flex flex-col items-center gap-1 flex-1">
|
||||
<span className="text-2xl font-bold text-accent-green">
|
||||
{mockHistory.filter(t => t.status === 'completed').length}
|
||||
{historyTasks.filter(t => t.status === 'completed').length}
|
||||
</span>
|
||||
<span className="text-xs text-text-tertiary">已完成</span>
|
||||
</div>
|
||||
<div className="w-px h-10 bg-border-subtle" />
|
||||
<div className="flex flex-col items-center gap-1 flex-1">
|
||||
<span className="text-2xl font-bold text-text-tertiary">
|
||||
{mockHistory.filter(t => t.status === 'expired').length}
|
||||
{historyTasks.filter(t => t.status === 'expired').length}
|
||||
</span>
|
||||
<span className="text-xs text-text-tertiary">已过期</span>
|
||||
</div>
|
||||
<div className="w-px h-10 bg-border-subtle" />
|
||||
<div className="flex flex-col items-center gap-1 flex-1">
|
||||
<span className="text-2xl font-bold text-accent-coral">
|
||||
{mockHistory.filter(t => t.status === 'cancelled').length}
|
||||
{historyTasks.filter(t => t.status === 'cancelled').length}
|
||||
</span>
|
||||
<span className="text-xs text-text-tertiary">已取消</span>
|
||||
</div>
|
||||
@@ -189,13 +259,23 @@ export default function CreatorHistoryPage() {
|
||||
|
||||
{/* 任务列表 */}
|
||||
<div className="flex flex-col gap-4 flex-1 overflow-y-auto pr-2">
|
||||
{filteredHistory.map((task) => (
|
||||
<HistoryCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onClick={() => router.push(`/creator/task/${task.id}`)}
|
||||
/>
|
||||
))}
|
||||
{loading ? (
|
||||
<HistorySkeleton />
|
||||
) : filteredHistory.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Clock className="w-12 h-12 text-text-tertiary mb-4" />
|
||||
<p className="text-text-secondary">暂无历史记录</p>
|
||||
<p className="text-sm text-text-tertiary mt-1">完成的任务将显示在这里</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredHistory.map((task) => (
|
||||
<HistoryCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onClick={() => router.push(`/creator/task/${task.id}`)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResponsiveLayout>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
import {
|
||||
@@ -14,11 +14,16 @@ import {
|
||||
Building2,
|
||||
Calendar,
|
||||
Clock,
|
||||
ChevronRight
|
||||
ChevronRight,
|
||||
Loader2
|
||||
} from 'lucide-react'
|
||||
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { api } from '@/lib/api'
|
||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||
import type { BriefResponse } from '@/types/brief'
|
||||
import type { TaskResponse } from '@/types/task'
|
||||
|
||||
// 代理商Brief文档类型
|
||||
type AgencyBriefFile = {
|
||||
@@ -29,6 +34,19 @@ type AgencyBriefFile = {
|
||||
description?: string
|
||||
}
|
||||
|
||||
// 页面视图模型
|
||||
type BriefViewModel = {
|
||||
taskName: string
|
||||
agencyName: string
|
||||
brandName: string
|
||||
deadline: string
|
||||
createdAt: string
|
||||
files: AgencyBriefFile[]
|
||||
sellingPoints: { id: string; content: string; required: boolean }[]
|
||||
blacklistWords: { id: string; word: string; reason: string }[]
|
||||
contentRequirements: string[]
|
||||
}
|
||||
|
||||
// 模拟任务数据
|
||||
const mockTaskInfo = {
|
||||
id: 'task-001',
|
||||
@@ -69,11 +87,151 @@ const mockAgencyBrief = {
|
||||
],
|
||||
}
|
||||
|
||||
function buildMockViewModel(): BriefViewModel {
|
||||
return {
|
||||
taskName: mockTaskInfo.taskName,
|
||||
agencyName: mockTaskInfo.agencyName,
|
||||
brandName: mockTaskInfo.brandName,
|
||||
deadline: mockTaskInfo.deadline,
|
||||
createdAt: mockTaskInfo.createdAt,
|
||||
files: mockAgencyBrief.files,
|
||||
sellingPoints: mockAgencyBrief.sellingPoints,
|
||||
blacklistWords: mockAgencyBrief.blacklistWords,
|
||||
contentRequirements: mockAgencyBrief.contentRequirements,
|
||||
}
|
||||
}
|
||||
|
||||
function buildViewModelFromAPI(task: TaskResponse, brief: BriefResponse): BriefViewModel {
|
||||
// Map attachments to file list
|
||||
const files: AgencyBriefFile[] = (brief.attachments ?? []).map((att, idx) => ({
|
||||
id: att.id || `att-${idx}`,
|
||||
name: att.name,
|
||||
size: att.size || '',
|
||||
uploadedAt: brief.updated_at?.split('T')[0] || '',
|
||||
description: undefined,
|
||||
}))
|
||||
|
||||
// Map selling points
|
||||
const sellingPoints = (brief.selling_points ?? []).map((sp, idx) => ({
|
||||
id: `sp-${idx}`,
|
||||
content: sp.content,
|
||||
required: sp.required,
|
||||
}))
|
||||
|
||||
// Map blacklist words
|
||||
const blacklistWords = (brief.blacklist_words ?? []).map((bw, idx) => ({
|
||||
id: `bw-${idx}`,
|
||||
word: bw.word,
|
||||
reason: bw.reason,
|
||||
}))
|
||||
|
||||
// Build content requirements
|
||||
const contentRequirements: string[] = []
|
||||
if (brief.min_duration != null || brief.max_duration != null) {
|
||||
const minStr = brief.min_duration != null ? `${brief.min_duration}` : '?'
|
||||
const maxStr = brief.max_duration != null ? `${brief.max_duration}` : '?'
|
||||
contentRequirements.push(`视频时长:${minStr}-${maxStr}秒`)
|
||||
}
|
||||
if (brief.other_requirements) {
|
||||
contentRequirements.push(brief.other_requirements)
|
||||
}
|
||||
|
||||
return {
|
||||
taskName: task.name,
|
||||
agencyName: task.agency.name,
|
||||
brandName: task.project.brand_name || task.project.name,
|
||||
deadline: '', // backend task has no deadline field yet
|
||||
createdAt: task.created_at.split('T')[0],
|
||||
files,
|
||||
sellingPoints,
|
||||
blacklistWords,
|
||||
contentRequirements,
|
||||
}
|
||||
}
|
||||
|
||||
// 骨架屏
|
||||
function BriefSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 h-full animate-pulse">
|
||||
{/* 顶部导航骨架 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="h-8 w-16 bg-bg-elevated rounded-lg" />
|
||||
<div className="h-7 w-48 bg-bg-elevated rounded" />
|
||||
<div className="h-4 w-36 bg-bg-elevated rounded" />
|
||||
</div>
|
||||
<div className="h-10 w-28 bg-bg-elevated rounded-xl" />
|
||||
</div>
|
||||
|
||||
{/* 任务信息骨架 */}
|
||||
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
|
||||
<div className="h-5 w-24 bg-bg-elevated rounded mb-4" />
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-bg-elevated" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="h-3 w-12 bg-bg-elevated rounded" />
|
||||
<div className="h-4 w-20 bg-bg-elevated rounded" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容区域骨架 */}
|
||||
<div className="flex-1 space-y-6">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="bg-bg-card rounded-2xl p-5 card-shadow">
|
||||
<div className="h-5 w-32 bg-bg-elevated rounded mb-4" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-full bg-bg-elevated rounded" />
|
||||
<div className="h-4 w-3/4 bg-bg-elevated rounded" />
|
||||
<div className="h-4 w-1/2 bg-bg-elevated rounded" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TaskBriefPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const toast = useToast()
|
||||
const taskId = params.id as string
|
||||
const [previewFile, setPreviewFile] = useState<AgencyBriefFile | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [viewModel, setViewModel] = useState<BriefViewModel | null>(null)
|
||||
|
||||
const loadBriefData = useCallback(async () => {
|
||||
if (USE_MOCK) {
|
||||
setViewModel(buildMockViewModel())
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
// First get the task to find its project ID
|
||||
const task = await api.getTask(taskId)
|
||||
// Then get the brief for that project
|
||||
const brief = await api.getBrief(task.project.id)
|
||||
setViewModel(buildViewModelFromAPI(task, brief))
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '加载Brief失败'
|
||||
toast.error(message)
|
||||
console.error('加载Brief失败:', err)
|
||||
// Fallback: still show task info if brief load fails
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [taskId, toast])
|
||||
|
||||
useEffect(() => {
|
||||
loadBriefData()
|
||||
}, [loadBriefData])
|
||||
|
||||
const handleDownload = (file: AgencyBriefFile) => {
|
||||
toast.info(`下载文件: ${file.name}`)
|
||||
@@ -83,8 +241,16 @@ export default function TaskBriefPage() {
|
||||
toast.info('下载全部文件')
|
||||
}
|
||||
|
||||
const requiredPoints = mockAgencyBrief.sellingPoints.filter(sp => sp.required)
|
||||
const optionalPoints = mockAgencyBrief.sellingPoints.filter(sp => !sp.required)
|
||||
if (loading || !viewModel) {
|
||||
return (
|
||||
<ResponsiveLayout role="creator">
|
||||
<BriefSkeleton />
|
||||
</ResponsiveLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const requiredPoints = viewModel.sellingPoints.filter(sp => sp.required)
|
||||
const optionalPoints = viewModel.sellingPoints.filter(sp => !sp.required)
|
||||
|
||||
return (
|
||||
<ResponsiveLayout role="creator">
|
||||
@@ -102,7 +268,7 @@ export default function TaskBriefPage() {
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
<h1 className="text-xl lg:text-[28px] font-bold text-text-primary">{mockTaskInfo.taskName}</h1>
|
||||
<h1 className="text-xl lg:text-[28px] font-bold text-text-primary">{viewModel.taskName}</h1>
|
||||
<p className="text-sm lg:text-[15px] text-text-secondary">查看任务要求和Brief文档</p>
|
||||
</div>
|
||||
<Button onClick={() => router.push(`/creator/task/${params.id}`)}>
|
||||
@@ -121,7 +287,7 @@ export default function TaskBriefPage() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-text-tertiary">代理商</p>
|
||||
<p className="text-sm font-medium text-text-primary">{mockTaskInfo.agencyName}</p>
|
||||
<p className="text-sm font-medium text-text-primary">{viewModel.agencyName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -130,7 +296,7 @@ export default function TaskBriefPage() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-text-tertiary">品牌方</p>
|
||||
<p className="text-sm font-medium text-text-primary">{mockTaskInfo.brandName}</p>
|
||||
<p className="text-sm font-medium text-text-primary">{viewModel.brandName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -139,142 +305,152 @@ export default function TaskBriefPage() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-text-tertiary">分配时间</p>
|
||||
<p className="text-sm font-medium text-text-primary">{mockTaskInfo.createdAt}</p>
|
||||
<p className="text-sm font-medium text-text-primary">{viewModel.createdAt}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-accent-coral/15 flex items-center justify-center">
|
||||
<Clock className="w-5 h-5 text-accent-coral" />
|
||||
{viewModel.deadline && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-accent-coral/15 flex items-center justify-center">
|
||||
<Clock className="w-5 h-5 text-accent-coral" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-text-tertiary">截止日期</p>
|
||||
<p className="text-sm font-medium text-text-primary">{viewModel.deadline}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-text-tertiary">截止日期</p>
|
||||
<p className="text-sm font-medium text-text-primary">{mockTaskInfo.deadline}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主要内容区域 - 可滚动 */}
|
||||
<div className="flex-1 overflow-y-auto space-y-6">
|
||||
{/* Brief文档列表 */}
|
||||
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<File className="w-5 h-5 text-accent-indigo" />
|
||||
<h3 className="text-base font-semibold text-text-primary">Brief 文档</h3>
|
||||
<span className="text-sm text-text-tertiary">({mockAgencyBrief.files.length}个文件)</span>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={handleDownloadAll}>
|
||||
<Download className="w-4 h-4" />
|
||||
下载全部
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{mockAgencyBrief.files.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center justify-between p-4 bg-bg-elevated rounded-xl hover:bg-bg-page transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-11 h-11 rounded-xl bg-accent-indigo/15 flex items-center justify-center flex-shrink-0">
|
||||
<FileText className="w-5 h-5 text-accent-indigo" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary truncate">{file.name}</p>
|
||||
<p className="text-xs text-text-tertiary">{file.size}</p>
|
||||
{file.description && (
|
||||
<p className="text-xs text-text-secondary mt-0.5 truncate">{file.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewFile(file)}
|
||||
className="p-2.5 hover:bg-bg-card rounded-lg transition-colors"
|
||||
>
|
||||
<Eye className="w-4 h-4 text-text-secondary" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDownload(file)}
|
||||
className="p-2.5 hover:bg-bg-card rounded-lg transition-colors"
|
||||
>
|
||||
<Download className="w-4 h-4 text-text-secondary" />
|
||||
</button>
|
||||
</div>
|
||||
{viewModel.files.length > 0 && (
|
||||
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<File className="w-5 h-5 text-accent-indigo" />
|
||||
<h3 className="text-base font-semibold text-text-primary">Brief 文档</h3>
|
||||
<span className="text-sm text-text-tertiary">({viewModel.files.length}个文件)</span>
|
||||
</div>
|
||||
))}
|
||||
<Button variant="secondary" size="sm" onClick={handleDownloadAll}>
|
||||
<Download className="w-4 h-4" />
|
||||
下载全部
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{viewModel.files.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center justify-between p-4 bg-bg-elevated rounded-xl hover:bg-bg-page transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-11 h-11 rounded-xl bg-accent-indigo/15 flex items-center justify-center flex-shrink-0">
|
||||
<FileText className="w-5 h-5 text-accent-indigo" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary truncate">{file.name}</p>
|
||||
<p className="text-xs text-text-tertiary">{file.size}</p>
|
||||
{file.description && (
|
||||
<p className="text-xs text-text-secondary mt-0.5 truncate">{file.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewFile(file)}
|
||||
className="p-2.5 hover:bg-bg-card rounded-lg transition-colors"
|
||||
>
|
||||
<Eye className="w-4 h-4 text-text-secondary" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDownload(file)}
|
||||
className="p-2.5 hover:bg-bg-card rounded-lg transition-colors"
|
||||
>
|
||||
<Download className="w-4 h-4 text-text-secondary" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 内容要求 */}
|
||||
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<FileText className="w-5 h-5 text-accent-amber" />
|
||||
<h3 className="text-base font-semibold text-text-primary">内容要求</h3>
|
||||
{viewModel.contentRequirements.length > 0 && (
|
||||
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<FileText className="w-5 h-5 text-accent-amber" />
|
||||
<h3 className="text-base font-semibold text-text-primary">内容要求</h3>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{viewModel.contentRequirements.map((req, index) => (
|
||||
<li key={index} className="flex items-start gap-2 text-sm text-text-secondary">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent-amber mt-2 flex-shrink-0" />
|
||||
{req}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{mockAgencyBrief.contentRequirements.map((req, index) => (
|
||||
<li key={index} className="flex items-start gap-2 text-sm text-text-secondary">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent-amber mt-2 flex-shrink-0" />
|
||||
{req}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 卖点要求 */}
|
||||
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Target className="w-5 h-5 text-accent-green" />
|
||||
<h3 className="text-base font-semibold text-text-primary">卖点要求</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{requiredPoints.length > 0 && (
|
||||
<div className="p-4 bg-accent-coral/10 rounded-xl border border-accent-coral/30">
|
||||
<p className="text-xs text-accent-coral font-semibold mb-2">必选卖点(必须在内容中提及)</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{requiredPoints.map((sp) => (
|
||||
<span key={sp.id} className="px-3 py-1.5 text-sm bg-accent-coral/20 text-accent-coral rounded-lg font-medium">
|
||||
{sp.content}
|
||||
</span>
|
||||
))}
|
||||
{viewModel.sellingPoints.length > 0 && (
|
||||
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Target className="w-5 h-5 text-accent-green" />
|
||||
<h3 className="text-base font-semibold text-text-primary">卖点要求</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{requiredPoints.length > 0 && (
|
||||
<div className="p-4 bg-accent-coral/10 rounded-xl border border-accent-coral/30">
|
||||
<p className="text-xs text-accent-coral font-semibold mb-2">必选卖点(必须在内容中提及)</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{requiredPoints.map((sp) => (
|
||||
<span key={sp.id} className="px-3 py-1.5 text-sm bg-accent-coral/20 text-accent-coral rounded-lg font-medium">
|
||||
{sp.content}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{optionalPoints.length > 0 && (
|
||||
<div className="p-4 bg-bg-elevated rounded-xl">
|
||||
<p className="text-xs text-text-tertiary font-semibold mb-2">可选卖点(建议提及)</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{optionalPoints.map((sp) => (
|
||||
<span key={sp.id} className="px-3 py-1.5 text-sm bg-bg-page text-text-secondary rounded-lg">
|
||||
{sp.content}
|
||||
</span>
|
||||
))}
|
||||
)}
|
||||
{optionalPoints.length > 0 && (
|
||||
<div className="p-4 bg-bg-elevated rounded-xl">
|
||||
<p className="text-xs text-text-tertiary font-semibold mb-2">可选卖点(建议提及)</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{optionalPoints.map((sp) => (
|
||||
<span key={sp.id} className="px-3 py-1.5 text-sm bg-bg-page text-text-secondary rounded-lg">
|
||||
{sp.content}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 违禁词 */}
|
||||
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Ban className="w-5 h-5 text-accent-coral" />
|
||||
<h3 className="text-base font-semibold text-text-primary">违禁词(请勿在内容中使用)</h3>
|
||||
{viewModel.blacklistWords.length > 0 && (
|
||||
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Ban className="w-5 h-5 text-accent-coral" />
|
||||
<h3 className="text-base font-semibold text-text-primary">违禁词(请勿在内容中使用)</h3>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{viewModel.blacklistWords.map((bw) => (
|
||||
<span
|
||||
key={bw.id}
|
||||
className="px-3 py-1.5 text-sm bg-accent-coral/15 text-accent-coral rounded-lg border border-accent-coral/30"
|
||||
>
|
||||
「{bw.word}」<span className="text-xs opacity-75 ml-1">{bw.reason}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{mockAgencyBrief.blacklistWords.map((bw) => (
|
||||
<span
|
||||
key={bw.id}
|
||||
className="px-3 py-1.5 text-sm bg-accent-coral/15 text-accent-coral rounded-lg border border-accent-coral/30"
|
||||
>
|
||||
「{bw.word}」<span className="text-xs opacity-75 ml-1">{bw.reason}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 底部操作按钮 */}
|
||||
<div className="flex justify-center py-4">
|
||||
|
||||
Reference in New Issue
Block a user