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 NewQuestionPage from "@/routes/questions/new"
|
||||||
import EditQuestionPage from "@/routes/questions/$id.edit"
|
import EditQuestionPage from "@/routes/questions/$id.edit"
|
||||||
import CategoriesPage from "@/routes/categories"
|
import CategoriesPage from "@/routes/categories"
|
||||||
|
import KnowledgeCardsPage from "@/routes/knowledge-cards"
|
||||||
import SkillTreePage from "@/routes/skill-tree"
|
import SkillTreePage from "@/routes/skill-tree"
|
||||||
import UsersPage from "@/routes/users"
|
import UsersPage from "@/routes/users"
|
||||||
import UserDetailPage from "@/routes/users/$id"
|
import UserDetailPage from "@/routes/users/$id"
|
||||||
@ -33,6 +34,7 @@ const router = createBrowserRouter([
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ path: "categories", Component: CategoriesPage },
|
{ path: "categories", Component: CategoriesPage },
|
||||||
|
{ path: "knowledge-cards", Component: KnowledgeCardsPage },
|
||||||
{ path: "skill-tree", Component: SkillTreePage },
|
{ path: "skill-tree", Component: SkillTreePage },
|
||||||
{ path: "feedback", Component: FeedbackPage },
|
{ path: "feedback", Component: FeedbackPage },
|
||||||
{ path: "reports", Component: ReportsPage },
|
{ path: "reports", Component: ReportsPage },
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
FileCheck,
|
FileCheck,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Shield,
|
Shield,
|
||||||
|
BookMarked,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useAuth } from "@/hooks/use-auth"
|
import { useAuth } from "@/hooks/use-auth"
|
||||||
@ -20,6 +21,7 @@ const navItems = [
|
|||||||
{ to: "/questions", label: "题库管理", icon: BookOpen },
|
{ to: "/questions", label: "题库管理", icon: BookOpen },
|
||||||
{ to: "/questions?source=ugc", label: "UGC 审核", icon: FileCheck },
|
{ to: "/questions?source=ugc", label: "UGC 审核", icon: FileCheck },
|
||||||
{ to: "/categories", label: "分类管理", icon: FolderOpen },
|
{ to: "/categories", label: "分类管理", icon: FolderOpen },
|
||||||
|
{ to: "/knowledge-cards", label: "知识卡", icon: BookMarked },
|
||||||
{ to: "/skill-tree", label: "技能树", icon: TreePine },
|
{ to: "/skill-tree", label: "技能树", icon: TreePine },
|
||||||
{ to: "/users", label: "用户管理", icon: Users },
|
{ to: "/users", label: "用户管理", icon: Users },
|
||||||
{ to: "/feedback", label: "用户反馈", icon: MessageSquare },
|
{ 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
|
categoryId?: string
|
||||||
difficulty?: Difficulty
|
difficulty?: Difficulty
|
||||||
source?: "system" | "ugc"
|
source?: "system" | "ugc"
|
||||||
|
sort?: "createdAt" | "difficulty" | "updatedAt"
|
||||||
|
order?: "asc" | "desc"
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchQuestions(
|
export async function fetchQuestions(
|
||||||
@ -23,6 +25,8 @@ export async function fetchQuestions(
|
|||||||
if (params.categoryId) searchParams.set("categoryId", params.categoryId)
|
if (params.categoryId) searchParams.set("categoryId", params.categoryId)
|
||||||
if (params.difficulty) searchParams.set("difficulty", String(params.difficulty))
|
if (params.difficulty) searchParams.set("difficulty", String(params.difficulty))
|
||||||
if (params.source) searchParams.set("source", params.source)
|
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
|
return apiClient
|
||||||
.get("questions", { searchParams })
|
.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,
|
flexRender,
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
} from "@tanstack/react-table"
|
} 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 { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import {
|
import {
|
||||||
@ -68,6 +68,8 @@ export default function QuestionsPage() {
|
|||||||
const [statusFilter, setStatusFilter] = useState<QuestionStatus | "all">("all")
|
const [statusFilter, setStatusFilter] = useState<QuestionStatus | "all">("all")
|
||||||
const [categoryFilter, setCategoryFilter] = useState<string>("all")
|
const [categoryFilter, setCategoryFilter] = useState<string>("all")
|
||||||
const [difficultyFilter, setDifficultyFilter] = 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"
|
// 从 URL 查询参数读取 source,如果没有则默认为 "all"
|
||||||
const [sourceTab, setSourceTab] = useState<SourceTab>(
|
const [sourceTab, setSourceTab] = useState<SourceTab>(
|
||||||
@ -127,6 +129,8 @@ export default function QuestionsPage() {
|
|||||||
? (Number(difficultyFilter) as Difficulty)
|
? (Number(difficultyFilter) as Difficulty)
|
||||||
: undefined,
|
: undefined,
|
||||||
source: sourceTab !== "all" ? sourceTab : undefined,
|
source: sourceTab !== "all" ? sourceTab : undefined,
|
||||||
|
sort: sortField,
|
||||||
|
order: sortOrder,
|
||||||
})
|
})
|
||||||
setQuestions(res.data)
|
setQuestions(res.data)
|
||||||
setTotal(res.pagination.total)
|
setTotal(res.pagination.total)
|
||||||
@ -135,7 +139,7 @@ export default function QuestionsPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [page, search, statusFilter, categoryFilter, difficultyFilter, sourceTab])
|
}, [page, search, statusFilter, categoryFilter, difficultyFilter, sourceTab, sortField, sortOrder])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadQuestions()
|
loadQuestions()
|
||||||
@ -262,6 +266,8 @@ export default function QuestionsPage() {
|
|||||||
difficulty: difficultyFilter !== "all"
|
difficulty: difficultyFilter !== "all"
|
||||||
? (Number(difficultyFilter) as Difficulty)
|
? (Number(difficultyFilter) as Difficulty)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
sort: sortField,
|
||||||
|
order: sortOrder,
|
||||||
})
|
})
|
||||||
exportToCsv("questions.csv", [
|
exportToCsv("questions.csv", [
|
||||||
{ key: "stem", label: "题干" },
|
{ key: "stem", label: "题干" },
|
||||||
@ -392,6 +398,33 @@ export default function QuestionsPage() {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* 批量操作栏 */}
|
{/* 批量操作栏 */}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user