feat: 完善知识卡独立页面和题目列表排序
- 新增 /knowledge-cards 独立路由和列表页面 - 知识卡支持搜索、完成度筛选、详情查看和快速编辑 - 题目列表添加排序控件(创建时间/更新时间/难度 + 升降序) - API 层支持 sort 和 order 参数 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
87a1f39d51
commit
1fc27207e0
@ -6,6 +6,7 @@ import QuestionsPage from "@/routes/questions"
|
||||
import NewQuestionPage from "@/routes/questions/new"
|
||||
import EditQuestionPage from "@/routes/questions/$id.edit"
|
||||
import CategoriesPage from "@/routes/categories"
|
||||
import KnowledgeCardsPage from "@/routes/knowledge-cards"
|
||||
import SkillTreePage from "@/routes/skill-tree"
|
||||
import UsersPage from "@/routes/users"
|
||||
import UserDetailPage from "@/routes/users/$id"
|
||||
@ -33,6 +34,7 @@ const router = createBrowserRouter([
|
||||
],
|
||||
},
|
||||
{ path: "categories", Component: CategoriesPage },
|
||||
{ path: "knowledge-cards", Component: KnowledgeCardsPage },
|
||||
{ path: "skill-tree", Component: SkillTreePage },
|
||||
{ path: "feedback", Component: FeedbackPage },
|
||||
{ path: "reports", Component: ReportsPage },
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
FileCheck,
|
||||
AlertCircle,
|
||||
Shield,
|
||||
BookMarked,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useAuth } from "@/hooks/use-auth"
|
||||
@ -20,6 +21,7 @@ const navItems = [
|
||||
{ to: "/questions", label: "题库管理", icon: BookOpen },
|
||||
{ to: "/questions?source=ugc", label: "UGC 审核", icon: FileCheck },
|
||||
{ to: "/categories", label: "分类管理", icon: FolderOpen },
|
||||
{ to: "/knowledge-cards", label: "知识卡", icon: BookMarked },
|
||||
{ to: "/skill-tree", label: "技能树", icon: TreePine },
|
||||
{ to: "/users", label: "用户管理", icon: Users },
|
||||
{ to: "/feedback", label: "用户反馈", icon: MessageSquare },
|
||||
|
||||
49
src/lib/api/knowledge-card-api.ts
Normal file
49
src/lib/api/knowledge-card-api.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { apiClient } from "@/lib/api-client"
|
||||
import type { ApiResponse } from "@/types/api"
|
||||
|
||||
export interface KnowledgeCardItem {
|
||||
id: string
|
||||
questionId: string
|
||||
questionStem: string
|
||||
categoryId: string
|
||||
basic: string
|
||||
deep?: string
|
||||
sourceRef?: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface FetchKnowledgeCardsParams {
|
||||
page?: number
|
||||
limit?: number
|
||||
search?: string
|
||||
status?: "all" | "complete" | "incomplete"
|
||||
}
|
||||
|
||||
export async function fetchKnowledgeCards(
|
||||
params: FetchKnowledgeCardsParams = {}
|
||||
): Promise<ApiResponse<KnowledgeCardItem[]>> {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params.page) searchParams.set("page", String(params.page))
|
||||
if (params.limit) searchParams.set("limit", String(params.limit))
|
||||
if (params.search) searchParams.set("search", params.search)
|
||||
if (params.status && params.status !== "all") searchParams.set("status", params.status)
|
||||
|
||||
return apiClient
|
||||
.get("knowledge-cards", { searchParams })
|
||||
.json<ApiResponse<KnowledgeCardItem[]>>()
|
||||
}
|
||||
|
||||
export interface UpdateKnowledgeCardData {
|
||||
basic: string
|
||||
deep?: string
|
||||
sourceRef?: string
|
||||
}
|
||||
|
||||
export async function updateKnowledgeCard(
|
||||
id: string,
|
||||
data: UpdateKnowledgeCardData
|
||||
): Promise<ApiResponse<KnowledgeCardItem>> {
|
||||
return apiClient
|
||||
.put(`knowledge-cards/${id}`, { json: data })
|
||||
.json<ApiResponse<KnowledgeCardItem>>()
|
||||
}
|
||||
@ -10,6 +10,8 @@ export interface FetchQuestionsParams {
|
||||
categoryId?: string
|
||||
difficulty?: Difficulty
|
||||
source?: "system" | "ugc"
|
||||
sort?: "createdAt" | "difficulty" | "updatedAt"
|
||||
order?: "asc" | "desc"
|
||||
}
|
||||
|
||||
export async function fetchQuestions(
|
||||
@ -23,6 +25,8 @@ export async function fetchQuestions(
|
||||
if (params.categoryId) searchParams.set("categoryId", params.categoryId)
|
||||
if (params.difficulty) searchParams.set("difficulty", String(params.difficulty))
|
||||
if (params.source) searchParams.set("source", params.source)
|
||||
if (params.sort) searchParams.set("sort", params.sort)
|
||||
if (params.order) searchParams.set("order", params.order)
|
||||
|
||||
return apiClient
|
||||
.get("questions", { searchParams })
|
||||
|
||||
420
src/routes/knowledge-cards/index.tsx
Normal file
420
src/routes/knowledge-cards/index.tsx
Normal file
@ -0,0 +1,420 @@
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
flexRender,
|
||||
type ColumnDef,
|
||||
} from "@tanstack/react-table"
|
||||
import { Search, Pencil, Eye } 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 {
|
||||
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 { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { fetchKnowledgeCards, updateKnowledgeCard } from "@/lib/api/knowledge-card-api"
|
||||
import type { KnowledgeCardItem, UpdateKnowledgeCardData } from "@/lib/api/knowledge-card-api"
|
||||
import { fetchCategories } from "@/lib/api/category-api"
|
||||
import type { Category } from "@/types/category"
|
||||
|
||||
const BASIC_MAX = 100
|
||||
const DEEP_MAX = 300
|
||||
|
||||
type CardStatus = "all" | "complete" | "incomplete"
|
||||
|
||||
function getCardStatus(item: KnowledgeCardItem): "complete" | "incomplete" {
|
||||
return item.basic && item.basic.trim().length > 0 ? "complete" : "incomplete"
|
||||
}
|
||||
|
||||
export default function KnowledgeCardsPage() {
|
||||
const [cards, setCards] = useState<KnowledgeCardItem[]>([])
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<CardStatus>("all")
|
||||
|
||||
// 编辑对话框
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [editingCard, setEditingCard] = useState<KnowledgeCardItem | null>(null)
|
||||
const [editForm, setEditForm] = useState<UpdateKnowledgeCardData>({ basic: "", deep: "", sourceRef: "" })
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// 详情对话框
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [detailCard, setDetailCard] = useState<KnowledgeCardItem | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories({ limit: 100 }).then((res) => setCategories(res.data))
|
||||
}, [])
|
||||
|
||||
const loadCards = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetchKnowledgeCards({
|
||||
search: search || undefined,
|
||||
status: statusFilter,
|
||||
})
|
||||
setCards(res.data)
|
||||
} catch {
|
||||
setCards([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [search, statusFilter])
|
||||
|
||||
useEffect(() => {
|
||||
loadCards()
|
||||
}, [loadCards])
|
||||
|
||||
function getCategoryName(categoryId: string): string {
|
||||
return categories.find((c) => c.id === categoryId)?.name ?? categoryId
|
||||
}
|
||||
|
||||
function openEdit(card: KnowledgeCardItem) {
|
||||
setEditingCard(card)
|
||||
setEditForm({
|
||||
basic: card.basic,
|
||||
deep: card.deep || "",
|
||||
sourceRef: card.sourceRef || "",
|
||||
})
|
||||
setEditOpen(true)
|
||||
}
|
||||
|
||||
function openDetail(card: KnowledgeCardItem) {
|
||||
setDetailCard(card)
|
||||
setDetailOpen(true)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!editingCard) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await updateKnowledgeCard(editingCard.id, editForm)
|
||||
setEditOpen(false)
|
||||
await loadCards()
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnDef<KnowledgeCardItem>[] = [
|
||||
{
|
||||
accessorKey: "questionStem",
|
||||
header: "关联题目",
|
||||
cell: ({ row }) => {
|
||||
const stem = row.original.questionStem
|
||||
return (
|
||||
<span className="line-clamp-2 max-w-xs" title={stem}>
|
||||
{stem.length > 60 ? stem.slice(0, 60) + "..." : stem}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "categoryId",
|
||||
header: "分类",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground">
|
||||
{getCategoryName(row.original.categoryId)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "basicStatus",
|
||||
header: "基础卡",
|
||||
cell: ({ row }) => {
|
||||
const hasBasic = row.original.basic?.trim().length > 0
|
||||
return (
|
||||
<Badge variant={hasBasic ? "default" : "outline"}>
|
||||
{hasBasic ? `${row.original.basic.length} 字` : "未填写"}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "deepStatus",
|
||||
header: "深度卡",
|
||||
cell: ({ row }) => {
|
||||
const hasDeep = (row.original.deep?.trim().length ?? 0) > 0
|
||||
return (
|
||||
<Badge variant={hasDeep ? "secondary" : "outline"}>
|
||||
{hasDeep ? `${row.original.deep!.length} 字` : "未填写"}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "completeness",
|
||||
header: "完成度",
|
||||
cell: ({ row }) => {
|
||||
const status = getCardStatus(row.original)
|
||||
return (
|
||||
<Badge variant={status === "complete" ? "default" : "destructive"}>
|
||||
{status === "complete" ? "完整" : "待补充"}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "updatedAt",
|
||||
header: "更新时间",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{new Date(row.original.updatedAt).toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "操作",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="icon-xs" onClick={() => openDetail(row.original)} title="查看">
|
||||
<Eye className="size-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-xs" onClick={() => openEdit(row.original)} title="编辑">
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const table = useReactTable({
|
||||
data: cards,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
})
|
||||
|
||||
// 统计
|
||||
const totalCards = cards.length
|
||||
const completeCards = cards.filter((c) => getCardStatus(c) === "complete").length
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
{/* 页面头部 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">知识卡管理</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
管理{totalCards} 张知识卡,{completeCards} 张已完成,{totalCards - completeCards} 张待补充
|
||||
</p>
|
||||
</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)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={statusFilter} onValueChange={(val) => setStatusFilter(val as CardStatus)}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="complete">已完成</SelectItem>
|
||||
<SelectItem value="incomplete">待补充</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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>
|
||||
) : cards.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>
|
||||
</div>
|
||||
|
||||
{/* 查看详情 */}
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>知识卡详情</DialogTitle>
|
||||
</DialogHeader>
|
||||
{detailCard && (
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<Label className="text-muted-foreground">关联题目</Label>
|
||||
<p className="mt-1 text-sm">{detailCard.questionStem}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Label>基础知识卡</Label>
|
||||
<Badge variant="secondary" className="text-xs">所有用户</Badge>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed bg-muted/30 rounded-md p-3">
|
||||
{detailCard.basic || <span className="italic text-muted-foreground">未填写</span>}
|
||||
</p>
|
||||
</div>
|
||||
{detailCard.deep && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Label>深度知识卡</Label>
|
||||
<Badge variant="outline" className="text-xs">Pro 用户</Badge>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed bg-muted/30 rounded-md p-3 text-muted-foreground">
|
||||
{detailCard.deep}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{detailCard.sourceRef && (
|
||||
<div>
|
||||
<Label className="text-muted-foreground">来源参考</Label>
|
||||
<p className="text-sm">{detailCard.sourceRef}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDetailOpen(false)}>关闭</Button>
|
||||
<Button onClick={() => { setDetailOpen(false); if (detailCard) openEdit(detailCard) }}>
|
||||
编辑
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 编辑对话框 */}
|
||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑知识卡</DialogTitle>
|
||||
<DialogDescription>
|
||||
编辑知识卡内容。基础版所有用户可见,深度版仅 Pro 用户可见。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{editingCard && (
|
||||
<div className="rounded-md bg-muted/30 p-3">
|
||||
<Label className="text-muted-foreground text-xs">关联题目</Label>
|
||||
<p className="text-sm mt-1 line-clamp-2">{editingCard.questionStem}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label htmlFor="edit-basic">基础知识卡</Label>
|
||||
<span className={`text-xs ${editForm.basic.length > BASIC_MAX ? "text-destructive font-medium" : "text-muted-foreground"}`}>
|
||||
{editForm.basic.length}/{BASIC_MAX}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
id="edit-basic"
|
||||
value={editForm.basic}
|
||||
onChange={(e) => setEditForm({ ...editForm, basic: e.target.value })}
|
||||
rows={3}
|
||||
placeholder="2-3 句趣味解读..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label htmlFor="edit-deep">深度知识卡(Pro)</Label>
|
||||
<span className={`text-xs ${editForm.deep && editForm.deep.length > DEEP_MAX ? "text-destructive font-medium" : "text-muted-foreground"}`}>
|
||||
{(editForm.deep?.length ?? 0)}/{DEEP_MAX}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
id="edit-deep"
|
||||
value={editForm.deep ?? ""}
|
||||
onChange={(e) => setEditForm({ ...editForm, deep: e.target.value })}
|
||||
rows={5}
|
||||
placeholder="扩展背景故事、趣味延伸..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="edit-sourceRef">来源参考(选填)</Label>
|
||||
<Input
|
||||
id="edit-sourceRef"
|
||||
value={editForm.sourceRef ?? ""}
|
||||
onChange={(e) => setEditForm({ ...editForm, sourceRef: e.target.value })}
|
||||
placeholder="如:《旧唐书·太宗本纪》"
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditOpen(false)} disabled={submitting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={submitting}>
|
||||
{submitting ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -6,7 +6,7 @@ import {
|
||||
flexRender,
|
||||
type ColumnDef,
|
||||
} from "@tanstack/react-table"
|
||||
import { Plus, Search, Trash2, EyeOff, Send, Download, FileCheck } from "lucide-react"
|
||||
import { Plus, Search, Trash2, EyeOff, Send, Download, FileCheck, ArrowUpDown } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
@ -68,6 +68,8 @@ export default function QuestionsPage() {
|
||||
const [statusFilter, setStatusFilter] = useState<QuestionStatus | "all">("all")
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>("all")
|
||||
const [difficultyFilter, setDifficultyFilter] = useState<string>("all")
|
||||
const [sortField, setSortField] = useState<"createdAt" | "difficulty" | "updatedAt">("createdAt")
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc")
|
||||
|
||||
// 从 URL 查询参数读取 source,如果没有则默认为 "all"
|
||||
const [sourceTab, setSourceTab] = useState<SourceTab>(
|
||||
@ -127,6 +129,8 @@ export default function QuestionsPage() {
|
||||
? (Number(difficultyFilter) as Difficulty)
|
||||
: undefined,
|
||||
source: sourceTab !== "all" ? sourceTab : undefined,
|
||||
sort: sortField,
|
||||
order: sortOrder,
|
||||
})
|
||||
setQuestions(res.data)
|
||||
setTotal(res.pagination.total)
|
||||
@ -135,7 +139,7 @@ export default function QuestionsPage() {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, search, statusFilter, categoryFilter, difficultyFilter, sourceTab])
|
||||
}, [page, search, statusFilter, categoryFilter, difficultyFilter, sourceTab, sortField, sortOrder])
|
||||
|
||||
useEffect(() => {
|
||||
loadQuestions()
|
||||
@ -262,6 +266,8 @@ export default function QuestionsPage() {
|
||||
difficulty: difficultyFilter !== "all"
|
||||
? (Number(difficultyFilter) as Difficulty)
|
||||
: undefined,
|
||||
sort: sortField,
|
||||
order: sortOrder,
|
||||
})
|
||||
exportToCsv("questions.csv", [
|
||||
{ key: "stem", label: "题干" },
|
||||
@ -392,6 +398,33 @@ export default function QuestionsPage() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={sortField}
|
||||
onValueChange={(val) => {
|
||||
setSortField(val as "createdAt" | "difficulty" | "updatedAt")
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="createdAt">创建时间</SelectItem>
|
||||
<SelectItem value="updatedAt">更新时间</SelectItem>
|
||||
<SelectItem value="difficulty">难度</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-9"
|
||||
onClick={() => setSortOrder((prev) => prev === "asc" ? "desc" : "asc")}
|
||||
title={sortOrder === "asc" ? "升序" : "降序"}
|
||||
>
|
||||
<ArrowUpDown className={`size-4 ${sortOrder === "asc" ? "rotate-180" : ""} transition-transform`} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 批量操作栏 */}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user