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
183 lines
7.1 KiB
TypeScript
183 lines
7.1 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" // 默认教师端,学生端通过 hrefBase prop 覆盖
|
|
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>
|
|
)
|
|
}
|