feat: 完善品牌方和代理商前端功能

品牌方功能:
- 项目看板: 添加截止日期编辑功能
- 项目详情: 添加代理商管理、截止日期编辑、最近任务显示代理商
- 项目创建: 代理商选择支持搜索(名称/ID/公司名)
- 代理商管理: 通过ID邀请、添加备注/分配项目/移除操作
- Brief配置: 新增项目级Brief和规则配置页面
- 系统设置: 完善账户安全(密码/2FA/邮箱/手机/设备管理)、数据导出、退出登录

代理商功能:
- 个人中心: 新增代理商ID展示、公司信息(企业验证)、个人信息编辑
- 账户设置: 密码修改、手机/邮箱绑定、两步验证
- 通知设置: 分类型和渠道的通知开关
- 审核历史: 搜索筛选和统计展示
- 帮助反馈: FAQ分类搜索和客服联系

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-02-06 17:40:11 +08:00
co-authored by Claude Opus 4.5
parent ae74c515c7
commit 964797d2e9
14 changed files with 4349 additions and 154 deletions
+412 -66
View File
@@ -11,18 +11,49 @@ import {
Users,
TrendingUp,
TrendingDown,
Mail,
Copy,
CheckCircle,
Clock,
MoreVertical
MoreVertical,
Building2,
AlertCircle,
UserPlus,
MessageSquareText,
Trash2,
FolderPlus
} from 'lucide-react'
// 代理商类型
interface Agency {
id: string
agencyId: string // 代理商IDAG开头)
name: string
companyName: string
email: string
status: 'active' | 'pending' | 'paused'
creatorCount: number
projectCount: number
passRate: number
trend: 'up' | 'down' | 'stable'
joinedAt: string
remark?: string
}
// 模拟项目列表(用于分配代理商)
const mockProjects = [
{ id: 'proj-001', name: 'XX品牌618推广' },
{ id: 'proj-002', name: '口红系列推广' },
{ id: 'proj-003', name: 'XX运动品牌' },
{ id: 'proj-004', name: '护肤品秋季活动' },
]
// 模拟代理商列表
const mockAgencies = [
const initialAgencies: Agency[] = [
{
id: 'agency-001',
id: 'a-001',
agencyId: 'AG789012',
name: '星耀传媒',
companyName: '上海星耀文化传媒有限公司',
email: 'contact@xingyao.com',
status: 'active',
creatorCount: 50,
@@ -32,8 +63,10 @@ const mockAgencies = [
joinedAt: '2025-06-15',
},
{
id: 'agency-002',
id: 'a-002',
agencyId: 'AG456789',
name: '创意无限',
companyName: '深圳创意无限广告有限公司',
email: 'hello@chuangyi.com',
status: 'active',
creatorCount: 35,
@@ -43,8 +76,10 @@ const mockAgencies = [
joinedAt: '2025-08-20',
},
{
id: 'agency-003',
id: 'a-003',
agencyId: 'AG123456',
name: '美妆达人MCN',
companyName: '杭州美妆达人网络科技有限公司',
email: 'biz@meizhuang.com',
status: 'active',
creatorCount: 28,
@@ -54,9 +89,11 @@ const mockAgencies = [
joinedAt: '2025-10-10',
},
{
id: 'agency-004',
name: '时尚风向标',
email: 'info@shishang.com',
id: 'a-004',
agencyId: 'AG111111',
name: '蓝海科技',
companyName: '北京蓝海数字科技有限公司',
email: 'info@lanhai.com',
status: 'pending',
creatorCount: 0,
projectCount: 0,
@@ -74,35 +111,130 @@ function StatusTag({ status }: { status: string }) {
export default function AgenciesManagePage() {
const [searchQuery, setSearchQuery] = useState('')
const [showInviteModal, setShowInviteModal] = useState(false)
const [inviteEmail, setInviteEmail] = useState('')
const [inviteLink, setInviteLink] = useState('')
const [agencies, setAgencies] = useState<Agency[]>(initialAgencies)
const [copiedId, setCopiedId] = useState<string | null>(null)
const filteredAgencies = mockAgencies.filter(agency =>
// 邀请代理商弹窗
const [showInviteModal, setShowInviteModal] = useState(false)
const [inviteAgencyId, setInviteAgencyId] = useState('')
const [inviteResult, setInviteResult] = useState<{ success: boolean; message: string } | null>(null)
// 操作菜单状态
const [openMenuId, setOpenMenuId] = useState<string | null>(null)
// 备注弹窗状态
const [remarkModal, setRemarkModal] = useState<{ open: boolean; agency: Agency | null }>({ open: false, agency: null })
const [remarkText, setRemarkText] = useState('')
// 删除确认弹窗状态
const [deleteModal, setDeleteModal] = useState<{ open: boolean; agency: Agency | null }>({ open: false, agency: null })
// 分配项目弹窗状态
const [assignModal, setAssignModal] = useState<{ open: boolean; agency: Agency | null }>({ open: false, agency: null })
const [selectedProjects, setSelectedProjects] = useState<string[]>([])
const filteredAgencies = agencies.filter(agency =>
agency.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
agency.email.toLowerCase().includes(searchQuery.toLowerCase())
agency.agencyId.toLowerCase().includes(searchQuery.toLowerCase()) ||
agency.companyName.toLowerCase().includes(searchQuery.toLowerCase())
)
// 复制代理商ID
const handleCopyAgencyId = async (agencyId: string) => {
await navigator.clipboard.writeText(agencyId)
setCopiedId(agencyId)
setTimeout(() => setCopiedId(null), 2000)
}
// 邀请代理商
const handleInvite = () => {
if (!inviteEmail.trim()) {
alert('请输入代理商邮箱')
if (!inviteAgencyId.trim()) {
setInviteResult({ success: false, message: '请输入代理商ID' })
return
}
// 模拟生成邀请链接
const link = `https://miaosi.app/invite/agency/${Date.now()}`
setInviteLink(link)
// 检查代理商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())) {
setInviteResult({ success: false, message: '该代理商已在您的列表中' })
return
}
// 模拟发送邀请成功
setInviteResult({ success: true, message: `已向代理商 ${inviteAgencyId.toUpperCase()} 发送邀请` })
}
const handleCopyLink = () => {
navigator.clipboard.writeText(inviteLink)
alert('链接已复制')
}
const handleSendInvite = () => {
alert(`邀请已发送至 ${inviteEmail}`)
const handleCloseInviteModal = () => {
setShowInviteModal(false)
setInviteEmail('')
setInviteLink('')
setInviteAgencyId('')
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
))
}
setRemarkModal({ open: false, agency: null })
setRemarkText('')
}
// 打开删除确认
const handleOpenDelete = (agency: Agency) => {
setDeleteModal({ open: true, agency })
setOpenMenuId(null)
}
// 确认删除
const handleConfirmDelete = () => {
if (deleteModal.agency) {
setAgencies(prev => prev.filter(a => a.id !== deleteModal.agency!.id))
}
setDeleteModal({ open: false, agency: null })
}
// 打开分配项目弹窗
const handleOpenAssign = (agency: Agency) => {
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 = () => {
if (assignModal.agency && selectedProjects.length > 0) {
const projectNames = mockProjects
.filter(p => selectedProjects.includes(p.id))
.map(p => p.name)
.join('、')
alert(`已将代理商「${assignModal.agency.name}」分配到项目「${projectNames}`)
}
setAssignModal({ open: false, agency: null })
setSelectedProjects([])
}
return (
@@ -126,7 +258,7 @@ export default function AgenciesManagePage() {
<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">{mockAgencies.length}</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" />
@@ -139,7 +271,7 @@ export default function AgenciesManagePage() {
<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">{mockAgencies.filter(a => a.status === 'active').length}</p>
<p className="text-2xl font-bold text-accent-green">{agencies.filter(a => a.status === 'active').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" />
@@ -152,7 +284,7 @@ export default function AgenciesManagePage() {
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-text-secondary"></p>
<p className="text-2xl font-bold text-yellow-400">{mockAgencies.filter(a => a.status === 'pending').length}</p>
<p className="text-2xl font-bold text-yellow-400">{agencies.filter(a => a.status === 'pending').length}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center">
<Clock size={20} className="text-yellow-400" />
@@ -166,7 +298,9 @@ export default function AgenciesManagePage() {
<div>
<p className="text-sm text-text-secondary"></p>
<p className="text-2xl font-bold text-text-primary">
{Math.round(mockAgencies.filter(a => a.status === 'active').reduce((sum, a) => sum + a.passRate, 0) / mockAgencies.filter(a => a.status === 'active').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}%
</p>
</div>
<div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
@@ -182,10 +316,10 @@ export default function AgenciesManagePage() {
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-tertiary" />
<input
type="text"
placeholder="搜索代理商名称或邮箱..."
placeholder="搜索代理商名称、ID或公司名..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-border-subtle rounded-lg bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
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>
@@ -196,6 +330,7 @@ export default function AgenciesManagePage() {
<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>
@@ -208,9 +343,43 @@ export default function AgenciesManagePage() {
{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>
<div className="font-medium text-text-primary">{agency.name}</div>
<div className="text-sm text-text-tertiary">{agency.email}</div>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-accent-indigo/15 flex items-center justify-center">
<Building2 size={20} className="text-accent-indigo" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-text-primary">{agency.name}</span>
{agency.remark && (
<span className="px-2 py-0.5 text-xs rounded bg-accent-amber/15 text-accent-amber" title={agency.remark}>
</span>
)}
</div>
<div className="text-sm text-text-tertiary">{agency.companyName}</div>
{agency.remark && (
<p className="text-xs text-text-tertiary mt-0.5 line-clamp-1">{agency.remark}</p>
)}
</div>
</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<code className="px-2 py-1 rounded bg-bg-elevated text-sm font-mono text-accent-indigo">
{agency.agencyId}
</code>
<button
type="button"
onClick={() => handleCopyAgencyId(agency.agencyId)}
className="p-1 rounded hover:bg-bg-elevated transition-colors"
title="复制代理商ID"
>
{copiedId === agency.agencyId ? (
<CheckCircle size={14} className="text-accent-green" />
) : (
<Copy size={14} className="text-text-tertiary" />
)}
</button>
</div>
</td>
<td className="px-6 py-4">
@@ -233,66 +402,243 @@ export default function AgenciesManagePage() {
</td>
<td className="px-6 py-4 text-sm text-text-tertiary">{agency.joinedAt}</td>
<td className="px-6 py-4">
<Button variant="ghost" size="sm">
<MoreVertical size={16} />
</Button>
<div className="relative">
<Button
variant="ghost"
size="sm"
onClick={() => setOpenMenuId(openMenuId === agency.id ? null : agency.id)}
>
<MoreVertical size={16} />
</Button>
{/* 下拉菜单 */}
{openMenuId === agency.id && (
<div className="absolute right-0 top-full mt-1 w-40 bg-bg-card rounded-xl shadow-lg border border-border-subtle z-10 overflow-hidden">
<button
type="button"
onClick={() => handleOpenRemark(agency)}
className="w-full px-4 py-2.5 text-left text-sm text-text-primary hover:bg-bg-elevated flex items-center gap-2"
>
<MessageSquareText size={14} className="text-text-secondary" />
{agency.remark ? '编辑备注' : '添加备注'}
</button>
<button
type="button"
onClick={() => handleOpenAssign(agency)}
className="w-full px-4 py-2.5 text-left text-sm text-text-primary hover:bg-bg-elevated flex items-center gap-2"
>
<FolderPlus size={14} className="text-text-secondary" />
</button>
<button
type="button"
onClick={() => handleOpenDelete(agency)}
className="w-full px-4 py-2.5 text-left text-sm text-accent-coral hover:bg-accent-coral/10 flex items-center gap-2"
>
<Trash2 size={14} />
</button>
</div>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
{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={() => { setShowInviteModal(false); setInviteEmail(''); setInviteLink(''); }} title="邀请代理商">
<Modal isOpen={showInviteModal} onClose={handleCloseInviteModal} title="邀请代理商">
<div className="space-y-4">
<p className="text-text-secondary text-sm"></p>
<p className="text-text-secondary text-sm">
ID邀请合作ID可在代理商的个人中心查看
</p>
<div>
<label className="block text-sm font-medium text-text-primary mb-1"></label>
<label className="block text-sm font-medium text-text-primary mb-2">ID</label>
<div className="flex gap-2">
<input
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="agency@example.com"
className="flex-1 px-4 py-2 border border-border-subtle rounded-lg bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
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}>
</Button>
</div>
<p className="text-xs text-text-tertiary mt-2">ID格式AG + 6</p>
</div>
{inviteLink && (
<div className="p-4 bg-bg-elevated rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-text-primary"></span>
<button
type="button"
onClick={handleCopyLink}
className="flex items-center gap-1 text-sm text-accent-indigo hover:underline"
>
<Copy size={14} />
</button>
</div>
<p className="text-sm text-text-secondary break-all">{inviteLink}</p>
{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={() => { setShowInviteModal(false); setInviteEmail(''); setInviteLink(''); }}>
<Button variant="ghost" onClick={handleCloseInviteModal}>
</Button>
<Button onClick={handleSendInvite} disabled={!inviteEmail.trim()}>
<Mail size={16} />
<Button
onClick={() => {
if (inviteResult?.success) {
handleCloseInviteModal()
}
}}
disabled={!inviteResult?.success}
>
<UserPlus size={16} />
</Button>
</div>
</div>
</Modal>
{/* 备注弹窗 */}
<Modal
isOpen={remarkModal.open}
onClose={() => { setRemarkModal({ open: false, agency: null }); setRemarkText(''); }}
title={`${remarkModal.agency?.remark ? '编辑' : '添加'}备注 - ${remarkModal.agency?.name}`}
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-2"></label>
<textarea
value={remarkText}
onChange={(e) => setRemarkText(e.target.value)}
placeholder="输入备注信息,如代理商特点、合作注意事项等..."
className="w-full h-32 px-4 py-3 border border-border-subtle rounded-xl bg-bg-elevated text-text-primary placeholder-text-tertiary resize-none focus:outline-none focus:ring-2 focus:ring-accent-indigo"
/>
</div>
<div className="flex gap-3 justify-end">
<Button variant="ghost" onClick={() => { setRemarkModal({ open: false, agency: null }); setRemarkText(''); }}>
</Button>
<Button onClick={handleSaveRemark}>
<CheckCircle size={16} />
</Button>
</div>
</div>
</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}
>
<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">
{mockProjects.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}>
<FolderPlus size={16} />
</Button>
</div>
</div>
</Modal>
{/* 点击其他地方关闭菜单 */}
{openMenuId && (
<div
className="fixed inset-0 z-0"
onClick={() => setOpenMenuId(null)}
/>
)}
</div>
)
}