Phase 3a - UGC 审核队列: - 题目列表添加来源 Tab 切换(全部/官方/用户投稿) - UGC 审核对话框,支持通过/拒绝并填写备注 - 添加来源列和审核操作入口 Phase 3b - 举报处理: - 举报列表页面,支持搜索和筛选 - 举报详情对话框,支持驳回/采纳处理 - 5 种举报原因和 4 种处理状态 Phase 3c - 运营配置: - 设置页面使用 Tabs 布局 - 活动配置:XP 加成、时间范围、状态管理 - 推送文案:模板管理、变量支持、测试发送 - 通用设置:应用级配置项管理 Phase 3d - 多管理员支持: - 用户名密码登录(替换 Token 登录) - 管理员管理页面:创建、删除、重置密码 - 角色区分:admin(管理员)/ moderator(审核员) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
390 lines
12 KiB
TypeScript
390 lines
12 KiB
TypeScript
import { useState, useEffect, useCallback } from "react"
|
||
import { Plus, Trash2, Shield, ShieldAlert, Key } from "lucide-react"
|
||
import { Button } from "@/components/ui/button"
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table"
|
||
import { Badge } from "@/components/ui/badge"
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog"
|
||
import { Input } from "@/components/ui/input"
|
||
import { Label } from "@/components/ui/label"
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select"
|
||
import {
|
||
AlertDialog,
|
||
AlertDialogAction,
|
||
AlertDialogCancel,
|
||
AlertDialogContent,
|
||
AlertDialogDescription,
|
||
AlertDialogFooter,
|
||
AlertDialogHeader,
|
||
AlertDialogTitle,
|
||
} from "@/components/ui/alert-dialog"
|
||
import { fetchAdmins, createAdmin, deleteAdmin, resetAdminPassword } from "@/lib/api/admin-api"
|
||
import { ADMIN_ROLE_LABELS } from "@/lib/constants"
|
||
import type { Admin, AdminRole, CreateAdminForm } from "@/types/admin"
|
||
|
||
const roleIcons = {
|
||
admin: Shield,
|
||
moderator: ShieldAlert,
|
||
}
|
||
|
||
const roleBadgeVariants: Record<AdminRole, "default" | "secondary"> = {
|
||
admin: "default",
|
||
moderator: "secondary",
|
||
}
|
||
|
||
export default function AdminsPage() {
|
||
const [admins, setAdmins] = useState<Admin[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [dialogOpen, setDialogOpen] = useState(false)
|
||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||
const [resetPasswordOpen, setResetPasswordOpen] = useState(false)
|
||
const [selectedAdmin, setSelectedAdmin] = useState<Admin | null>(null)
|
||
const [submitting, setSubmitting] = useState(false)
|
||
|
||
// 表单状态
|
||
const [formData, setFormData] = useState<CreateAdminForm>({
|
||
username: "",
|
||
password: "",
|
||
role: "moderator",
|
||
})
|
||
|
||
// 重置密码表单
|
||
const [resetPasswordData, setResetPasswordData] = useState({
|
||
newPassword: "",
|
||
confirmPassword: "",
|
||
})
|
||
|
||
const loadAdmins = useCallback(async () => {
|
||
setLoading(true)
|
||
try {
|
||
const res = await fetchAdmins()
|
||
setAdmins(res.data)
|
||
} catch {
|
||
setAdmins([])
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
loadAdmins()
|
||
}, [loadAdmins])
|
||
|
||
function openCreateDialog() {
|
||
setSelectedAdmin(null)
|
||
setFormData({
|
||
username: "",
|
||
password: "",
|
||
role: "moderator",
|
||
})
|
||
setDialogOpen(true)
|
||
}
|
||
|
||
function openDeleteDialog(admin: Admin) {
|
||
setSelectedAdmin(admin)
|
||
setDeleteOpen(true)
|
||
}
|
||
|
||
function openResetPasswordDialog(admin: Admin) {
|
||
setSelectedAdmin(admin)
|
||
setResetPasswordData({ newPassword: "", confirmPassword: "" })
|
||
setResetPasswordOpen(true)
|
||
}
|
||
|
||
async function handleSubmit() {
|
||
if (!formData.password) {
|
||
return
|
||
}
|
||
setSubmitting(true)
|
||
try {
|
||
await createAdmin(formData)
|
||
setDialogOpen(false)
|
||
await loadAdmins()
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
async function handleDelete() {
|
||
if (!selectedAdmin) return
|
||
await deleteAdmin(selectedAdmin.id)
|
||
setDeleteOpen(false)
|
||
setSelectedAdmin(null)
|
||
await loadAdmins()
|
||
}
|
||
|
||
async function handleResetPassword() {
|
||
if (!selectedAdmin || resetPasswordData.newPassword !== resetPasswordData.confirmPassword) {
|
||
return
|
||
}
|
||
setSubmitting(true)
|
||
try {
|
||
await resetAdminPassword(selectedAdmin.id, resetPasswordData.newPassword)
|
||
setResetPasswordOpen(false)
|
||
// TODO: 显示成功提示
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
// 获取当前管理员的信息(从 localStorage)
|
||
const currentAdminId = localStorage.getItem("duoqi_admin_current_id")
|
||
|
||
return (
|
||
<>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-2xl font-bold">管理员管理</h1>
|
||
<p className="text-sm text-muted-foreground">
|
||
管理后台管理员账号和权限
|
||
</p>
|
||
</div>
|
||
<Button onClick={openCreateDialog}>
|
||
<Plus className="size-4 mr-1" />
|
||
新建管理员
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="rounded-lg border">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>用户名</TableHead>
|
||
<TableHead>角色</TableHead>
|
||
<TableHead>创建时间</TableHead>
|
||
<TableHead>最后登录</TableHead>
|
||
<TableHead>操作</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{loading ? (
|
||
<TableRow>
|
||
<TableCell colSpan={5} className="h-24 text-center text-muted-foreground">
|
||
加载中...
|
||
</TableCell>
|
||
</TableRow>
|
||
) : admins.length === 0 ? (
|
||
<TableRow>
|
||
<TableCell colSpan={5} className="h-24 text-center text-muted-foreground">
|
||
暂无管理员账号
|
||
</TableCell>
|
||
</TableRow>
|
||
) : (
|
||
admins.map((admin) => {
|
||
const RoleIcon = roleIcons[admin.role]
|
||
const isCurrentUser = admin.id === currentAdminId
|
||
return (
|
||
<TableRow key={admin.id}>
|
||
<TableCell className="font-medium">
|
||
<div className="flex items-center gap-2">
|
||
<RoleIcon className="size-4 text-muted-foreground" />
|
||
{admin.username}
|
||
{isCurrentUser && (
|
||
<Badge variant="outline" className="text-xs">当前账号</Badge>
|
||
)}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell>
|
||
<Badge variant={roleBadgeVariants[admin.role]}>
|
||
{ADMIN_ROLE_LABELS[admin.role]}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell className="text-sm text-muted-foreground">
|
||
{new Date(admin.createdAt).toLocaleString("zh-CN")}
|
||
</TableCell>
|
||
<TableCell className="text-sm text-muted-foreground">
|
||
{admin.lastLoginAt
|
||
? new Date(admin.lastLoginAt).toLocaleString("zh-CN")
|
||
: "从未登录"}
|
||
</TableCell>
|
||
<TableCell>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="icon-xs"
|
||
onClick={() => openResetPasswordDialog(admin)}
|
||
title="重置密码"
|
||
>
|
||
<Key className="size-3.5" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-xs"
|
||
className="text-destructive"
|
||
onClick={() => openDeleteDialog(admin)}
|
||
disabled={isCurrentUser}
|
||
title={isCurrentUser ? "不能删除当前账号" : "删除"}
|
||
>
|
||
<Trash2 className="size-3.5" />
|
||
</Button>
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
)
|
||
})
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
|
||
{/* 创建管理员对话框 */}
|
||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>新建管理员</DialogTitle>
|
||
<DialogDescription>
|
||
创建新的管理员账号,请妥善保管密码
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="space-y-4 py-4">
|
||
<div>
|
||
<Label htmlFor="username">用户名</Label>
|
||
<Input
|
||
id="username"
|
||
value={formData.username}
|
||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||
placeholder="请输入用户名"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<Label htmlFor="password">密码</Label>
|
||
<Input
|
||
id="password"
|
||
type="password"
|
||
value={formData.password}
|
||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||
placeholder="请输入密码(至少 6 位)"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<Label htmlFor="role">角色</Label>
|
||
<Select
|
||
value={formData.role}
|
||
onValueChange={(val) => setFormData({ ...formData, role: val as AdminRole })}
|
||
>
|
||
<SelectTrigger id="role">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{Object.entries(ADMIN_ROLE_LABELS).map(([value, label]) => (
|
||
<SelectItem key={value} value={value}>
|
||
{label}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={submitting}>
|
||
取消
|
||
</Button>
|
||
<Button onClick={handleSubmit} disabled={submitting || !formData.username || !formData.password}>
|
||
{submitting ? "创建中..." : "创建"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* 删除确认 */}
|
||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
确定要删除管理员"{selectedAdmin?.username}"吗?此操作不可撤销。
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||
<AlertDialogAction
|
||
onClick={handleDelete}
|
||
className="bg-destructive text-white hover:bg-destructive/90"
|
||
>
|
||
删除
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
|
||
{/* 重置密码对话框 */}
|
||
<Dialog open={resetPasswordOpen} onOpenChange={setResetPasswordOpen}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>重置密码</DialogTitle>
|
||
<DialogDescription>
|
||
为管理员"{selectedAdmin?.username}"设置新密码
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="space-y-4 py-4">
|
||
<div>
|
||
<Label htmlFor="new-password">新密码</Label>
|
||
<Input
|
||
id="new-password"
|
||
type="password"
|
||
value={resetPasswordData.newPassword}
|
||
onChange={(e) =>
|
||
setResetPasswordData({ ...resetPasswordData, newPassword: e.target.value })
|
||
}
|
||
placeholder="请输入新密码(至少 6 位)"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<Label htmlFor="confirm-password">确认密码</Label>
|
||
<Input
|
||
id="confirm-password"
|
||
type="password"
|
||
value={resetPasswordData.confirmPassword}
|
||
onChange={(e) =>
|
||
setResetPasswordData({ ...resetPasswordData, confirmPassword: e.target.value })
|
||
}
|
||
placeholder="请再次输入新密码"
|
||
/>
|
||
{resetPasswordData.newPassword !== resetPasswordData.confirmPassword && (
|
||
<p className="text-sm text-destructive mt-1">两次输入的密码不一致</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setResetPasswordOpen(false)} disabled={submitting}>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
onClick={handleResetPassword}
|
||
disabled={submitting || !resetPasswordData.newPassword || resetPasswordData.newPassword !== resetPasswordData.confirmPassword}
|
||
>
|
||
{submitting ? "重置中..." : "确认重置"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</>
|
||
)
|
||
}
|