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>
600 lines
19 KiB
TypeScript
600 lines
19 KiB
TypeScript
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>
|
||
)
|
||
}
|