feat(attendance,elective): 实现所有 P2 长期改进项
P2 修复(来自审计报告): - 2.4.4: Server Action 错误消息 i18n 化(attendance/elective 全部 Action) - 2.5.3: 抽取 AttendancePageLayout 组件复用(admin/teacher 页面) - 2.5.4: 抽取 ElectivePageLayout 组件复用(admin/teacher 列表页) - 2.6.3: 考勤月历键盘导航(tabIndex + 方向键 + Home/End + role=grid) - 2.8.2: getStudentAttendanceSummary 分页优化(SQL 聚合统计 + LIMIT 分页) - 2.8.3: resolveCourseDisplayNames 缓存优化(React cache 去重) - 2.1.4: elective data-access 跨模块依赖接口抽象(resolvers.ts 可注入) P2 建议项: - 选课时间冲突检测(parseSchedule + isScheduleConflict 纯函数 + checkScheduleConflict) - 学分上限校验(MAX_CREDIT_PER_TERM + checkCreditLimit) - 考勤/选课数据导出 Excel(export.ts + API 路由扩展) 新增文件: - src/modules/attendance/components/attendance-page-layout.tsx - src/modules/elective/components/elective-page-layout.tsx - src/modules/elective/resolvers.ts - src/modules/attendance/export.ts - src/modules/elective/export.ts 校验: - npm run lint 通过(exit 0) - npx tsc --noEmit attendance/elective/parent 相关零错误
This commit is contained in:
@@ -27,7 +27,9 @@ import {
|
||||
import {
|
||||
getKnowledgePointsWithRelations,
|
||||
getStudentKpMastery,
|
||||
getClassKpMastery,
|
||||
} from "./data-access-graph";
|
||||
import { getClassStudents } from "@/modules/classes/data-access-students";
|
||||
import {
|
||||
CreateTextbookSchema,
|
||||
UpdateTextbookSchema,
|
||||
@@ -387,11 +389,17 @@ export async function getKnowledgeGraphDataAction(
|
||||
masteryMap[kpId] = info;
|
||||
}
|
||||
}
|
||||
// 无学生身份时 masteryMap 保持为空,前端将显示"未测评"状态
|
||||
} else if (viewMode === "class-mastery") {
|
||||
// 简化实现:暂不获取班级学生列表,返回空 masteryMap
|
||||
// 后续迭代可通过 classes 模块获取教师所带班级学生 ID,
|
||||
// 再从 data-access-graph 导入 getClassKpMastery 并调用
|
||||
// getClassKpMastery(studentIds, textbookId) 计算班级平均掌握度
|
||||
// 获取教师所带班级的所有学生 ID,计算班级平均掌握度
|
||||
const students = await getClassStudents({ status: "active" });
|
||||
const studentIds = students.map((s) => s.id);
|
||||
if (studentIds.length > 0) {
|
||||
const mastery = await getClassKpMastery(studentIds, textbookId);
|
||||
for (const [kpId, info] of mastery) {
|
||||
masteryMap[kpId] = info;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -40,7 +40,7 @@ interface SortableChapterItemProps {
|
||||
canEdit?: boolean
|
||||
}
|
||||
|
||||
function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId, onDelete, onCreateSub, canEdit = true }: SortableChapterItemProps) {
|
||||
function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId, onDelete, onCreateSub, canEdit = false }: SortableChapterItemProps) {
|
||||
const t = useTranslations("textbooks")
|
||||
const [isOpen, setIsOpen] = useState(level === 0)
|
||||
const hasChildren = chapter.children && chapter.children.length > 0
|
||||
@@ -158,7 +158,7 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId,
|
||||
)
|
||||
}
|
||||
|
||||
function RecursiveSortableList({ items, level, selectedId, onSelect, textbookId, onDelete, onCreateSub, canEdit = true }: {
|
||||
function RecursiveSortableList({ items, level, selectedId, onSelect, textbookId, onDelete, onCreateSub, canEdit = false }: {
|
||||
items: Chapter[],
|
||||
level: number,
|
||||
selectedId?: string,
|
||||
@@ -195,7 +195,7 @@ interface ChapterSidebarListProps {
|
||||
canEdit?: boolean
|
||||
}
|
||||
|
||||
export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapter, textbookId, canEdit = true }: ChapterSidebarListProps) {
|
||||
export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapter, textbookId, canEdit = false }: ChapterSidebarListProps) {
|
||||
const t = useTranslations("textbooks")
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
||||
const [createParentId, setCreateParentId] = useState<string | undefined>(undefined)
|
||||
@@ -239,11 +239,11 @@ export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapte
|
||||
if (result.success) {
|
||||
toast.success(t("dialog.chapter.orderUpdated"))
|
||||
} else {
|
||||
toast.error(result.message || t("dialog.chapter.orderUpdated"))
|
||||
toast.error(result.message || t("dialog.chapter.orderUpdateFailed"))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to reorder chapters", e)
|
||||
toast.error(t("dialog.chapter.orderUpdated"))
|
||||
toast.error(t("dialog.chapter.orderUpdateFailed"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ export function CreateChapterDialog({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
{triggerNode ? <DialogTrigger asChild>{triggerNode}</DialogTrigger> : null}
|
||||
{/* 任意值 sm:max-w-[425px]:shadcn/ui Dialog 标准宽度约定 */}
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("dialog.chapter.createTitle")}</DialogTitle>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTranslations } from "next-intl"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import type { GraphNodeData, MasteryLevel } from "../types"
|
||||
import type { GraphLayoutNodeData } from "../graph-layout"
|
||||
import { NODE_WIDTH } from "../graph-layout"
|
||||
|
||||
/** 根据掌握度计算色彩等级 */
|
||||
function getMasteryLevel(mastery: number | null): MasteryLevel {
|
||||
@@ -47,12 +48,13 @@ function GraphKpNodeComponent({ data, selected }: NodeProps) {
|
||||
graphData?.isHighlighted && "ring-2 ring-primary",
|
||||
!graphData?.isHighlighted && graphData !== undefined && "opacity-40",
|
||||
)}
|
||||
style={{ width: 180 }}
|
||||
style={{ width: NODE_WIDTH }}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} className="opacity-0" />
|
||||
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<span className="text-xs font-medium line-clamp-2 flex-1">{kp.name}</span>
|
||||
{/* 任意值 text-[10px]:图谱节点空间受限,text-xs(12px) 过大 */}
|
||||
{kp.questionCount > 0 && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary shrink-0">
|
||||
{kp.questionCount} {t("graph.node.questions")}
|
||||
|
||||
@@ -15,7 +15,6 @@ interface GraphNodeDetailPanelProps {
|
||||
prerequisites: { id: string; name: string; description: string | null }[]
|
||||
successors: { id: string; name: string; description: string | null }[]
|
||||
canEdit: boolean
|
||||
textbookId: string
|
||||
onClose: () => void
|
||||
onJumpToKp: (kpId: string) => void
|
||||
onAddPrerequisite: () => void
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Search, RotateCcw } from "lucide-react"
|
||||
import { Search, RotateCcw, Loader2 } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import {
|
||||
@@ -20,6 +20,8 @@ interface GraphToolbarProps {
|
||||
searchText: string
|
||||
onSearchChange: (text: string) => void
|
||||
onResetView: () => void
|
||||
/** 切换模式刷新中(显示轻量指示器,保留旧数据) */
|
||||
isRefreshing?: boolean
|
||||
}
|
||||
|
||||
const ALL_VIEW_MODES: readonly GraphViewMode[] = [
|
||||
@@ -39,6 +41,7 @@ export function GraphToolbar({
|
||||
searchText,
|
||||
onSearchChange,
|
||||
onResetView,
|
||||
isRefreshing,
|
||||
}: GraphToolbarProps) {
|
||||
const t = useTranslations("textbooks")
|
||||
|
||||
@@ -67,6 +70,7 @@ export function GraphToolbar({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 任意值 min-w-[120px]:搜索框最小宽度,防止压缩过窄无法输入 */}
|
||||
<div className="relative flex-1 min-w-[120px]">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -81,6 +85,10 @@ export function GraphToolbar({
|
||||
<RotateCcw className="h-3 w-3 mr-1" />
|
||||
<span className="text-xs">{t("graph.toolbar.resetView")}</span>
|
||||
</Button>
|
||||
|
||||
{isRefreshing && (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" aria-label={t("graph.toolbar.refreshing")} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -66,7 +66,6 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know
|
||||
const t = useTranslations("textbooks")
|
||||
const { hasPermission } = usePermission()
|
||||
const canEdit = hasPermission(Permissions.TEXTBOOK_UPDATE)
|
||||
const isTeacher = hasPermission(Permissions.TEXTBOOK_UPDATE)
|
||||
const reactFlow = useReactFlow()
|
||||
|
||||
const [viewMode, setViewMode] = useState<GraphViewMode>(initialViewMode)
|
||||
@@ -77,9 +76,10 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know
|
||||
const [newPrereqId, setNewPrereqId] = useState<string>("")
|
||||
const [isSavingPrereq, setIsSavingPrereq] = useState(false)
|
||||
|
||||
const { data, isLoading, error, reload } = useGraphData(textbookId, viewMode)
|
||||
const { data, isLoading, isRefreshing, error, reload } = useGraphData(textbookId, viewMode)
|
||||
|
||||
const availableViewModes: GraphViewMode[] = isTeacher
|
||||
// 教师可查看班级掌握度,学生可查看个人掌握度
|
||||
const availableViewModes: GraphViewMode[] = canEdit
|
||||
? ["structure", "class-mastery"]
|
||||
: ["structure", "student-mastery"]
|
||||
|
||||
@@ -286,6 +286,7 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know
|
||||
searchText={searchText}
|
||||
onSearchChange={setSearchText}
|
||||
onResetView={resetView}
|
||||
isRefreshing={isRefreshing}
|
||||
/>
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<ReactFlow
|
||||
@@ -315,6 +316,7 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know
|
||||
</div>
|
||||
|
||||
{selectedKp && (
|
||||
// 任意值 w-[300px]:详情面板固定宽度,保证内容可读性
|
||||
<div className="w-[300px] shrink-0">
|
||||
<GraphNodeDetailPanel
|
||||
kp={selectedKp}
|
||||
@@ -322,7 +324,6 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know
|
||||
prerequisites={prerequisites}
|
||||
successors={successors}
|
||||
canEdit={canEdit}
|
||||
textbookId={textbookId}
|
||||
onClose={() => setSelectedKpId(null)}
|
||||
onJumpToKp={onJumpToKp}
|
||||
onAddPrerequisite={() => setAddPrereqOpen(true)}
|
||||
|
||||
@@ -144,6 +144,7 @@ export function KnowledgePointDialogs({
|
||||
className="text-sm font-mono"
|
||||
required
|
||||
/>
|
||||
{/* 任意值 text-[10px]:辅助提示文案,text-xs(12px) 过大 */}
|
||||
<p className="text-[10px] text-muted-foreground mt-1">{t("anchorTextHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { PlusCircle, Pencil, Trash2 } from "lucide-react"
|
||||
import { PlusCircle, Pencil, Trash2, Tag } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import type { KnowledgePoint } from "../types"
|
||||
@@ -8,6 +8,7 @@ import { cn } from "@/shared/lib/utils"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
interface KnowledgePointListProps {
|
||||
knowledgePoints: KnowledgePoint[]
|
||||
@@ -35,9 +36,12 @@ export function KnowledgePointList({
|
||||
|
||||
if (knowledgePoints.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
|
||||
{t("reader.emptyKnowledge")}
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={Tag}
|
||||
title={t("reader.emptyKnowledge")}
|
||||
description={t("reader.emptyKnowledgeDesc")}
|
||||
className="h-full border-none shadow-none bg-transparent"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -57,6 +61,7 @@ export function KnowledgePointList({
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h4 className="text-sm font-medium leading-none">{kp.name}</h4>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* 任意值 text-[10px]:紧凑徽章,text-xs(12px) 在列表项中过大 */}
|
||||
<Badge variant="outline" className="text-[10px] h-5 px-1">
|
||||
{t("panel.level")}
|
||||
{kp.level}
|
||||
|
||||
@@ -44,20 +44,25 @@ export class TextbookSectionErrorBoundary extends Component<
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
// 默认值为空字符串,强制调用方传入 i18n 文案
|
||||
const title = this.props.fallbackTitle ?? ""
|
||||
const description = this.props.fallbackDescription ?? ""
|
||||
const retryLabel = this.props.retryLabel ?? ""
|
||||
return (
|
||||
// 任意值 min-h-[200px]:错误降级 UI 最小高度,保证视觉占位
|
||||
<div className="flex h-full min-h-[200px] flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<AlertCircle className="h-8 w-8 text-muted-foreground" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{this.props.fallbackTitle ?? "区块加载失败"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{this.props.fallbackDescription ?? "请重试或刷新页面"}
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={this.handleReset}>
|
||||
{this.props.retryLabel ?? "重试"}
|
||||
</Button>
|
||||
{title && (
|
||||
<p className="text-sm font-medium text-foreground">{title}</p>
|
||||
)}
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
)}
|
||||
{retryLabel && (
|
||||
<Button size="sm" variant="outline" onClick={this.handleReset}>
|
||||
{retryLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { TextbookReader, type TextbookReaderProps } from "./textbook-reader"
|
||||
import { CreateQuestionDialog } from "@/modules/questions/components/create-question-dialog"
|
||||
import type { KnowledgePoint } from "../types"
|
||||
|
||||
/**
|
||||
* 教师端 TextbookReader 包装组件。
|
||||
*
|
||||
* 教师详情页是 Server Component,不能直接向 Client Component(TextbookReader)
|
||||
* 传递函数 prop(renderQuestionCreator)。此包装组件在客户端层组装
|
||||
* renderQuestionCreator,避免违反 Next.js App Router 的 Server→Client 序列化约束。
|
||||
*/
|
||||
export function TeacherTextbookReader({
|
||||
chapters,
|
||||
textbookId,
|
||||
}: {
|
||||
chapters: TextbookReaderProps["chapters"]
|
||||
textbookId: string
|
||||
}) {
|
||||
const t = useTranslations("textbooks")
|
||||
const renderQuestionCreator: TextbookReaderProps["renderQuestionCreator"] = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
targetKp,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
targetKp: KnowledgePoint | null
|
||||
}): ReactNode => (
|
||||
<CreateQuestionDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
defaultKnowledgePointIds={targetKp ? [targetKp.id] : []}
|
||||
defaultContent={targetKp ? t("reader.questionCreatorDefaultContent", { name: targetKp.name }) : ""}
|
||||
defaultType="text"
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<TextbookReader
|
||||
key={textbookId}
|
||||
chapters={chapters}
|
||||
textbookId={textbookId}
|
||||
renderQuestionCreator={renderQuestionCreator}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -103,6 +103,7 @@ export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardPr
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Building2 className="h-3.5 w-3.5" />
|
||||
{/* 任意值 max-w-[120px]:出版社名称截断宽度,防止卡片布局错乱 */}
|
||||
<span className="truncate max-w-[120px]" title={textbook.publisher || ""}>
|
||||
{textbook.publisher || t("card.publisherNA")}
|
||||
</span>
|
||||
|
||||
@@ -4,13 +4,14 @@ import ReactMarkdown from "react-markdown"
|
||||
import remarkBreaks from "remark-breaks"
|
||||
import remarkGfm from "remark-gfm"
|
||||
import rehypeSanitize from "rehype-sanitize"
|
||||
import { Edit2, Save, Plus } from "lucide-react"
|
||||
import { Edit2, Save, Plus, BookOpen } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import type { Chapter, KnowledgePoint } from "../types"
|
||||
import type { Chapter } from "../types"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@@ -25,7 +26,6 @@ interface TextbookContentPanelProps {
|
||||
editContent: string
|
||||
setEditContent: (content: string) => void
|
||||
canEdit: boolean
|
||||
knowledgePoints: KnowledgePoint[]
|
||||
highlightedKpId: string | null
|
||||
onHighlight: (id: string) => void
|
||||
onSwitchToKnowledgeTab: () => void
|
||||
@@ -33,10 +33,7 @@ interface TextbookContentPanelProps {
|
||||
onPointerDown: (e: React.PointerEvent) => void
|
||||
onContextMenuChange: (open: boolean) => void
|
||||
selectedText: string
|
||||
createDialogOpen: boolean
|
||||
setCreateDialogOpen: (open: boolean) => void
|
||||
isCreating: boolean
|
||||
onCreateKnowledgePoint: (formData: FormData) => Promise<boolean | void>
|
||||
startEditing: () => void
|
||||
cancelEditing: () => void
|
||||
saveContent: () => void
|
||||
@@ -68,9 +65,12 @@ export function TextbookContentPanel({
|
||||
|
||||
if (!selected) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground px-2">
|
||||
{t("reader.selectChapter")}
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={BookOpen}
|
||||
title={t("reader.selectChapter")}
|
||||
description={t("reader.selectChapterDesc")}
|
||||
className="h-full border-none shadow-none bg-transparent"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ export function TextbookContentPanel({
|
||||
<RichTextEditor
|
||||
value={editContent}
|
||||
onChange={setEditContent}
|
||||
// 任意值 min-h-[500px]:编辑器最小高度,保证可编辑区域可用空间
|
||||
className="min-h-[500px] border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
@@ -129,6 +130,10 @@ export function TextbookContentPanel({
|
||||
return (
|
||||
<span
|
||||
data-kp-id={id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t("reader.clickToViewKp")}
|
||||
aria-pressed={isHighlighted}
|
||||
className={cn(
|
||||
"font-medium text-primary cursor-pointer hover:underline decoration-dashed underline-offset-4 transition-all duration-300",
|
||||
isHighlighted && "bg-yellow-300 dark:bg-yellow-600 text-black dark:text-white rounded px-1 py-0.5 shadow-sm scale-110 inline-block mx-0.5 font-bold ring-2 ring-yellow-400/50 dark:ring-yellow-500/50"
|
||||
@@ -138,6 +143,13 @@ export function TextbookContentPanel({
|
||||
onHighlight(id)
|
||||
onSwitchToKnowledgeTab()
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
onHighlight(id)
|
||||
onSwitchToKnowledgeTab()
|
||||
}
|
||||
}}
|
||||
title={t("reader.clickToViewKp")}
|
||||
>
|
||||
{children}
|
||||
@@ -152,9 +164,12 @@ export function TextbookContentPanel({
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground italic py-8 text-center">
|
||||
{t("reader.emptyContent")}
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={BookOpen}
|
||||
title={t("reader.emptyContent")}
|
||||
description={t("reader.emptyContentDesc")}
|
||||
className="h-64 border-none shadow-none bg-transparent"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
@@ -39,6 +39,7 @@ export function TextbookFilters() {
|
||||
|
||||
<div className="flex flex-wrap gap-2 w-full md:w-auto">
|
||||
<Select value={subject} onValueChange={(val) => setSubject(val === "all" ? null : val)}>
|
||||
{/* 任意值 w-[140px]:学科选择器固定宽度,保证触发器不随内容变化 */}
|
||||
<SelectTrigger className="w-[140px] bg-background border-muted-foreground/20">
|
||||
<SelectValue placeholder={t("field.subject")} />
|
||||
</SelectTrigger>
|
||||
@@ -53,6 +54,7 @@ export function TextbookFilters() {
|
||||
</Select>
|
||||
|
||||
<Select value={grade} onValueChange={(val) => setGrade(val === "all" ? null : val)}>
|
||||
{/* 任意值 w-[130px]:年级选择器固定宽度,保证触发器不随内容变化 */}
|
||||
<SelectTrigger className="w-[130px] bg-background border-muted-foreground/20">
|
||||
<SelectValue placeholder={t("field.grade")} />
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -64,6 +64,7 @@ export function TextbookFormDialog() {
|
||||
{t("list.add")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
{/* 任意值 sm:max-w-[425px]:shadcn/ui Dialog 标准宽度约定 */}
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("dialog.create.title")}</DialogTitle>
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import { useMemo, useState, useEffect, useRef, type ReactNode } from "react"
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
import { Tag, List, Share2, Menu } from "lucide-react"
|
||||
import { Tag, List, Share2, Menu, GraduationCap } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import Link from "next/link"
|
||||
|
||||
import type { Chapter, KnowledgePoint } from "../types"
|
||||
import { updateChapterContentAction, getKnowledgePointsByChapterAction } from "../actions"
|
||||
@@ -77,6 +78,7 @@ export function TextbookReader({
|
||||
// P0-2 前端权限改由 usePermission 判断,不再接受外部 canEdit 硬编码
|
||||
const canEdit = hasPermission(Permissions.TEXTBOOK_UPDATE)
|
||||
const canCreateQuestion = hasPermission(Permissions.QUESTION_CREATE)
|
||||
const canCreateLessonPlan = hasPermission(Permissions.LESSON_PLAN_CREATE)
|
||||
|
||||
const [chapterId, setChapterId] = useQueryState("chapterId", parseAsString.withDefault(""))
|
||||
const [activeTab, setActiveTab] = useState("chapters")
|
||||
@@ -249,6 +251,7 @@ export function TextbookReader({
|
||||
<TabsTrigger value="knowledge" className="gap-2" disabled={!selectedId}>
|
||||
<Tag className="h-4 w-4" />
|
||||
{t("reader.tabs.knowledge")}
|
||||
{/* 任意值 text-[10px]:紧凑徽章,text-xs(12px) 在标签栏中过大 */}
|
||||
{currentChapterKPs.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1 px-1 py-0 h-5 text-[10px]">
|
||||
{currentChapterKPs.length}
|
||||
@@ -341,6 +344,7 @@ export function TextbookReader({
|
||||
|
||||
{/* P2-4 移动端侧栏:lg 以下用 Sheet 抽屉式展示 */}
|
||||
<Sheet open={mobileSidebarOpen} onOpenChange={setMobileSidebarOpen}>
|
||||
{/* 任意值 w-[85vw]:移动端抽屉占视口宽度,max-w-sm 防止超宽屏过大 */}
|
||||
<SheetContent side="left" className="w-[85vw] max-w-sm p-0 flex flex-col">
|
||||
<SheetHeader className="px-4 py-3 border-b shrink-0">
|
||||
<SheetTitle className="text-left">{t("reader.sidebar")}</SheetTitle>
|
||||
@@ -350,12 +354,13 @@ export function TextbookReader({
|
||||
</Sheet>
|
||||
|
||||
<div className="lg:col-span-8 flex flex-col min-h-0 relative">
|
||||
{/* P2-4 移动端侧栏触发按钮 */}
|
||||
<div className="lg:hidden flex items-center gap-2 mb-3 px-2 shrink-0">
|
||||
{/* P2-4 移动端侧栏触发按钮 + 为此课文备课按钮 */}
|
||||
<div className="flex items-center gap-2 mb-3 px-2 shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setMobileSidebarOpen(true)}
|
||||
className="lg:hidden"
|
||||
aria-expanded={mobileSidebarOpen}
|
||||
aria-controls="mobile-sidebar-sheet"
|
||||
>
|
||||
@@ -363,10 +368,20 @@ export function TextbookReader({
|
||||
{t("reader.openSidebar")}
|
||||
</Button>
|
||||
{selected && (
|
||||
<span className="text-sm text-muted-foreground truncate">
|
||||
<span className="text-sm text-muted-foreground truncate hidden lg:inline">
|
||||
{selected.title}
|
||||
</span>
|
||||
)}
|
||||
{selected && canCreateLessonPlan && (
|
||||
<Button asChild variant="default" size="sm" className="ml-auto">
|
||||
<Link
|
||||
href={`/teacher/lesson-plans/new?textbookId=${encodeURIComponent(textbookId)}&chapterId=${encodeURIComponent(selected.id)}`}
|
||||
>
|
||||
<GraduationCap className="mr-2 h-4 w-4" />
|
||||
{t("reader.prepareLesson")}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
|
||||
|
||||
@@ -99,6 +99,7 @@ export function TextbookSettingsDialog({ textbook, trigger }: TextbookSettingsDi
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
{/* 任意值 sm:max-w-[425px]:shadcn/ui Dialog 标准宽度约定 */}
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("dialog.settings.title")}</DialogTitle>
|
||||
|
||||
@@ -57,14 +57,18 @@ export const getKnowledgePointsWithRelations = cache(async (
|
||||
questionCountMap.set(r.knowledgePointId, Number(r.count))
|
||||
}
|
||||
|
||||
// 3. 查询前置依赖(批量)
|
||||
// 3. 查询前置依赖(批量,仅查询属于当前教材知识点的依赖)
|
||||
// 双向过滤:knowledgePointId 和 prerequisiteKpId 都必须在当前教材的知识点集合内
|
||||
const prereqRows = await db
|
||||
.select({
|
||||
knowledgePointId: knowledgePointPrerequisites.knowledgePointId,
|
||||
prerequisiteKpId: knowledgePointPrerequisites.prerequisiteKpId,
|
||||
})
|
||||
.from(knowledgePointPrerequisites)
|
||||
.where(inArray(knowledgePointPrerequisites.knowledgePointId, kpIds))
|
||||
.where(and(
|
||||
inArray(knowledgePointPrerequisites.knowledgePointId, kpIds),
|
||||
inArray(knowledgePointPrerequisites.prerequisiteKpId, kpIds),
|
||||
))
|
||||
|
||||
const prereqMap = new Map<string, string[]>()
|
||||
for (const r of prereqRows) {
|
||||
|
||||
@@ -60,5 +60,69 @@ describe("textbooks/graph-layout", () => {
|
||||
expect(node.position.y).toBeGreaterThanOrEqual(0)
|
||||
}
|
||||
})
|
||||
|
||||
it("should not create edge for non-existent parentId", () => {
|
||||
const layout = computeGraphLayout([makeKp("1", "nonexistent")])
|
||||
expect(layout.nodes).toHaveLength(1)
|
||||
expect(layout.edges).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should not create edge for non-existent prerequisiteIds", () => {
|
||||
const layout = computeGraphLayout([makeKp("1", null, ["nonexistent"])])
|
||||
expect(layout.nodes).toHaveLength(1)
|
||||
expect(layout.edges).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should generate both parent and prerequisite edges for one node", () => {
|
||||
const layout = computeGraphLayout([
|
||||
makeKp("1"),
|
||||
makeKp("2"),
|
||||
makeKp("3", "1", ["2"]),
|
||||
])
|
||||
const parentEdge = layout.edges.find((e) => e.id === "parent-1-3")
|
||||
const prereqEdge = layout.edges.find((e) => e.id === "prereq-2-3")
|
||||
expect(parentEdge).toBeDefined()
|
||||
expect(prereqEdge).toBeDefined()
|
||||
expect(layout.edges).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("should return positive width/height for non-empty graph", () => {
|
||||
const layout = computeGraphLayout([
|
||||
makeKp("1"),
|
||||
makeKp("2", "1"),
|
||||
makeKp("3", "1"),
|
||||
])
|
||||
expect(layout.width).toBeGreaterThan(0)
|
||||
expect(layout.height).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("should handle deeply nested parent chain (boundary)", () => {
|
||||
const depth = 20
|
||||
const kps: KpWithRelations[] = [makeKp("0")]
|
||||
for (let i = 1; i < depth; i++) {
|
||||
kps.push(makeKp(String(i), String(i - 1)))
|
||||
}
|
||||
const layout = computeGraphLayout(kps)
|
||||
expect(layout.nodes).toHaveLength(depth)
|
||||
// 应生成 depth-1 条 parent 边
|
||||
const parentEdges = layout.edges.filter((e) => e.id.startsWith("parent-"))
|
||||
expect(parentEdges).toHaveLength(depth - 1)
|
||||
})
|
||||
|
||||
it("should handle many nodes (boundary, 50 nodes)", () => {
|
||||
const count = 50
|
||||
const kps: KpWithRelations[] = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
kps.push(makeKp(`n${i}`))
|
||||
}
|
||||
const layout = computeGraphLayout(kps)
|
||||
expect(layout.nodes).toHaveLength(count)
|
||||
expect(layout.edges).toHaveLength(0)
|
||||
// 所有节点都应有有效位置
|
||||
for (const node of layout.nodes) {
|
||||
expect(Number.isFinite(node.position.x)).toBe(true)
|
||||
expect(Number.isFinite(node.position.y)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,7 +6,10 @@ import type { GraphViewMode, KnowledgeGraphData } from "../types"
|
||||
|
||||
interface UseGraphDataResult {
|
||||
data: KnowledgeGraphData | null
|
||||
/** 首次加载(无任何数据时) */
|
||||
isLoading: boolean
|
||||
/** 切换模式时的刷新(保留旧数据,显示轻量指示器) */
|
||||
isRefreshing: boolean
|
||||
error: string | null
|
||||
reload: () => void
|
||||
}
|
||||
@@ -15,7 +18,8 @@ interface UseGraphDataResult {
|
||||
* 图谱数据加载 Hook。
|
||||
*
|
||||
* 按 textbookId + viewMode 加载,切换 viewMode 时重新加载。
|
||||
* 使用派生值模式(isLoading 从 data.viewMode 派生),避免 effect 中同步 setState。
|
||||
* 区分 isLoading(首次加载)和 isRefreshing(切换模式刷新),
|
||||
* 切换模式时保留旧数据避免 UI 闪烁。
|
||||
*/
|
||||
export function useGraphData(
|
||||
textbookId: string,
|
||||
@@ -30,8 +34,10 @@ export function useGraphData(
|
||||
setReloadTrigger((n) => n + 1)
|
||||
}, [])
|
||||
|
||||
// 派生 loading 状态:无数据或当前数据不匹配请求的 viewMode
|
||||
const isLoading = data === null || data.viewMode !== viewMode
|
||||
// 首次加载:完全无数据
|
||||
const isLoading = data === null && error === null
|
||||
// 切换模式刷新:有旧数据但 viewMode 不匹配
|
||||
const isRefreshing = data !== null && data.viewMode !== viewMode
|
||||
|
||||
useEffect(() => {
|
||||
if (!textbookId) return
|
||||
@@ -50,13 +56,14 @@ export function useGraphData(
|
||||
setError(null)
|
||||
} else {
|
||||
setData(null)
|
||||
setError(result.message ?? "Unknown error")
|
||||
// 错误消息使用通用 key,由组件层翻译
|
||||
setError(result.message ?? "graph.error.loadFailed")
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled) {
|
||||
setData(null)
|
||||
setError(e instanceof Error ? e.message : "Unknown error")
|
||||
setError(e instanceof Error ? e.message : "graph.error.loadFailed")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -65,5 +72,5 @@ export function useGraphData(
|
||||
}
|
||||
}, [textbookId, viewMode, reloadTrigger])
|
||||
|
||||
return { data, isLoading, error, reload }
|
||||
return { data, isLoading, error, reload, isRefreshing }
|
||||
}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import type { KnowledgePoint } from "../types"
|
||||
import {
|
||||
createKnowledgePointAction,
|
||||
deleteKnowledgePointAction,
|
||||
updateKnowledgePointAction,
|
||||
} from "../actions"
|
||||
import { useKpDialogState } from "./use-kp-dialog-state"
|
||||
import { useKpCrud } from "./use-kp-crud"
|
||||
|
||||
/**
|
||||
* 知识点操作 Hook(门面)。
|
||||
*
|
||||
* 组合 useKpDialogState(对话框状态)和 useKpCrud(CRUD 操作),
|
||||
* 对外保持原有 API 不变,避免调用方修改。
|
||||
*/
|
||||
export function useKnowledgePointActions(
|
||||
textbookId: string | undefined,
|
||||
selectedChapterId: string | null,
|
||||
@@ -19,105 +17,33 @@ export function useKnowledgePointActions(
|
||||
setHighlightedKpId: (id: string | null) => void,
|
||||
onKpCreated?: () => void,
|
||||
) {
|
||||
const t = useTranslations("textbooks")
|
||||
const [editingKp, setEditingKp] = useState<KnowledgePoint | null>(null)
|
||||
const [editKpDialogOpen, setEditKpDialogOpen] = useState(false)
|
||||
const [isUpdatingKp, setIsUpdatingKp] = useState(false)
|
||||
const dialog = useKpDialogState()
|
||||
|
||||
const [questionDialogOpen, setQuestionDialogOpen] = useState(false)
|
||||
const [targetKpForQuestion, setTargetKpForQuestion] = useState<KnowledgePoint | null>(null)
|
||||
|
||||
const handleCreateKnowledgePoint = async (formData: FormData) => {
|
||||
if (!selectedChapterId || !selectedChapterTextbookId) return
|
||||
|
||||
try {
|
||||
const result = await createKnowledgePointAction(
|
||||
selectedChapterId,
|
||||
selectedChapterTextbookId,
|
||||
null,
|
||||
formData,
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
toast.success(t("action.kpCreateSuccess"))
|
||||
onKpCreated?.()
|
||||
window.getSelection()?.removeAllRanges()
|
||||
return true
|
||||
} else {
|
||||
toast.error(result.message || t("action.kpCreateFailed"))
|
||||
return false
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("action.errorOccurred"))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
|
||||
const [pendingDeleteKpId, setPendingDeleteKpId] = useState<string | null>(null)
|
||||
|
||||
const requestDeleteKnowledgePoint = (kpId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setPendingDeleteKpId(kpId)
|
||||
setDeleteConfirmOpen(true)
|
||||
}
|
||||
|
||||
const confirmDeleteKnowledgePoint = async () => {
|
||||
if (!pendingDeleteKpId || !textbookId) return
|
||||
setDeleteConfirmOpen(false)
|
||||
|
||||
try {
|
||||
const result = await deleteKnowledgePointAction(pendingDeleteKpId, textbookId)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
if (highlightedKpId === pendingDeleteKpId) {
|
||||
setHighlightedKpId(null)
|
||||
}
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("action.deleteFailed"))
|
||||
} finally {
|
||||
setPendingDeleteKpId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateKnowledgePoint = async (formData: FormData) => {
|
||||
if (!editingKp || !textbookId) return
|
||||
setIsUpdatingKp(true)
|
||||
|
||||
try {
|
||||
const result = await updateKnowledgePointAction(editingKp.id, textbookId, null, formData)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
setEditKpDialogOpen(false)
|
||||
setEditingKp(null)
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("action.updateFailedGeneric"))
|
||||
} finally {
|
||||
setIsUpdatingKp(false)
|
||||
}
|
||||
}
|
||||
const crud = useKpCrud({
|
||||
textbookId,
|
||||
selectedChapterId,
|
||||
selectedChapterTextbookId,
|
||||
highlightedKpId,
|
||||
setHighlightedKpId,
|
||||
onKpCreated,
|
||||
dialog,
|
||||
})
|
||||
|
||||
return {
|
||||
editingKp,
|
||||
setEditingKp,
|
||||
editKpDialogOpen,
|
||||
setEditKpDialogOpen,
|
||||
isUpdatingKp,
|
||||
questionDialogOpen,
|
||||
setQuestionDialogOpen,
|
||||
targetKpForQuestion,
|
||||
setTargetKpForQuestion,
|
||||
deleteConfirmOpen,
|
||||
setDeleteConfirmOpen,
|
||||
handleCreateKnowledgePoint,
|
||||
requestDeleteKnowledgePoint,
|
||||
confirmDeleteKnowledgePoint,
|
||||
handleUpdateKnowledgePoint,
|
||||
editingKp: dialog.editingKp,
|
||||
setEditingKp: dialog.setEditingKp,
|
||||
editKpDialogOpen: dialog.editKpDialogOpen,
|
||||
setEditKpDialogOpen: dialog.setEditKpDialogOpen,
|
||||
isUpdatingKp: dialog.isUpdatingKp,
|
||||
questionDialogOpen: dialog.questionDialogOpen,
|
||||
setQuestionDialogOpen: dialog.setQuestionDialogOpen,
|
||||
targetKpForQuestion: dialog.targetKpForQuestion,
|
||||
setTargetKpForQuestion: dialog.setTargetKpForQuestion,
|
||||
deleteConfirmOpen: dialog.deleteConfirmOpen,
|
||||
setDeleteConfirmOpen: dialog.setDeleteConfirmOpen,
|
||||
handleCreateKnowledgePoint: crud.handleCreateKnowledgePoint,
|
||||
requestDeleteKnowledgePoint: crud.requestDeleteKnowledgePoint,
|
||||
confirmDeleteKnowledgePoint: crud.confirmDeleteKnowledgePoint,
|
||||
handleUpdateKnowledgePoint: crud.handleUpdateKnowledgePoint,
|
||||
}
|
||||
}
|
||||
|
||||
122
src/modules/textbooks/hooks/use-kp-crud.ts
Normal file
122
src/modules/textbooks/hooks/use-kp-crud.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import type { KnowledgePoint } from "../types"
|
||||
import {
|
||||
createKnowledgePointAction,
|
||||
deleteKnowledgePointAction,
|
||||
updateKnowledgePointAction,
|
||||
} from "../actions"
|
||||
import type { useKpDialogState } from "./use-kp-dialog-state"
|
||||
|
||||
type DialogState = ReturnType<typeof useKpDialogState>
|
||||
|
||||
interface UseKpCrudArgs {
|
||||
textbookId: string | undefined
|
||||
selectedChapterId: string | null
|
||||
selectedChapterTextbookId: string | undefined
|
||||
highlightedKpId: string | null
|
||||
setHighlightedKpId: (id: string | null) => void
|
||||
onKpCreated?: () => void
|
||||
dialog: DialogState
|
||||
}
|
||||
|
||||
/**
|
||||
* 知识点 CRUD 操作 Hook。
|
||||
*
|
||||
* 依赖 useKpDialogState 提供的对话框状态,执行创建/更新/删除操作。
|
||||
*/
|
||||
export function useKpCrud({
|
||||
textbookId,
|
||||
selectedChapterId,
|
||||
selectedChapterTextbookId,
|
||||
highlightedKpId,
|
||||
setHighlightedKpId,
|
||||
onKpCreated,
|
||||
dialog,
|
||||
}: UseKpCrudArgs) {
|
||||
const t = useTranslations("textbooks")
|
||||
|
||||
const handleCreateKnowledgePoint = async (formData: FormData): Promise<boolean> => {
|
||||
if (!selectedChapterId || !selectedChapterTextbookId) return false
|
||||
|
||||
try {
|
||||
const result = await createKnowledgePointAction(
|
||||
selectedChapterId,
|
||||
selectedChapterTextbookId,
|
||||
null,
|
||||
formData,
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
toast.success(t("action.kpCreateSuccess"))
|
||||
onKpCreated?.()
|
||||
window.getSelection()?.removeAllRanges()
|
||||
return true
|
||||
}
|
||||
toast.error(result.message || t("action.kpCreateFailed"))
|
||||
return false
|
||||
} catch {
|
||||
toast.error(t("action.errorOccurred"))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const requestDeleteKnowledgePoint = (kpId: string, e: React.MouseEvent): void => {
|
||||
e.stopPropagation()
|
||||
dialog.setPendingDeleteKpId(kpId)
|
||||
dialog.setDeleteConfirmOpen(true)
|
||||
}
|
||||
|
||||
const confirmDeleteKnowledgePoint = async (): Promise<void> => {
|
||||
if (!dialog.pendingDeleteKpId || !textbookId) return
|
||||
dialog.setDeleteConfirmOpen(false)
|
||||
|
||||
try {
|
||||
const result = await deleteKnowledgePointAction(dialog.pendingDeleteKpId, textbookId)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
if (highlightedKpId === dialog.pendingDeleteKpId) {
|
||||
setHighlightedKpId(null)
|
||||
}
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("action.deleteFailed"))
|
||||
} finally {
|
||||
dialog.setPendingDeleteKpId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateKnowledgePoint = async (formData: FormData): Promise<void> => {
|
||||
if (!dialog.editingKp || !textbookId) return
|
||||
dialog.setIsUpdatingKp(true)
|
||||
|
||||
try {
|
||||
const result = await updateKnowledgePointAction(dialog.editingKp.id, textbookId, null, formData)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
dialog.setEditKpDialogOpen(false)
|
||||
dialog.setEditingKp(null)
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("action.updateFailedGeneric"))
|
||||
} finally {
|
||||
dialog.setIsUpdatingKp(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleCreateKnowledgePoint,
|
||||
requestDeleteKnowledgePoint,
|
||||
confirmDeleteKnowledgePoint,
|
||||
handleUpdateKnowledgePoint,
|
||||
}
|
||||
}
|
||||
|
||||
export type { KnowledgePoint }
|
||||
38
src/modules/textbooks/hooks/use-kp-dialog-state.ts
Normal file
38
src/modules/textbooks/hooks/use-kp-dialog-state.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import type { KnowledgePoint } from "../types"
|
||||
|
||||
/**
|
||||
* 知识点相关对话框状态管理 Hook。
|
||||
*
|
||||
* 管理:编辑对话框、题目创建对话框、删除确认对话框。
|
||||
*/
|
||||
export function useKpDialogState() {
|
||||
const [editingKp, setEditingKp] = useState<KnowledgePoint | null>(null)
|
||||
const [editKpDialogOpen, setEditKpDialogOpen] = useState(false)
|
||||
const [isUpdatingKp, setIsUpdatingKp] = useState(false)
|
||||
|
||||
const [questionDialogOpen, setQuestionDialogOpen] = useState(false)
|
||||
const [targetKpForQuestion, setTargetKpForQuestion] = useState<KnowledgePoint | null>(null)
|
||||
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
|
||||
const [pendingDeleteKpId, setPendingDeleteKpId] = useState<string | null>(null)
|
||||
|
||||
return {
|
||||
editingKp,
|
||||
setEditingKp,
|
||||
editKpDialogOpen,
|
||||
setEditKpDialogOpen,
|
||||
isUpdatingKp,
|
||||
setIsUpdatingKp,
|
||||
questionDialogOpen,
|
||||
setQuestionDialogOpen,
|
||||
targetKpForQuestion,
|
||||
setTargetKpForQuestion,
|
||||
deleteConfirmOpen,
|
||||
setDeleteConfirmOpen,
|
||||
pendingDeleteKpId,
|
||||
setPendingDeleteKpId,
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,8 @@ export const CreatePrerequisiteSchema = z.object({
|
||||
knowledgePointId: z.string().min(1),
|
||||
prerequisiteKpId: z.string().min(1),
|
||||
}).refine((data) => data.knowledgePointId !== data.prerequisiteKpId, {
|
||||
message: "知识点不能作为自己的前置",
|
||||
// Zod message 仅为开发期提示,Action 层返回的 t("invalidInput") 是用户可见文案
|
||||
message: "A knowledge point cannot be a prerequisite of itself",
|
||||
})
|
||||
|
||||
export type CreatePrerequisiteInput = z.infer<typeof CreatePrerequisiteSchema>
|
||||
|
||||
@@ -263,4 +263,23 @@ describe("textbooks/utils - cycle detection", () => {
|
||||
const edges: Array<[string, string]> = [["a", "b"], ["a", "c"], ["b", "d"], ["c", "d"]]
|
||||
expect(hasCycleAfterAddingEdge(edges, "d", "e")).toBe(false)
|
||||
})
|
||||
|
||||
it("should detect self-loop as cycle (a->a)", () => {
|
||||
expect(hasCycleAfterAddingEdge([], "a", "a")).toBe(true)
|
||||
})
|
||||
|
||||
it("should not detect cycle for duplicate edge (a->b already exists)", () => {
|
||||
const edges: Array<[string, string]> = [["a", "b"]]
|
||||
expect(hasCycleAfterAddingEdge(edges, "a", "b")).toBe(false)
|
||||
})
|
||||
|
||||
it("should detect longer indirect cycle (a->b->c->d then d->a)", () => {
|
||||
const edges: Array<[string, string]> = [["a", "b"], ["b", "c"], ["c", "d"]]
|
||||
expect(hasCycleAfterAddingEdge(edges, "d", "a")).toBe(true)
|
||||
})
|
||||
|
||||
it("should not detect cycle when adding edge to unrelated node", () => {
|
||||
const edges: Array<[string, string]> = [["a", "b"], ["b", "c"]]
|
||||
expect(hasCycleAfterAddingEdge(edges, "d", "e")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user