duoqi-admin/src/routes/questions/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

600 lines
19 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 { useCallback, useEffect, useState } from "react"
import { Link, useSearchParams } from "react-router"
import {
useReactTable,
getCoreRowModel,
flexRender,
type ColumnDef,
} from "@tanstack/react-table"
import { Plus, Search, Trash2, EyeOff, Send, Download, FileCheck } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { getColumns } from "@/components/question/columns"
import { StatusTransitionDialog } from "@/components/question/StatusTransitionDialog"
import { ImportQuestionsDialog } from "@/components/question/ImportQuestionsDialog"
import { UgcReviewDialog } from "@/components/question/UgcReviewDialog"
import { Checkbox } from "@/components/ui/checkbox"
import { fetchQuestions, deleteQuestion, updateQuestionStatus, batchOperateQuestions } from "@/lib/api/question-api"
import { fetchCategories } from "@/lib/api/category-api"
import { exportToCsv } from "@/lib/csv-export"
import { DIFFICULTY_LABELS, QUESTION_STATUSES } from "@/lib/constants"
import type { Question, QuestionStatus, Difficulty } from "@/types/question"
import type { Category } from "@/types/category"
const PAGE_SIZE = 20
type SourceTab = "all" | "system" | "ugc"
const SOURCE_TABS = [
{ value: "all" as const, label: "全部题目" },
{ value: "system" as const, label: "官方题库" },
{ value: "ugc" as const, label: "用户投稿" },
] as const
export default function QuestionsPage() {
const [searchParams, setSearchParams] = useSearchParams()
const [questions, setQuestions] = useState<Question[]>([])
const [categories, setCategories] = useState<Category[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<QuestionStatus | "all">("all")
const [categoryFilter, setCategoryFilter] = useState<string>("all")
const [difficultyFilter, setDifficultyFilter] = useState<string>("all")
// 从 URL 查询参数读取 source如果没有则默认为 "all"
const [sourceTab, setSourceTab] = useState<SourceTab>(
() => (searchParams.get("source") as SourceTab) || "all"
)
// 当 sourceTab 改变时,更新 URL
useEffect(() => {
const newParams = new URLSearchParams(searchParams)
if (sourceTab === "all") {
newParams.delete("source")
} else {
newParams.set("source", sourceTab)
}
setSearchParams(newParams, { replace: true })
}, [sourceTab, searchParams, setSearchParams])
// 删除对话框
const [deleteOpen, setDeleteOpen] = useState(false)
const [deletingQuestion, setDeletingQuestion] = useState<Question | null>(null)
// 状态流转对话框
const [statusDialogOpen, setStatusDialogOpen] = useState(false)
const [statusTarget, setStatusTarget] = useState<Question | null>(null)
const [statusTargetState, setStatusTargetState] = useState<QuestionStatus | null>(null)
// 批量导入对话框
const [importOpen, setImportOpen] = useState(false)
// UGC 审核对话框
const [ugcReviewOpen, setUgcReviewOpen] = useState(false)
const [ugcReviewQuestion, setUgcReviewQuestion] = useState<Question | null>(null)
// 批量操作
const [batchConfirmOpen, setBatchConfirmOpen] = useState(false)
const [batchAction, setBatchAction] = useState<"publish" | "archive" | "delete">("delete")
const [batchSubmitting, setBatchSubmitting] = useState(false)
const [exporting, setExporting] = useState(false)
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
// 加载分类列表(用于筛选和列显示)
useEffect(() => {
fetchCategories({ limit: 100 }).then((res) => setCategories(res.data))
}, [])
const loadQuestions = useCallback(async () => {
setLoading(true)
try {
const res = await fetchQuestions({
page,
limit: PAGE_SIZE,
search: search || undefined,
status: statusFilter !== "all" ? statusFilter : undefined,
categoryId: categoryFilter !== "all" ? categoryFilter : undefined,
difficulty: difficultyFilter !== "all"
? (Number(difficultyFilter) as Difficulty)
: undefined,
source: sourceTab !== "all" ? sourceTab : undefined,
})
setQuestions(res.data)
setTotal(res.pagination.total)
} catch {
setQuestions([])
} finally {
setLoading(false)
}
}, [page, search, statusFilter, categoryFilter, difficultyFilter, sourceTab])
useEffect(() => {
loadQuestions()
}, [loadQuestions])
async function handleDelete() {
if (!deletingQuestion) return
await deleteQuestion(deletingQuestion.id)
setDeleteOpen(false)
setDeletingQuestion(null)
await loadQuestions()
}
async function handleStatusChange(question: Question, status: QuestionStatus) {
setStatusTarget(question)
setStatusTargetState(status)
setStatusDialogOpen(true)
}
async function confirmStatusChange() {
if (!statusTarget || !statusTargetState) return
await updateQuestionStatus(statusTarget.id, statusTargetState)
setStatusDialogOpen(false)
setStatusTarget(null)
setStatusTargetState(null)
await loadQuestions()
}
function openDelete(question: Question) {
setDeletingQuestion(question)
setDeleteOpen(true)
}
// 批量操作
async function confirmBatchAction() {
setBatchSubmitting(true)
try {
const ids = table.getFilteredSelectedRowModel().rows.map((r) => r.original.id)
await batchOperateQuestions(ids, batchAction)
setBatchConfirmOpen(false)
table.resetRowSelection()
await loadQuestions()
} finally {
setBatchSubmitting(false)
}
}
function openBatchConfirm(action: "publish" | "archive" | "delete") {
setBatchAction(action)
setBatchConfirmOpen(true)
}
// UGC 审核
function openUgcReview(question: Question) {
setUgcReviewQuestion(question)
setUgcReviewOpen(true)
}
async function handleApproveUgc(_note?: string) {
if (!ugcReviewQuestion) return
await updateQuestionStatus(ugcReviewQuestion.id, "published")
setUgcReviewOpen(false)
setUgcReviewQuestion(null)
await loadQuestions()
}
async function handleRejectUgc(_note: string) {
if (!ugcReviewQuestion) return
// TODO: 这里可以添加 API 调用来保存审核备注
// 暂时只更新状态
await updateQuestionStatus(ugcReviewQuestion.id, "draft")
setUgcReviewOpen(false)
setUgcReviewQuestion(null)
await loadQuestions()
}
const columns = getColumns({
categories,
onDelete: openDelete,
onStatusChange: handleStatusChange,
onReview: sourceTab === "ugc" ? openUgcReview : undefined,
})
// 选择列 + 数据列
const tableColumns: ColumnDef<Question>[] = [
{
id: "select",
header: ({ table: t }) => (
<Checkbox
checked={t.getIsAllPageRowsSelected() || (t.getIsSomePageRowsSelected() && "indeterminate")}
onCheckedChange={(value: boolean) => t.toggleAllPageRowsSelected(!!value)}
aria-label="全选"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value: boolean) => row.toggleSelected(!!value)}
aria-label="选择"
/>
),
},
...columns,
]
const table = useReactTable({
data: questions,
columns: tableColumns,
getCoreRowModel: getCoreRowModel(),
enableRowSelection: true,
})
const selectedCount = table.getFilteredSelectedRowModel().rows.length
async function handleExport() {
setExporting(true)
try {
const res = await fetchQuestions({
limit: 10000,
search: search || undefined,
status: statusFilter !== "all" ? statusFilter : undefined,
categoryId: categoryFilter !== "all" ? categoryFilter : undefined,
difficulty: difficultyFilter !== "all"
? (Number(difficultyFilter) as Difficulty)
: undefined,
})
exportToCsv("questions.csv", [
{ key: "stem", label: "题干" },
{ key: "categoryId", label: "分类" },
{ key: "difficulty", label: "难度" },
{ key: "status", label: "状态" },
{ key: "answerCount", label: "答题次数" },
{ key: "correctRate", label: "正确率" },
{ key: "updatedAt", label: "更新时间" },
], res.data as unknown as Record<string, unknown>[])
} finally {
setExporting(false)
}
}
return (
<div className="space-y-6">
{/* 页面头部 */}
<div className="flex items-center justify-between">
<div className="space-y-2">
<h1 className="text-2xl font-bold"></h1>
<Tabs value={sourceTab} onValueChange={(val) => { setSourceTab(val as SourceTab); setPage(1) }}>
<TabsList>
{SOURCE_TABS.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
<div className="flex gap-2">
{sourceTab === "ugc" && (
<Button variant="outline" size="sm" onClick={() => {/* UGC 批量审核 - 后续实现 */}}>
<FileCheck className="mr-1 size-4" />
</Button>
)}
<Button variant="outline" size="sm" disabled={exporting} onClick={handleExport}>
<Download className="mr-1 size-4" />
{exporting ? "导出中..." : "导出 CSV"}
</Button>
{sourceTab === "system" && (
<Button variant="outline" onClick={() => setImportOpen(true)}>
</Button>
)}
<Button asChild>
<Link to="/questions/new">
<Plus className="size-4" />
</Link>
</Button>
</div>
</div>
{/* 筛选栏 */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative max-w-xs flex-1">
<Search className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
<Input
placeholder="搜索题干..."
className="pl-9"
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(1)
}}
/>
</div>
<Select
value={statusFilter}
onValueChange={(val) => {
setStatusFilter(val as QuestionStatus | "all")
setPage(1)
}}
>
<SelectTrigger className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{Object.entries(QUESTION_STATUSES).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={categoryFilter}
onValueChange={(val) => {
setCategoryFilter(val)
setPage(1)
}}
>
<SelectTrigger className="w-32">
<SelectValue placeholder="全部分类" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{categories.map((cat) => (
<SelectItem key={cat.id} value={cat.id}>
{cat.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={difficultyFilter}
onValueChange={(val) => {
setDifficultyFilter(val)
setPage(1)
}}
>
<SelectTrigger className="w-28">
<SelectValue placeholder="全部难度" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{Object.entries(DIFFICULTY_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 批量操作栏 */}
{selectedCount > 0 && (
<div className="flex items-center gap-3 rounded-lg border bg-muted/50 px-4 py-2">
<span className="text-sm text-muted-foreground">
{selectedCount}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => openBatchConfirm("publish")}
>
<Send className="size-3.5" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => openBatchConfirm("archive")}
>
<EyeOff className="size-3.5" />
</Button>
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => openBatchConfirm("delete")}
>
<Trash2 className="size-3.5" />
</Button>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => table.resetRowSelection()}
>
</Button>
</div>
)}
{/* 表格 */}
<div className="rounded-lg border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center text-muted-foreground"
>
...
</TableCell>
</TableRow>
) : questions.length === 0 ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center text-muted-foreground"
>
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* 分页 */}
{totalPages > 1 && (
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{total} {page}/{totalPages}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
</Button>
</div>
</div>
)}
{/* 删除确认 */}
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-white hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 状态流转确认 */}
<StatusTransitionDialog
open={statusDialogOpen}
onOpenChange={setStatusDialogOpen}
question={statusTarget}
targetStatus={statusTargetState}
onConfirm={confirmStatusChange}
/>
{/* 批量导入 */}
<ImportQuestionsDialog
open={importOpen}
onOpenChange={setImportOpen}
onSuccess={loadQuestions}
/>
{/* UGC 审核对话框 */}
<UgcReviewDialog
open={ugcReviewOpen}
onOpenChange={setUgcReviewOpen}
question={ugcReviewQuestion}
categories={categories}
onApprove={handleApproveUgc}
onReject={handleRejectUgc}
/>
{/* 批量操作确认 */}
<AlertDialog open={batchConfirmOpen} onOpenChange={setBatchConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{batchAction === "delete" ? "批量删除" : batchAction === "publish" ? "批量发布" : "批量下架"}
</AlertDialogTitle>
<AlertDialogDescription>
{batchAction === "delete"
? `确定要删除选中的 ${selectedCount} 道题目吗?此操作不可撤销。`
: batchAction === "publish"
? `确定要将选中的 ${selectedCount} 道题目发布吗?`
: `确定要将选中的 ${selectedCount} 道题目下架吗?`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={batchSubmitting}></AlertDialogCancel>
<AlertDialogAction
onClick={confirmBatchAction}
disabled={batchSubmitting}
className={batchAction === "delete" ? "bg-destructive text-white hover:bg-destructive/90" : undefined}
>
{batchSubmitting ? "处理中..." : "确认"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}