flnxi-Wu/app/admin/_components/InvitationsTab.tsx
WuYongJie d1c6efc79d
Some checks failed
CI Check / check (push) Has been cancelled
feat: NAS 部署与 Harbor 推送流程
2025-12-03 14:39:06 +08:00

354 lines
14 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.

/**
* 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>
</>
)
}