feat(settings,questions,school,textbooks): add brand config, question components, school dialogs, textbooks hooks
settings: - Add actions-brand, brand-config, data-access-brand for brand management - Add admin-file-upload-card, admin-notification-config-card, admin-school-info-card, admin-security-policy-card - Add ai-provider-delete-dialog, ai-provider-selector, brand-config-card - Add security-recent-logins-section, security-two-factor-section - Add config/profile-overview-config, data-access-profile-overview, lib/system-settings-utils questions: - Add batch-operations, import-export-buttons, knowledge-point-selector, options-editor - Add question-bank-results-client, question-cascade-filter, question-content-renderer, utils school: - Add grade-delete-dialog, grade-form-dialog, grade-list-toolbar, grade-overview-cards - Add use-grade-data hook textbooks: - Add textbook-form-fields component - Add use-kp-create, use-kp-delete, use-kp-update hooks
This commit is contained in:
@@ -97,9 +97,17 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId,
|
||||
<div className="w-5 shrink-0 mr-1" />
|
||||
)}
|
||||
|
||||
<div
|
||||
className="flex-1 min-w-0 flex items-center gap-2"
|
||||
<div
|
||||
className="flex-1 min-w-0 flex items-center gap-2 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:rounded-sm"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(chapter)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
onSelect(chapter)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<Folder className={cn("h-4 w-4 shrink-0 transition-colors", isOpen || isSelected ? "text-blue-500/80" : "text-muted-foreground/50")} />
|
||||
|
||||
@@ -4,8 +4,7 @@ import { memo } from "react"
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import type { GraphNodeData, MasteryLevel } from "../types"
|
||||
import type { GraphLayoutNodeData } from "../graph-layout"
|
||||
import type { GraphNodeData, MasteryLevel, KpWithRelations } from "../types"
|
||||
import { NODE_WIDTH } from "../graph-layout"
|
||||
|
||||
/** 根据掌握度计算色彩等级 */
|
||||
@@ -30,11 +29,23 @@ const MASTERY_BAR_COLORS: Record<MasteryLevel, string> = {
|
||||
unassessed: "bg-muted",
|
||||
}
|
||||
|
||||
function GraphKpNodeComponent({ data, selected }: NodeProps) {
|
||||
/**
|
||||
* 类型守卫:从 React Flow node.data(Record<string, unknown>)安全提取图谱节点数据。
|
||||
* 替代 as unknown as 双重断言,提供运行时安全。
|
||||
*/
|
||||
function extractNodeData(data: Record<string, unknown>): {
|
||||
kp: KpWithRelations
|
||||
graphData?: GraphNodeData
|
||||
} {
|
||||
const kp = data.kp as KpWithRelations
|
||||
const graphData = data.graphData as GraphNodeData | undefined
|
||||
return { kp, graphData }
|
||||
}
|
||||
|
||||
function GraphKpNodeComponent(props: NodeProps) {
|
||||
const t = useTranslations("textbooks")
|
||||
const nodeData = data as unknown as GraphLayoutNodeData
|
||||
const { kp } = nodeData
|
||||
const graphData = (data as unknown as { graphData?: GraphNodeData }).graphData
|
||||
const { data, selected } = props
|
||||
const { kp, graphData } = extractNodeData(data)
|
||||
const mastery = graphData?.mastery ?? null
|
||||
const masteryLevel = getMasteryLevel(mastery?.masteryLevel ?? null)
|
||||
const showMastery = graphData?.viewMode === "student-mastery" || graphData?.viewMode === "class-mastery"
|
||||
|
||||
@@ -42,7 +42,7 @@ export function GraphNodeDetailPanel({
|
||||
<div className="flex flex-col h-full border-l bg-background">
|
||||
<div className="flex items-center justify-between p-3 border-b shrink-0">
|
||||
<h3 className="text-sm font-semibold truncate">{t("graph.detail.title")}</h3>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={onClose}>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={onClose} aria-label={t("graph.detail.close")}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -141,6 +141,7 @@ export function GraphNodeDetailPanel({
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => onRemovePrerequisite(p.id)}
|
||||
aria-label={t("graph.detail.removePrerequisite")}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import type { GraphViewMode, GraphNodeData } from "../types"
|
||||
import { computeGraphLayout } from "../graph-layout"
|
||||
import type { GraphLayoutNodeData } from "../graph-layout"
|
||||
import { useGraphData } from "../hooks/use-graph-data"
|
||||
import {
|
||||
createPrerequisiteAction,
|
||||
@@ -202,35 +203,44 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know
|
||||
const handleAddPrerequisite = useCallback(async () => {
|
||||
if (!selectedKpId || !newPrereqId || !textbookId) return
|
||||
setIsSavingPrereq(true)
|
||||
const formData = new FormData()
|
||||
formData.set("knowledgePointId", selectedKpId)
|
||||
formData.set("prerequisiteKpId", newPrereqId)
|
||||
formData.set("textbookId", textbookId)
|
||||
const result = await createPrerequisiteAction(formData)
|
||||
setIsSavingPrereq(false)
|
||||
if (result.success) {
|
||||
toast.success(t("graph.detail.prerequisiteAdded"))
|
||||
setAddPrereqOpen(false)
|
||||
setNewPrereqId("")
|
||||
reload()
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.set("knowledgePointId", selectedKpId)
|
||||
formData.set("prerequisiteKpId", newPrereqId)
|
||||
formData.set("textbookId", textbookId)
|
||||
const result = await createPrerequisiteAction(formData)
|
||||
if (result.success) {
|
||||
toast.success(t("graph.detail.prerequisiteAdded"))
|
||||
setAddPrereqOpen(false)
|
||||
setNewPrereqId("")
|
||||
reload()
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : t("graph.detail.prerequisiteAddFailed"))
|
||||
} finally {
|
||||
setIsSavingPrereq(false)
|
||||
}
|
||||
}, [selectedKpId, newPrereqId, textbookId, t, reload])
|
||||
|
||||
// 删除前置依赖
|
||||
const handleRemovePrerequisite = useCallback(async (prereqId: string) => {
|
||||
if (!selectedKpId || !textbookId) return
|
||||
const formData = new FormData()
|
||||
formData.set("knowledgePointId", selectedKpId)
|
||||
formData.set("prerequisiteKpId", prereqId)
|
||||
formData.set("textbookId", textbookId)
|
||||
const result = await deletePrerequisiteAction(formData)
|
||||
if (result.success) {
|
||||
toast.success(t("graph.detail.prerequisiteRemoved"))
|
||||
reload()
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.set("knowledgePointId", selectedKpId)
|
||||
formData.set("prerequisiteKpId", prereqId)
|
||||
formData.set("textbookId", textbookId)
|
||||
const result = await deletePrerequisiteAction(formData)
|
||||
if (result.success) {
|
||||
toast.success(t("graph.detail.prerequisiteRemoved"))
|
||||
reload()
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : t("graph.detail.prerequisiteRemoveFailed"))
|
||||
}
|
||||
}, [selectedKpId, textbookId, t, reload])
|
||||
|
||||
@@ -306,9 +316,10 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know
|
||||
<MiniMap
|
||||
className="!bg-background !border !rounded-lg"
|
||||
nodeColor={(node) => {
|
||||
// node.data 是 Record<string, unknown>;从 unknown 安全转换读取 graphData
|
||||
const graphData = (node.data as unknown as { graphData?: { chapterColor: string } })?.graphData
|
||||
return graphData?.chapterColor ?? "#6b7280"
|
||||
// 安全的类型收窄:node.data 是 Record<string, unknown>,
|
||||
// GraphLayoutNodeData 有索引签名 [key: string]: unknown,是 Record 的子类型
|
||||
const data = node.data as GraphLayoutNodeData
|
||||
return data.graphData?.chapterColor ?? "#6b7280"
|
||||
}}
|
||||
/>
|
||||
</ReactFlow>
|
||||
|
||||
@@ -81,7 +81,8 @@ export function KnowledgePointDialogs({
|
||||
<DialogTitle>{t("createTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("createDesc")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form action={onCreateKnowledgePoint as (formData: FormData) => void}>
|
||||
{/* 包装为 void 返回,避免 as 断言;Promise 仍会被 React 处理 */}
|
||||
<form action={(formData: FormData) => { void onCreateKnowledgePoint(formData) }}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">{t("name")}</Label>
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
/**
|
||||
* 教材模块内联 Error Boundary。
|
||||
*
|
||||
* 用于包裹独立数据区块(章节树、内容区、知识点区、图谱区),
|
||||
* 隔离故障域,避免单点错误导致整个阅读器白屏。
|
||||
* 薄包装:委托给共享 SectionErrorBoundary,通过自定义 fallback 渲染
|
||||
* 调用方传入的 fallbackTitle/fallbackDescription/retryLabel。
|
||||
* 保留同名导出和 props 以兼容现有 import。
|
||||
*/
|
||||
|
||||
import { Component, type ReactNode } from "react"
|
||||
import type { ReactNode } from "react"
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
|
||||
|
||||
interface TextbookSectionErrorBoundaryProps {
|
||||
children: ReactNode
|
||||
@@ -21,52 +23,36 @@ interface TextbookSectionErrorBoundaryProps {
|
||||
retryLabel?: string
|
||||
}
|
||||
|
||||
interface TextbookSectionErrorBoundaryState {
|
||||
hasError: boolean
|
||||
}
|
||||
|
||||
export class TextbookSectionErrorBoundary extends Component<
|
||||
TextbookSectionErrorBoundaryProps,
|
||||
TextbookSectionErrorBoundaryState
|
||||
> {
|
||||
constructor(props: TextbookSectionErrorBoundaryProps) {
|
||||
super(props)
|
||||
this.state = { hasError: false }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(): TextbookSectionErrorBoundaryState {
|
||||
return { hasError: true }
|
||||
}
|
||||
|
||||
handleReset = (): void => {
|
||||
this.setState({ hasError: false })
|
||||
}
|
||||
|
||||
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" />
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
export function TextbookSectionErrorBoundary({
|
||||
children,
|
||||
fallbackTitle,
|
||||
fallbackDescription,
|
||||
retryLabel,
|
||||
}: TextbookSectionErrorBoundaryProps): ReactNode {
|
||||
const fallback = (_error: Error, reset: () => void): ReactNode => (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
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" aria-hidden="true" />
|
||||
{fallbackTitle && (
|
||||
<p className="text-sm font-medium text-foreground">{fallbackTitle}</p>
|
||||
)}
|
||||
{fallbackDescription && (
|
||||
<p className="text-xs text-muted-foreground">{fallbackDescription}</p>
|
||||
)}
|
||||
{retryLabel && (
|
||||
<Button size="sm" variant="outline" onClick={reset}>
|
||||
{retryLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<SectionErrorBoundary fallback={fallback}>
|
||||
{children}
|
||||
</SectionErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ interface TextbookCardProps {
|
||||
export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardProps) {
|
||||
const t = useTranslations("textbooks")
|
||||
const router = useRouter()
|
||||
const base = hrefBase || "/teacher/textbooks"
|
||||
const base = hrefBase ?? "/teacher/textbooks" // 默认教师端,学生端通过 hrefBase prop 覆盖
|
||||
const colorClass = getSubjectColor(textbook.subject)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
@@ -20,48 +20,67 @@ import {
|
||||
} from "@/shared/components/ui/context-menu"
|
||||
import { RichTextEditor } from "@/shared/components/ui/rich-text-editor"
|
||||
|
||||
interface TextbookContentPanelProps {
|
||||
/**
|
||||
* 章节数据组:当前选中章节及其预处理后的内容。
|
||||
*/
|
||||
export interface ChapterDataGroup {
|
||||
selected: Chapter | null
|
||||
processedContent: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑状态组:章节内容编辑相关的状态与操作。
|
||||
*/
|
||||
export interface EditingStateGroup {
|
||||
isEditing: boolean
|
||||
editContent: string
|
||||
setEditContent: (content: string) => void
|
||||
canEdit: boolean
|
||||
highlightedKpId: string | null
|
||||
onHighlight: (id: string) => void
|
||||
onSwitchToKnowledgeTab: () => void
|
||||
isSaving: boolean
|
||||
startEditing: () => void
|
||||
cancelEditing: () => void
|
||||
saveContent: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 文本选区组:右键菜单与文本选区相关的状态与操作。
|
||||
*/
|
||||
export interface TextSelectionGroup {
|
||||
contentRef: React.RefObject<HTMLDivElement | null>
|
||||
onPointerDown: (e: React.PointerEvent) => void
|
||||
onContextMenuChange: (open: boolean) => void
|
||||
selectedText: string
|
||||
setCreateDialogOpen: (open: boolean) => void
|
||||
startEditing: () => void
|
||||
cancelEditing: () => void
|
||||
saveContent: () => void
|
||||
isSaving: boolean
|
||||
processedContent: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 知识点高亮组:高亮跳转相关的状态与操作。
|
||||
*/
|
||||
export interface KpHighlightGroup {
|
||||
highlightedKpId: string | null
|
||||
onHighlight: (id: string) => void
|
||||
onSwitchToKnowledgeTab: () => void
|
||||
}
|
||||
|
||||
interface TextbookContentPanelProps {
|
||||
chapter: ChapterDataGroup
|
||||
editing: EditingStateGroup
|
||||
selection: TextSelectionGroup
|
||||
highlight: KpHighlightGroup
|
||||
canEdit: boolean
|
||||
}
|
||||
|
||||
export function TextbookContentPanel({
|
||||
selected,
|
||||
isEditing,
|
||||
editContent,
|
||||
setEditContent,
|
||||
chapter,
|
||||
editing,
|
||||
selection,
|
||||
highlight,
|
||||
canEdit,
|
||||
highlightedKpId,
|
||||
onHighlight,
|
||||
onSwitchToKnowledgeTab,
|
||||
contentRef,
|
||||
onPointerDown,
|
||||
onContextMenuChange,
|
||||
selectedText,
|
||||
setCreateDialogOpen,
|
||||
startEditing,
|
||||
cancelEditing,
|
||||
saveContent,
|
||||
isSaving,
|
||||
processedContent,
|
||||
}: TextbookContentPanelProps) {
|
||||
}: TextbookContentPanelProps): React.ReactNode {
|
||||
const t = useTranslations("textbooks")
|
||||
const { selected, processedContent } = chapter
|
||||
const { isEditing, editContent, setEditContent, isSaving, startEditing, cancelEditing, saveContent } = editing
|
||||
const { contentRef, onPointerDown, onContextMenuChange, selectedText, setCreateDialogOpen } = selection
|
||||
const { highlightedKpId, onHighlight, onSwitchToKnowledgeTab } = highlight
|
||||
|
||||
if (!selected) {
|
||||
return (
|
||||
|
||||
@@ -14,18 +14,9 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { createTextbookAction } from "../actions"
|
||||
import { SUBJECTS, GRADES } from "../constants"
|
||||
import { toast } from "sonner"
|
||||
import { TextbookFormFields } from "./textbook-form-fields"
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus()
|
||||
@@ -71,65 +62,7 @@ export function TextbookFormDialog() {
|
||||
<DialogDescription>{t("dialog.create.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form action={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="title" className="text-right">
|
||||
{t("field.title")}
|
||||
</Label>
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
placeholder={t("field.titlePlaceholder")}
|
||||
className="col-span-3"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="subject" className="text-right">
|
||||
{t("field.subject")}
|
||||
</Label>
|
||||
<Select name="subject" required>
|
||||
<SelectTrigger className="col-span-3">
|
||||
<SelectValue placeholder={t("field.subjectPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SUBJECTS.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>
|
||||
{t(`subject.${s.labelKey}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="grade" className="text-right">
|
||||
{t("field.grade")}
|
||||
</Label>
|
||||
<Select name="grade" required>
|
||||
<SelectTrigger className="col-span-3">
|
||||
<SelectValue placeholder={t("field.gradePlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{GRADES.map((g) => (
|
||||
<SelectItem key={g.value} value={g.value}>
|
||||
{t(`grade.${g.labelKey}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="publisher" className="text-right">
|
||||
{t("field.publisher")}
|
||||
</Label>
|
||||
<Input
|
||||
id="publisher"
|
||||
name="publisher"
|
||||
placeholder={t("field.publisherPlaceholder")}
|
||||
className="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TextbookFormFields />
|
||||
<DialogFooter>
|
||||
<SubmitButton />
|
||||
</DialogFooter>
|
||||
|
||||
107
src/modules/textbooks/components/textbook-form-fields.tsx
Normal file
107
src/modules/textbooks/components/textbook-form-fields.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { SUBJECTS, GRADES } from "../constants"
|
||||
|
||||
/**
|
||||
* 教材表单字段共享组件。
|
||||
*
|
||||
* 用于 TextbookFormDialog(创建模式)和 TextbookSettingsDialog(编辑模式),
|
||||
* 消除 title/subject/grade/publisher 四个字段的 JSX 重复。
|
||||
*
|
||||
* - 创建模式:不传 defaultValues,字段使用 placeholder
|
||||
* - 编辑模式:传 defaultValues,字段使用 defaultValue
|
||||
*/
|
||||
export interface TextbookFormFieldsProps {
|
||||
/** 编辑模式下的默认值;创建模式不传 */
|
||||
defaultValues?: {
|
||||
title?: string | null
|
||||
subject?: string | null
|
||||
grade?: string | null
|
||||
publisher?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export function TextbookFormFields({ defaultValues }: TextbookFormFieldsProps): React.ReactNode {
|
||||
const t = useTranslations("textbooks")
|
||||
const isEdit = defaultValues !== undefined
|
||||
// null → undefined 转换:Input/Select 的 defaultValue 不接受 null
|
||||
const dv = defaultValues ?? {}
|
||||
const title = dv.title ?? undefined
|
||||
const subject = dv.subject ?? undefined
|
||||
const grade = dv.grade ?? undefined
|
||||
const publisher = dv.publisher ?? undefined
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="title" className="text-right">
|
||||
{t("field.title")}
|
||||
</Label>
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
defaultValue={title}
|
||||
placeholder={isEdit ? undefined : t("field.titlePlaceholder")}
|
||||
className="col-span-3"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="subject" className="text-right">
|
||||
{t("field.subject")}
|
||||
</Label>
|
||||
<Select name="subject" defaultValue={subject} required>
|
||||
<SelectTrigger className="col-span-3">
|
||||
<SelectValue placeholder={t("field.subjectPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SUBJECTS.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>
|
||||
{t(`subject.${s.labelKey}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="grade" className="text-right">
|
||||
{t("field.grade")}
|
||||
</Label>
|
||||
<Select name="grade" defaultValue={grade} required>
|
||||
<SelectTrigger className="col-span-3">
|
||||
<SelectValue placeholder={t("field.gradePlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{GRADES.map((g) => (
|
||||
<SelectItem key={g.value} value={g.value}>
|
||||
{t(`grade.${g.labelKey}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="publisher" className="text-right">
|
||||
{t("field.publisher")}
|
||||
</Label>
|
||||
<Input
|
||||
id="publisher"
|
||||
name="publisher"
|
||||
defaultValue={publisher}
|
||||
placeholder={isEdit ? undefined : t("field.publisherPlaceholder")}
|
||||
className="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -53,12 +53,6 @@ export interface TextbookReaderProps {
|
||||
* 必传,否则知识点面板将始终为空。
|
||||
*/
|
||||
textbookId: string
|
||||
/**
|
||||
* 是否可编辑。已废弃——改由内部 usePermission() 自动判断。
|
||||
* 保留 prop 仅为向后兼容,传入值会被忽略。
|
||||
* @deprecated 改用权限系统自动判断
|
||||
*/
|
||||
canEdit?: boolean
|
||||
/**
|
||||
* 题目创建器渲染函数(P0-1 解耦)。
|
||||
* 由页面层注入 questions 模块的 CreateQuestionDialog 实现。
|
||||
@@ -176,8 +170,11 @@ export function TextbookReader({
|
||||
|
||||
const onCreateKnowledgePoint = async (formData: FormData) => {
|
||||
setIsCreating(true)
|
||||
await handleCreateKnowledgePoint(formData)
|
||||
setIsCreating(false)
|
||||
try {
|
||||
await handleCreateKnowledgePoint(formData)
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveContent = async () => {
|
||||
@@ -224,19 +221,13 @@ export function TextbookReader({
|
||||
return highlightKnowledgePoints(effectiveContent, currentChapterKPs)
|
||||
}, [effectiveContent, currentChapterKPs])
|
||||
|
||||
// P2 状态驱动:仅保留 scrollIntoView(滚动无法声明式实现),
|
||||
// 视觉高亮已由 TextbookContentPanel 中 isHighlighted 状态驱动,无需命令式 classList 操作
|
||||
useEffect(() => {
|
||||
if (!highlightedKpId) return
|
||||
const el = document.querySelector(`[data-kp-id="${highlightedKpId}"]`)
|
||||
if (!el) return
|
||||
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" })
|
||||
el.classList.add("ring-2", "ring-primary", "ring-offset-2")
|
||||
const timer = setTimeout(() => {
|
||||
el.classList.remove("ring-2", "ring-primary", "ring-offset-2")
|
||||
}, 2000)
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [highlightedKpId])
|
||||
|
||||
// P2-4 侧边栏内容(章节/知识点/图谱 Tabs),桌面端内联、移动端抽屉复用同一份
|
||||
@@ -422,24 +413,29 @@ export function TextbookReader({
|
||||
retryLabel={t("error.retry")}
|
||||
>
|
||||
<TextbookContentPanel
|
||||
selected={selected}
|
||||
isEditing={isEditing}
|
||||
editContent={editContent}
|
||||
setEditContent={setEditContent}
|
||||
chapter={{ selected, processedContent }}
|
||||
editing={{
|
||||
isEditing,
|
||||
editContent,
|
||||
setEditContent,
|
||||
isSaving,
|
||||
startEditing,
|
||||
cancelEditing: () => setIsEditing(false),
|
||||
saveContent: handleSaveContent,
|
||||
}}
|
||||
selection={{
|
||||
contentRef,
|
||||
onPointerDown: handleContentPointerDown,
|
||||
onContextMenuChange: handleContextMenuChange,
|
||||
selectedText,
|
||||
setCreateDialogOpen,
|
||||
}}
|
||||
highlight={{
|
||||
highlightedKpId,
|
||||
onHighlight: setHighlightedKpId,
|
||||
onSwitchToKnowledgeTab: () => setActiveTab("knowledge"),
|
||||
}}
|
||||
canEdit={canEdit}
|
||||
highlightedKpId={highlightedKpId}
|
||||
onHighlight={setHighlightedKpId}
|
||||
onSwitchToKnowledgeTab={() => setActiveTab("knowledge")}
|
||||
contentRef={contentRef}
|
||||
onPointerDown={handleContentPointerDown}
|
||||
onContextMenuChange={handleContextMenuChange}
|
||||
selectedText={selectedText}
|
||||
setCreateDialogOpen={setCreateDialogOpen}
|
||||
startEditing={startEditing}
|
||||
cancelEditing={() => setIsEditing(false)}
|
||||
saveContent={handleSaveContent}
|
||||
isSaving={isSaving}
|
||||
processedContent={processedContent}
|
||||
/>
|
||||
</TextbookSectionErrorBoundary>
|
||||
</div>
|
||||
|
||||
@@ -24,26 +24,19 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/components/ui/alert-dialog"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { updateTextbookAction, deleteTextbookAction } from "../actions"
|
||||
import { SUBJECTS, GRADES } from "../constants"
|
||||
import { toast } from "sonner"
|
||||
import type { Textbook } from "../types"
|
||||
import { TextbookFormFields } from "./textbook-form-fields"
|
||||
|
||||
interface TextbookSettingsDialogProps {
|
||||
textbook: Textbook
|
||||
trigger?: React.ReactNode
|
||||
/** 删除后跳转的 URL,默认 "/teacher/textbooks" */
|
||||
redirectAfterDelete?: string
|
||||
}
|
||||
|
||||
export function TextbookSettingsDialog({ textbook, trigger }: TextbookSettingsDialogProps) {
|
||||
export function TextbookSettingsDialog({ textbook, trigger, redirectAfterDelete = "/teacher/textbooks" }: TextbookSettingsDialogProps) {
|
||||
const t = useTranslations("textbooks")
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -77,7 +70,7 @@ export function TextbookSettingsDialog({ textbook, trigger }: TextbookSettingsDi
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
router.push("/teacher/textbooks")
|
||||
router.push(redirectAfterDelete)
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
@@ -107,65 +100,14 @@ export function TextbookSettingsDialog({ textbook, trigger }: TextbookSettingsDi
|
||||
</DialogHeader>
|
||||
|
||||
<form action={handleUpdate}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="title" className="text-right">
|
||||
{t("field.title")}
|
||||
</Label>
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
defaultValue={textbook.title}
|
||||
className="col-span-3"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="subject" className="text-right">
|
||||
{t("field.subject")}
|
||||
</Label>
|
||||
<Select name="subject" defaultValue={textbook.subject} required>
|
||||
<SelectTrigger className="col-span-3">
|
||||
<SelectValue placeholder={t("field.subjectPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SUBJECTS.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>
|
||||
{t(`subject.${s.labelKey}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="grade" className="text-right">
|
||||
{t("field.grade")}
|
||||
</Label>
|
||||
<Select name="grade" defaultValue={textbook.grade || undefined} required>
|
||||
<SelectTrigger className="col-span-3">
|
||||
<SelectValue placeholder={t("field.gradePlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{GRADES.map((g) => (
|
||||
<SelectItem key={g.value} value={g.value}>
|
||||
{t(`grade.${g.labelKey}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="publisher" className="text-right">
|
||||
{t("field.publisher")}
|
||||
</Label>
|
||||
<Input
|
||||
id="publisher"
|
||||
name="publisher"
|
||||
defaultValue={textbook.publisher || ""}
|
||||
className="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TextbookFormFields
|
||||
defaultValues={{
|
||||
title: textbook.title,
|
||||
subject: textbook.subject,
|
||||
grade: textbook.grade,
|
||||
publisher: textbook.publisher,
|
||||
}}
|
||||
/>
|
||||
|
||||
<DialogFooter className="flex justify-between sm:justify-between">
|
||||
<Button
|
||||
|
||||
Reference in New Issue
Block a user