Your Name a8be7bbca9 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>
2026-02-09 16:29:43 +08:00

552 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
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 } from '@/components/ui/Tag'
import { useToast } from '@/components/ui/Toast'
import {
Search,
Plus,
Users,
Copy,
CheckCircle,
MoreVertical,
Building2,
AlertCircle,
UserPlus,
Trash2,
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'
// ==================== Mock 数据 ====================
const mockAgencies: AgencyDetail[] = [
{ id: 'AG789012', name: '星耀传媒', contact_name: '张经理', force_pass_enabled: true },
{ id: 'AG456789', name: '创意无限', contact_name: '李总', force_pass_enabled: false },
{ id: 'AG123456', name: '美妆达人MCN', contact_name: '王经理', force_pass_enabled: false },
{ id: 'AG111111', name: '蓝海科技', force_pass_enabled: true },
]
const mockProjects: ProjectResponse[] = [
{ id: 'PJ000001', name: 'XX品牌618推广', brand_id: 'BR000001', status: 'active', agencies: [], task_count: 5, created_at: '2025-06-01', updated_at: '2025-06-01' },
{ id: 'PJ000002', name: '口红系列推广', brand_id: 'BR000001', status: 'active', agencies: [], task_count: 3, created_at: '2025-07-01', updated_at: '2025-07-01' },
]
function StatusTag({ forcePass }: { forcePass: boolean }) {
if (forcePass) return <SuccessTag></SuccessTag>
return <PendingTag></PendingTag>
}
function AgencySkeleton() {
return (
<div className="animate-pulse">
<div className="h-20 bg-bg-elevated rounded-lg mb-2" />
<div className="h-20 bg-bg-elevated rounded-lg mb-2" />
<div className="h-20 bg-bg-elevated rounded-lg" />
</div>
)
}
export default function AgenciesManagePage() {
const toast = useToast()
const [searchQuery, setSearchQuery] = useState('')
const [agencies, setAgencies] = useState<AgencyDetail[]>([])
const [projects, setProjects] = useState<ProjectResponse[]>([])
const [loading, setLoading] = useState(true)
const [copiedId, setCopiedId] = useState<string | null>(null)
// 邀请代理商弹窗
const [showInviteModal, setShowInviteModal] = useState(false)
const [inviteAgencyId, setInviteAgencyId] = useState('')
const [inviting, setInviting] = useState(false)
const [inviteResult, setInviteResult] = useState<{ success: boolean; message: string } | null>(null)
// 操作菜单状态
const [openMenuId, setOpenMenuId] = useState<string | null>(null)
// 删除确认弹窗状态
const [deleteModal, setDeleteModal] = useState<{ open: boolean; agency: AgencyDetail | null }>({ open: false, agency: null })
const [deleting, setDeleting] = useState(false)
// 分配项目弹窗状态
const [assignModal, setAssignModal] = useState<{ open: boolean; agency: AgencyDetail | null }>({ open: false, agency: null })
const [selectedProjects, setSelectedProjects] = useState<string[]>([])
const [assigning, setAssigning] = useState(false)
const loadData = useCallback(async () => {
if (USE_MOCK) {
setAgencies(mockAgencies)
setProjects(mockProjects)
setLoading(false)
return
}
try {
const [agencyRes, projectRes] = await Promise.all([
api.listBrandAgencies(),
api.listProjects(1, 100),
])
setAgencies(agencyRes.items)
setProjects(projectRes.items)
} catch (err) {
console.error('Failed to load data:', err)
toast.error('加载数据失败')
} finally {
setLoading(false)
}
}, [toast])
useEffect(() => { loadData() }, [loadData])
const filteredAgencies = agencies.filter(agency =>
agency.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
agency.id.toLowerCase().includes(searchQuery.toLowerCase()) ||
(agency.contact_name || '').toLowerCase().includes(searchQuery.toLowerCase())
)
// 复制代理商ID
const handleCopyAgencyId = async (agencyId: string) => {
await navigator.clipboard.writeText(agencyId)
setCopiedId(agencyId)
setTimeout(() => setCopiedId(null), 2000)
}
// 邀请代理商
const handleInvite = async () => {
if (!inviteAgencyId.trim()) {
setInviteResult({ success: false, message: '请输入代理商ID' })
return
}
const idPattern = /^AG\d{6}$/
if (!idPattern.test(inviteAgencyId.toUpperCase())) {
setInviteResult({ success: false, message: '代理商ID格式错误应为AG+6位数字' })
return
}
if (agencies.some(a => a.id === inviteAgencyId.toUpperCase())) {
setInviteResult({ success: false, message: '该代理商已在您的列表中' })
return
}
setInviting(true)
try {
if (USE_MOCK) {
await new Promise(resolve => setTimeout(resolve, 500))
} else {
await api.inviteAgency(inviteAgencyId.toUpperCase())
}
setInviteResult({ success: true, message: `已向代理商 ${inviteAgencyId.toUpperCase()} 发送邀请` })
} catch (err) {
setInviteResult({ success: false, message: err instanceof Error ? err.message : '邀请失败' })
} finally {
setInviting(false)
}
}
const handleCloseInviteModal = () => {
setShowInviteModal(false)
setInviteAgencyId('')
setInviteResult(null)
}
const handleConfirmInvite = async () => {
if (inviteResult?.success) {
handleCloseInviteModal()
await loadData()
}
}
// 打开删除确认
const handleOpenDelete = (agency: AgencyDetail) => {
setDeleteModal({ open: true, agency })
setOpenMenuId(null)
}
// 确认删除
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 })
}
}
// 打开分配项目弹窗
const handleOpenAssign = (agency: AgencyDetail) => {
setSelectedProjects([])
setAssignModal({ open: true, agency })
setOpenMenuId(null)
}
// 切换项目选择
const toggleProjectSelection = (projectId: string) => {
setSelectedProjects(prev =>
prev.includes(projectId) ? prev.filter(id => id !== projectId) : [...prev, projectId]
)
}
// 确认分配项目
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([])
}
}
return (
<div className="space-y-6 min-h-0">
{/* 页面标题 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary"></h1>
<p className="text-sm text-text-secondary mt-1"></p>
</div>
<Button onClick={() => setShowInviteModal(true)}>
<Plus size={16} />
</Button>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-text-secondary"></p>
<p className="text-2xl font-bold text-text-primary">{agencies.length}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-accent-indigo/20 flex items-center justify-center">
<Users size={20} className="text-accent-indigo" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-text-secondary"></p>
<p className="text-2xl font-bold text-accent-green">{agencies.filter(a => a.force_pass_enabled).length}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-accent-green/20 flex items-center justify-center">
<CheckCircle size={20} className="text-accent-green" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-text-secondary"></p>
<p className="text-2xl font-bold text-text-primary">{projects.length}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
<Building2 size={20} className="text-purple-400" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* 搜索 */}
<div className="relative max-w-md">
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-tertiary" />
<input
type="text"
placeholder="搜索代理商名称、ID..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 border border-border-subtle rounded-xl bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
/>
</div>
{/* 代理商列表 */}
<Card>
<CardContent className="p-0 overflow-x-auto">
{loading ? (
<div className="p-6"><AgencySkeleton /></div>
) : (
<table className="w-full min-w-[700px]">
<thead>
<tr className="border-b border-border-subtle text-left text-sm text-text-secondary bg-bg-elevated">
<th className="px-6 py-4 font-medium"></th>
<th className="px-6 py-4 font-medium">ID</th>
<th className="px-6 py-4 font-medium"></th>
<th className="px-6 py-4 font-medium"></th>
<th className="px-6 py-4 font-medium"></th>
</tr>
</thead>
<tbody>
{filteredAgencies.map((agency) => (
<tr key={agency.id} className="border-b border-border-subtle last:border-0 hover:bg-bg-elevated/50">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-accent-indigo/15 flex items-center justify-center">
<Building2 size={20} className="text-accent-indigo" />
</div>
<span className="font-medium text-text-primary">{agency.name}</span>
</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<code className="px-2 py-1 rounded bg-bg-elevated text-sm font-mono text-accent-indigo">
{agency.id}
</code>
<button
type="button"
onClick={() => handleCopyAgencyId(agency.id)}
className="p-1 rounded hover:bg-bg-elevated transition-colors"
title="复制代理商ID"
>
{copiedId === agency.id ? (
<CheckCircle size={14} className="text-accent-green" />
) : (
<Copy size={14} className="text-text-tertiary" />
)}
</button>
</div>
</td>
<td className="px-6 py-4 text-text-secondary text-sm">
{agency.contact_name || '-'}
</td>
<td className="px-6 py-4">
<StatusTag forcePass={agency.force_pass_enabled} />
</td>
<td className="px-6 py-4">
<div className="relative">
<Button
variant="ghost"
size="sm"
onClick={() => setOpenMenuId(openMenuId === agency.id ? null : agency.id)}
>
<MoreVertical size={16} />
</Button>
{openMenuId === agency.id && (
<div className="absolute right-0 top-full mt-1 w-40 bg-bg-card rounded-xl shadow-lg border border-border-subtle z-10 overflow-hidden">
<button
type="button"
onClick={() => handleOpenAssign(agency)}
className="w-full px-4 py-2.5 text-left text-sm text-text-primary hover:bg-bg-elevated flex items-center gap-2"
>
<FolderPlus size={14} className="text-text-secondary" />
</button>
<button
type="button"
onClick={() => handleOpenDelete(agency)}
className="w-full px-4 py-2.5 text-left text-sm text-accent-coral hover:bg-accent-coral/10 flex items-center gap-2"
>
<Trash2 size={14} />
</button>
</div>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
{!loading && filteredAgencies.length === 0 && (
<div className="text-center py-12 text-text-tertiary">
<Building2 size={48} className="mx-auto mb-4 opacity-50" />
<p></p>
</div>
)}
</CardContent>
</Card>
{/* 邀请代理商弹窗 */}
<Modal isOpen={showInviteModal} onClose={handleCloseInviteModal} title="邀请代理商">
<div className="space-y-4">
<p className="text-text-secondary text-sm">
ID邀请合作ID可在代理商的个人中心查看
</p>
<div>
<label className="block text-sm font-medium text-text-primary mb-2">ID</label>
<div className="flex gap-2">
<input
type="text"
value={inviteAgencyId}
onChange={(e) => {
setInviteAgencyId(e.target.value.toUpperCase())
setInviteResult(null)
}}
placeholder="例如: AG789012"
className="flex-1 px-4 py-2.5 border border-border-subtle rounded-xl bg-bg-elevated text-text-primary font-mono focus:outline-none focus:ring-2 focus:ring-accent-indigo"
/>
<Button variant="secondary" onClick={handleInvite} disabled={inviting}>
{inviting ? <Loader2 size={16} className="animate-spin" /> : '查找'}
</Button>
</div>
<p className="text-xs text-text-tertiary mt-2">ID格式AG + 6</p>
</div>
{inviteResult && (
<div className={`p-4 rounded-xl flex items-start gap-3 ${
inviteResult.success ? 'bg-accent-green/10 border border-accent-green/20' : 'bg-accent-coral/10 border border-accent-coral/20'
}`}>
{inviteResult.success ? (
<CheckCircle size={18} className="text-accent-green flex-shrink-0 mt-0.5" />
) : (
<AlertCircle size={18} className="text-accent-coral flex-shrink-0 mt-0.5" />
)}
<span className={`text-sm ${inviteResult.success ? 'text-accent-green' : 'text-accent-coral'}`}>
{inviteResult.message}
</span>
</div>
)}
<div className="flex gap-3 justify-end pt-4">
<Button variant="ghost" onClick={handleCloseInviteModal}>
</Button>
<Button onClick={handleConfirmInvite} disabled={!inviteResult?.success}>
<UserPlus size={16} />
</Button>
</div>
</div>
</Modal>
{/* 删除确认弹窗 */}
<Modal
isOpen={deleteModal.open}
onClose={() => setDeleteModal({ open: false, agency: null })}
title="确认移除代理商"
>
<div className="space-y-4">
<div className="p-4 rounded-xl bg-accent-coral/10 border border-accent-coral/20">
<div className="flex items-start gap-3">
<AlertCircle size={20} className="text-accent-coral flex-shrink-0 mt-0.5" />
<div>
<p className="text-text-primary font-medium">{deleteModal.agency?.name}</p>
<p className="text-sm text-text-secondary mt-1">
</p>
</div>
</div>
</div>
<div className="flex gap-3 justify-end">
<Button variant="ghost" onClick={() => setDeleteModal({ open: false, agency: null })}>
</Button>
<Button
variant="secondary"
className="border-accent-coral text-accent-coral hover:bg-accent-coral/10"
onClick={handleConfirmDelete}
disabled={deleting}
>
{deleting ? <Loader2 size={16} className="animate-spin" /> : <Trash2 size={16} />}
</Button>
</div>
</div>
</Modal>
{/* 分配项目弹窗 */}
<Modal
isOpen={assignModal.open}
onClose={() => { setAssignModal({ open: false, agency: null }); setSelectedProjects([]); }}
title={`分配代理商到项目 - ${assignModal.agency?.name}`}
>
<div className="space-y-4">
<p className="text-text-secondary text-sm">
</p>
<div>
<label className="block text-sm font-medium text-text-primary mb-2"></label>
<div className="space-y-2 max-h-60 overflow-y-auto">
{projects.map((project) => {
const isSelected = selectedProjects.includes(project.id)
return (
<button
key={project.id}
type="button"
onClick={() => toggleProjectSelection(project.id)}
className={`w-full flex items-center gap-3 p-4 rounded-xl border-2 text-left transition-colors ${
isSelected
? 'border-accent-indigo bg-accent-indigo/10'
: 'border-border-subtle hover:border-accent-indigo/50'
}`}
>
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center ${
isSelected ? 'border-accent-indigo bg-accent-indigo' : 'border-border-subtle'
}`}>
{isSelected && <CheckCircle size={12} className="text-white" />}
</div>
<span className="text-text-primary">{project.name}</span>
</button>
)
})}
</div>
</div>
{selectedProjects.length > 0 && (
<p className="text-sm text-text-secondary">
<span className="text-accent-indigo font-medium">{selectedProjects.length}</span>
</p>
)}
<div className="flex gap-3 justify-end pt-2">
<Button variant="ghost" onClick={() => { setAssignModal({ open: false, agency: null }); setSelectedProjects([]); }}>
</Button>
<Button onClick={handleConfirmAssign} disabled={selectedProjects.length === 0 || assigning}>
{assigning ? <Loader2 size={16} className="animate-spin" /> : <FolderPlus size={16} />}
</Button>
</div>
</div>
</Modal>
{/* 点击其他地方关闭菜单 */}
{openMenuId && (
<div
className="fixed inset-0 z-0"
onClick={() => setOpenMenuId(null)}
/>
)}
</div>
)
}