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 相关零错误
183 lines
7.0 KiB
TypeScript
183 lines
7.0 KiB
TypeScript
"use client"
|
|
|
|
import { useState } from "react"
|
|
import Link from "next/link"
|
|
import { useRouter } from "next/navigation"
|
|
import { useTranslations } from "next-intl"
|
|
import { Book, MoreVertical, Edit, Trash2, BookOpen, GraduationCap, Building2 } from "lucide-react"
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardFooter,
|
|
CardHeader,
|
|
} from "@/shared/components/ui/card"
|
|
import { Badge } from "@/shared/components/ui/badge"
|
|
import { Button } from "@/shared/components/ui/button"
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/shared/components/ui/dropdown-menu"
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@/shared/components/ui/alert-dialog"
|
|
import { cn, formatDate } from "@/shared/lib/utils"
|
|
import type { Textbook } from "../types"
|
|
import { getSubjectColor, getSubjectLabelKey, getGradeLabelKey } from "../constants"
|
|
import { deleteTextbookAction } from "../actions"
|
|
import { toast } from "sonner"
|
|
|
|
interface TextbookCardProps {
|
|
textbook: Textbook
|
|
hrefBase?: string
|
|
hideActions?: boolean
|
|
}
|
|
|
|
export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardProps) {
|
|
const t = useTranslations("textbooks")
|
|
const router = useRouter()
|
|
const base = hrefBase || "/teacher/textbooks"
|
|
const colorClass = getSubjectColor(textbook.subject)
|
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
|
const [isDeleting, setIsDeleting] = useState(false)
|
|
|
|
const handleDelete = async () => {
|
|
setIsDeleting(true)
|
|
try {
|
|
const result = await deleteTextbookAction(textbook.id)
|
|
if (result.success) {
|
|
toast.success(result.message)
|
|
router.refresh()
|
|
} else {
|
|
toast.error(result.message)
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to delete textbook", e)
|
|
toast.error(t("reader.deleteFailed"))
|
|
} finally {
|
|
setIsDeleting(false)
|
|
setShowDeleteDialog(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Card className="group flex flex-col h-full overflow-hidden border-border/60 transition-all duration-300 hover:shadow-md hover:border-primary/50">
|
|
<Link href={`${base}/${textbook.id}`} className="flex-1">
|
|
<div className={cn("relative h-32 w-full overflow-hidden p-5", colorClass)}>
|
|
<div className="relative z-10 flex h-full flex-col justify-between">
|
|
<Badge variant="secondary" className="w-fit bg-background/80 border border-border/60 shadow-sm">
|
|
{t(`subject.${getSubjectLabelKey(textbook.subject)}`)}
|
|
</Badge>
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-background text-foreground shadow-sm ring-1 ring-border/60">
|
|
<Book className="h-5 w-5" />
|
|
</div>
|
|
<div className="text-xs font-medium text-foreground/70">
|
|
{textbook.grade ? t(`grade.${getGradeLabelKey(textbook.grade)}`) : t("card.gradeNA")}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<CardHeader className="p-4 pb-2">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<h3 className="font-semibold text-lg leading-tight line-clamp-2 group-hover:text-primary transition-colors">
|
|
{textbook.title}
|
|
</h3>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent className="p-4 pt-1 pb-2">
|
|
<div className="flex flex-wrap gap-y-1 gap-x-4 text-xs text-muted-foreground">
|
|
<div className="flex items-center gap-1.5">
|
|
<GraduationCap className="h-3.5 w-3.5" />
|
|
<span>{textbook.grade ? t(`grade.${getGradeLabelKey(textbook.grade)}`) : t("card.gradeNA")}</span>
|
|
</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>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Link>
|
|
|
|
<CardFooter className="p-4 pt-2 mt-auto border-t border-border/60 bg-muted/30 flex items-center justify-between">
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground tabular-nums">
|
|
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-background/80 ring-1 ring-border/60">
|
|
<BookOpen className="h-3.5 w-3.5" />
|
|
</div>
|
|
<span>
|
|
{textbook._count?.chapters || 0} {t("card.chapters")}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-xs text-muted-foreground/60 mr-2">
|
|
{t("card.updated")} {formatDate(textbook.updatedAt)}
|
|
</span>
|
|
{!hideActions && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="h-6 w-6 -mr-2">
|
|
<MoreVertical className="h-3.5 w-3.5" />
|
|
<span className="sr-only">{t("card.moreOptions")}</span>
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem asChild>
|
|
<Link href={`${base}/${textbook.id}`}>
|
|
<Edit className="mr-2 h-4 w-4" />
|
|
{t("card.editContent")}
|
|
</Link>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
className="text-destructive focus:text-destructive"
|
|
onClick={() => setShowDeleteDialog(true)}
|
|
>
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
|
{t("card.delete")}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
</div>
|
|
</CardFooter>
|
|
|
|
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>{t("dialog.settings.deleteConfirmTitle")}</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{t("dialog.settings.deleteConfirmDesc")}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={isDeleting}>{t("dialog.knowledge.cancel")}</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={(e) => {
|
|
e.preventDefault()
|
|
handleDelete()
|
|
}}
|
|
disabled={isDeleting}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
>
|
|
{isDeleting ? t("dialog.settings.processing") : t("dialog.settings.delete")}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</Card>
|
|
)
|
|
}
|