duoqi-admin/src/routes/admins/index.tsx
Wang Zhuoxuan 0a31f8634e feat: 实现 Phase 3 — UGC 审核、举报处理、运营配置、多管理员
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>
2026-04-08 15:38:07 +08:00

390 lines
12 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.

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