flnxi-Wu/app/admin/_components/InvitationsTab.tsx
WuYongJie 7dc8d2a528
Some checks failed
CI Check / check (push) Has been cancelled
feat: 统一弹窗系统 & 首页UI优化
- 替换所有浏览器原生弹窗(confirm/alert/prompt)
  - 创建 ConfirmDialog、AlertDialog、PromptDialog 组件
  - 创建 DialogProvider 和 useDialog Hook
  - 替换所有 admin 组件、首页、分享页面中的原生弹窗

- 素材预览面板统一
  - 创建 MaterialPreviewModal 组件
  - 使用统一弹窗组件,统一退出逻辑

- 首页UI统一到三层架构
  - 迁移主要按钮和输入框样式
  - 迁移筛选弹窗中的所有按钮样式
  - 使用 useThemeStyles() Hook

- 更新文档
  - 更新优化记录、优化计划、后续修改、更新日志
2025-11-22 16:20:05 +08:00

330 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.

'use client'
import { useState, useEffect, useCallback } from 'react'
import { useThemeStyles, useTheme, cn } from '@/lib/ui-styles'
import { useDialog } from '@/components/ui/DialogProvider'
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)}
>
删除
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
</div>
{/* Create Invitation Modal */}
{showCreateInvitationModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className={cn(styles.cardStyles.base, 'w-full max-w-md')}>
<h3 className={cn('text-lg font-semibold mb-4', textTitle)}>
创建邀请链接
</h3>
<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 className="flex gap-3 justify-end">
<button
onClick={() => {
setShowCreateInvitationModal(false)
setNewInvitationMaxUses(1)
setNewInvitationExpiresInDays(null)
}}
className={cn(styles.buttonStyles.secondary)}
>
取消
</button>
<button
onClick={handleCreateInvitation}
className={cn(styles.buttonStyles.primary)}
>
创建
</button>
</div>
</div>
</div>
</div>
)}
</>
)
}