354 lines
14 KiB
TypeScript
354 lines
14 KiB
TypeScript
/**
|
||
* InvitationsTab 组件
|
||
*
|
||
* 邀请管理Tab组件
|
||
* 从主页面拆分出来,负责邀请管理功能
|
||
* 包括邀请列表、创建、删除、验证等
|
||
* 使用三层架构样式系统,支持深浅色主题
|
||
*
|
||
* @created 2025-01-XX(拆分自 page.tsx)
|
||
* @component
|
||
*/
|
||
|
||
'use client'
|
||
|
||
import { useState, useEffect, useCallback } from 'react'
|
||
import { useThemeStyles, useTheme, cn } from '@/lib/ui-styles'
|
||
import { useDialog } from '@/components/ui/DialogProvider'
|
||
import Modal from '@/components/ui/Modal'
|
||
import { formatDateTimeLocale } from '../_utils/formatters'
|
||
import type { Invitation, ToastFn } from './types'
|
||
|
||
interface InvitationsTabProps {
|
||
showToast: ToastFn
|
||
}
|
||
|
||
export default function InvitationsTab({ showToast }: InvitationsTabProps) {
|
||
const styles = useThemeStyles()
|
||
const theme = useTheme()
|
||
const { confirm } = useDialog()
|
||
const [invitations, setInvitations] = useState<Invitation[]>([])
|
||
const [showCreateInvitationModal, setShowCreateInvitationModal] = useState(false)
|
||
const [newInvitationMaxUses, setNewInvitationMaxUses] = useState(1)
|
||
const [newInvitationExpiresInDays, setNewInvitationExpiresInDays] = useState<number | null>(null)
|
||
|
||
// 根据主题选择颜色类名
|
||
const borderColor = theme === 'dark' ? 'border-slate-800' : 'border-slate-200'
|
||
const textTitle = theme === 'dark' ? 'text-white' : 'text-slate-900'
|
||
const textDesc = theme === 'dark' ? 'text-slate-400' : 'text-slate-500'
|
||
const textLabel = theme === 'dark' ? 'text-slate-300' : 'text-slate-700'
|
||
const textMono = theme === 'dark' ? 'text-white' : 'text-slate-900'
|
||
const textLink = theme === 'dark' ? 'text-slate-400' : 'text-slate-600'
|
||
const textEmpty = theme === 'dark' ? 'text-slate-500' : 'text-slate-400'
|
||
const hoverBg = theme === 'dark' ? 'hover:bg-slate-800/50' : 'hover:bg-slate-50'
|
||
const divideColor = theme === 'dark' ? 'divide-slate-800' : 'divide-slate-200'
|
||
const badgeSuccess = theme === 'dark' ? 'bg-green-900/30 text-green-300' : 'bg-green-100 text-green-800'
|
||
const badgeDanger = theme === 'dark' ? 'bg-red-900/30 text-red-300' : 'bg-red-100 text-red-800'
|
||
|
||
const fetchInvitations = useCallback(async () => {
|
||
try {
|
||
const response = await fetch('/api/invitations')
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setInvitations(data.invitations || [])
|
||
} else {
|
||
const errorData = await response.json().catch(() => ({}))
|
||
console.error('Error fetching invitations:', errorData)
|
||
showToast(errorData.error || '获取邀请链接失败', 'error')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching invitations:', error)
|
||
showToast('获取邀请链接失败,请稍后重试', 'error')
|
||
}
|
||
}, [showToast])
|
||
|
||
useEffect(() => {
|
||
fetchInvitations()
|
||
}, [fetchInvitations])
|
||
|
||
const handleCreateInvitation = async () => {
|
||
try {
|
||
const response = await fetch('/api/invitations', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
maxUses: newInvitationMaxUses,
|
||
expiresInDays: newInvitationExpiresInDays,
|
||
}),
|
||
})
|
||
|
||
if (response.ok) {
|
||
await fetchInvitations()
|
||
setShowCreateInvitationModal(false)
|
||
setNewInvitationMaxUses(1)
|
||
setNewInvitationExpiresInDays(null)
|
||
showToast('邀请链接创建成功')
|
||
} else {
|
||
const error = await response.json()
|
||
showToast(error.error || '创建失败', 'error')
|
||
}
|
||
} catch (error) {
|
||
console.error('Create invitation error:', error)
|
||
showToast('创建失败,请稍后重试', 'error')
|
||
}
|
||
}
|
||
|
||
const handleToggleInvitationActive = async (id: string, isActive: boolean) => {
|
||
try {
|
||
const response = await fetch(`/api/invitations/${id}`, {
|
||
method: 'PATCH',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ isActive: !isActive }),
|
||
})
|
||
|
||
if (response.ok) {
|
||
await fetchInvitations()
|
||
showToast(isActive ? '邀请链接已禁用' : '邀请链接已激活')
|
||
} else {
|
||
const error = await response.json()
|
||
showToast(error.error || '操作失败', 'error')
|
||
}
|
||
} catch (error) {
|
||
console.error('Toggle invitation active error:', error)
|
||
showToast('操作失败,请稍后重试', 'error')
|
||
}
|
||
}
|
||
|
||
const handleDeleteInvitation = async (id: string) => {
|
||
const confirmed = await confirm({
|
||
title: '确认删除',
|
||
message: '确定要删除这个邀请链接吗?',
|
||
variant: 'danger',
|
||
})
|
||
if (!confirmed) return
|
||
|
||
try {
|
||
const response = await fetch(`/api/invitations/${id}`, {
|
||
method: 'DELETE',
|
||
})
|
||
|
||
if (response.ok) {
|
||
await fetchInvitations()
|
||
showToast('邀请链接已删除')
|
||
} else {
|
||
const error = await response.json()
|
||
showToast(error.error || '删除失败', 'error')
|
||
}
|
||
} catch (error) {
|
||
console.error('Delete invitation error:', error)
|
||
showToast('删除失败,请稍后重试', 'error')
|
||
}
|
||
}
|
||
|
||
const copyToClipboard = (text: string) => {
|
||
navigator.clipboard.writeText(text).then(() => {
|
||
showToast('已复制到剪贴板')
|
||
}).catch(() => {
|
||
showToast('复制失败', 'error')
|
||
})
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<div className={cn(styles.cardStyles.base, 'overflow-hidden')}>
|
||
<div className={cn('px-6 py-4 border-b', borderColor)}>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h2 className={cn('text-xl font-semibold', textTitle)}>邀请管理</h2>
|
||
<p className={cn('text-sm mt-1', textDesc)}>
|
||
创建和管理邀请链接,用户可以通过邀请链接注册账号
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={() => setShowCreateInvitationModal(true)}
|
||
className={cn(styles.buttonStyles.primary, styles.buttonModifiers.compact, 'flex items-center gap-2')}
|
||
>
|
||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||
</svg>
|
||
创建邀请链接
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="p-6">
|
||
{invitations.length === 0 ? (
|
||
<div className={cn('text-center py-8', textDesc)}>
|
||
暂无邀请链接,点击上方按钮创建
|
||
</div>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full">
|
||
<thead>
|
||
<tr className={cn('border-b', borderColor)}>
|
||
<th className={cn('px-4 py-3 text-left text-xs font-medium uppercase', textDesc)}>邀请码</th>
|
||
<th className={cn('px-4 py-3 text-left text-xs font-medium uppercase', textDesc)}>邀请链接</th>
|
||
<th className={cn('px-4 py-3 text-left text-xs font-medium uppercase', textDesc)}>使用次数</th>
|
||
<th className={cn('px-4 py-3 text-left text-xs font-medium uppercase', textDesc)}>过期时间</th>
|
||
<th className={cn('px-4 py-3 text-left text-xs font-medium uppercase', textDesc)}>状态</th>
|
||
<th className={cn('px-4 py-3 text-left text-xs font-medium uppercase', textDesc)}>创建时间</th>
|
||
<th className={cn('px-4 py-3 text-left text-xs font-medium uppercase', textDesc)}>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className={cn('divide-y', divideColor)}>
|
||
{invitations.map((invitation) => {
|
||
const isExpired = invitation.expiresAt && new Date(invitation.expiresAt) < new Date()
|
||
const isUsedUp = (invitation.currentUses || 0) >= (invitation.maxUses || 1)
|
||
const isValid = (invitation.isActive !== false) && !isExpired && !isUsedUp
|
||
|
||
return (
|
||
<tr key={invitation.id} className={hoverBg}>
|
||
<td className={cn('px-4 py-3 text-sm font-mono', textMono)}>
|
||
{invitation.code}
|
||
</td>
|
||
<td className="px-4 py-3 text-sm">
|
||
{invitation.inviteUrl ? (
|
||
<div className="flex items-center gap-2">
|
||
<span className={cn('font-mono text-xs truncate max-w-xs', textLink)}>
|
||
{invitation.inviteUrl}
|
||
</span>
|
||
<button
|
||
onClick={() => copyToClipboard(invitation.inviteUrl!)}
|
||
className={cn(styles.buttonStyles.secondary, styles.buttonModifiers.compactXs, 'flex-shrink-0')}
|
||
>
|
||
复制
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<span className={textEmpty}>-</span>
|
||
)}
|
||
</td>
|
||
<td className={cn('px-4 py-3 text-sm', textMono)}>
|
||
{invitation.currentUses} / {invitation.maxUses}
|
||
</td>
|
||
<td className={cn('px-4 py-3 text-sm', textMono)}>
|
||
{invitation.expiresAt
|
||
? formatDateTimeLocale(invitation.expiresAt)
|
||
: '永不过期'}
|
||
</td>
|
||
<td className="px-4 py-3 text-sm">
|
||
<span
|
||
className={cn('px-2 py-1 rounded-full text-xs font-medium', isValid ? badgeSuccess : badgeDanger)}
|
||
>
|
||
{!invitation.isActive
|
||
? '已禁用'
|
||
: isExpired
|
||
? '已过期'
|
||
: isUsedUp
|
||
? '已用完'
|
||
: '有效'}
|
||
</span>
|
||
</td>
|
||
<td className={cn('px-4 py-3 text-sm', textDesc)}>
|
||
{formatDateTimeLocale(invitation.createdAt)}
|
||
</td>
|
||
<td className="px-4 py-3 text-sm">
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => handleToggleInvitationActive(invitation.id, invitation.isActive)}
|
||
className={cn(
|
||
invitation.isActive ? styles.buttonStyles.danger : styles.buttonStyles.success,
|
||
styles.buttonModifiers.compactXs,
|
||
)}
|
||
>
|
||
{invitation.isActive ? '禁用' : '启用'}
|
||
</button>
|
||
<button
|
||
onClick={() => handleDeleteInvitation(invitation.id)}
|
||
className={cn(
|
||
styles.buttonStyles.danger,
|
||
styles.buttonModifiers.compactXs,
|
||
'bg-red-500 hover:bg-red-600 text-white' // 显式添加背景色和文字色
|
||
)}
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Create Invitation Modal */}
|
||
<Modal
|
||
isOpen={showCreateInvitationModal}
|
||
onClose={() => {
|
||
setShowCreateInvitationModal(false)
|
||
setNewInvitationMaxUses(1)
|
||
setNewInvitationExpiresInDays(null)
|
||
}}
|
||
title="创建邀请链接"
|
||
size="small"
|
||
footer={
|
||
<div className="flex gap-3 justify-end">
|
||
<button
|
||
onClick={() => {
|
||
setShowCreateInvitationModal(false)
|
||
setNewInvitationMaxUses(1)
|
||
setNewInvitationExpiresInDays(null)
|
||
}}
|
||
className={cn(
|
||
styles.buttonStyles.secondary,
|
||
'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200'
|
||
)}
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
onClick={handleCreateInvitation}
|
||
className={cn(styles.buttonStyles.primary)}
|
||
>
|
||
创建
|
||
</button>
|
||
</div>
|
||
}
|
||
>
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className={cn('block text-sm font-medium mb-2', textLabel)}>
|
||
最大使用次数
|
||
</label>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
value={newInvitationMaxUses}
|
||
onChange={(e) => setNewInvitationMaxUses(parseInt(e.target.value) || 1)}
|
||
className={cn(styles.inputStyles.base)}
|
||
placeholder="请输入最大使用次数"
|
||
/>
|
||
<p className={cn('text-xs mt-1', textDesc)}>
|
||
邀请链接最多可以被使用多少次
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<label className={cn('block text-sm font-medium mb-2', textLabel)}>
|
||
过期天数(可选)
|
||
</label>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
value={newInvitationExpiresInDays || ''}
|
||
onChange={(e) => setNewInvitationExpiresInDays(e.target.value ? parseInt(e.target.value) : null)}
|
||
className={cn(styles.inputStyles.base)}
|
||
placeholder="留空表示永不过期"
|
||
/>
|
||
<p className={cn('text-xs mt-1', textDesc)}>
|
||
邀请链接在多少天后过期,留空表示永不过期
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
</>
|
||
)
|
||
}
|
||
|