Files
NextEdu/src/modules/announcements/components/announcement-card.tsx
SpecialX 2b95fd668b feat(announcements): add tests, skeleton, pagination, and service context
- Add announcement-card test for component testing

- Add is-announcement-visible test and schema test for logic testing

- Add announcement-list-skeleton for loading states

- Add announcement-pagination for list pagination

- Add announcements-service-context and default-announcements-service for service layer
2026-07-03 10:24:50 +08:00

157 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { useTranslations } from "next-intl"
import { Pin } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { cn, formatDate } from "@/shared/lib/utils"
import { useAnnouncementsService } from "./announcements-service-context"
import type { Announcement } from "../types"
const statusVariant: Record<Announcement["status"], "default" | "secondary" | "outline"> = {
draft: "secondary",
published: "default",
archived: "outline",
}
/**
* 公告卡片(列表项)。
*
* P1-3: 通过 `useAnnouncementsService()` 消费 togglePin不直接 import actions。
* P2-1 a11y: 重构交互结构——卡片整体为 `<Link>`,置顶按钮用独立 `<button>`
* 绝对定位脱离链接语义,避免键盘 Enter 同时触发按钮与链接导航。
* P1-2: 通过 `isRead` 视觉区分已读/未读(仅非管理端 + 未提供 isRead 时不渲染)。
*/
export function AnnouncementCard({
announcement,
href,
canManage,
isRead,
}: {
announcement: Announcement
href?: string
canManage?: boolean
/** 当前用户是否已读(用户端列表传入以做视觉区分) */
isRead?: boolean
}) {
const t = useTranslations("announcements")
const router = useRouter()
const service = useAnnouncementsService()
const [isPinned, setIsPinned] = useState(announcement.isPinned)
const [isToggling, setIsToggling] = useState(false)
const handleTogglePin = async (e: React.MouseEvent | React.KeyboardEvent) => {
e.preventDefault()
e.stopPropagation()
setIsToggling(true)
const prevPinned = isPinned
// 乐观更新
setIsPinned(!prevPinned)
try {
const res = await service.togglePin(announcement.id)
if (res.success) {
toast.success(t("messages.pinToggled"))
router.refresh()
} else {
// 回滚
setIsPinned(prevPinned)
toast.error(res.message)
}
} catch {
// 回滚
setIsPinned(prevPinned)
toast.error(t("messages.publishFailed"))
} finally {
setIsToggling(false)
}
}
const card = (
<Card
className={cn(
"relative h-full transition-colors hover:bg-accent/50",
isPinned && "border-primary/50",
// 已读/未读视觉区分P1-2
isRead === false && "ring-2 ring-primary/40"
)}
>
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0 pr-10">
<CardTitle className="line-clamp-2 text-base">
{isPinned ? (
<Pin className="text-primary mr-1 inline h-3.5 w-3.5 fill-primary align-text-bottom" aria-hidden="true" />
) : null}
{announcement.title}
</CardTitle>
<div className="flex shrink-0 items-center gap-1">
{isPinned ? (
<Badge variant="default" className="text-xs">
{t("status.pinned")}
</Badge>
) : null}
<Badge variant={statusVariant[announcement.status]} className="text-xs">
{t(`status.${announcement.status}`)}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-2">
<p className="line-clamp-3 text-sm text-muted-foreground whitespace-pre-wrap">
{announcement.content}
</p>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline" className="capitalize">
{t(`type.${announcement.type}`)}
</Badge>
{isRead === false ? (
<Badge variant="default" className="text-xs">
{t("status.unread")}
</Badge>
) : null}
<span>
{announcement.publishedAt
? t("meta.publishedAt", { date: formatDate(announcement.publishedAt) })
: t("meta.updatedAt", { date: formatDate(announcement.updatedAt) })}
</span>
{announcement.authorName ? (
<span className="ml-auto">{t("meta.author", { name: announcement.authorName })}</span>
) : null}
</div>
</CardContent>
{/* P2-1 a11y: 置顶按钮绝对定位脱离链接语义,键盘可达且不触发外层 Link 导航 */}
{canManage ? (
<button
type="button"
onClick={handleTogglePin}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
handleTogglePin(e)
}
}}
disabled={isToggling}
aria-label={isPinned ? t("actions.unpin") : t("actions.pin")}
className="text-muted-foreground hover:text-foreground absolute right-3 top-3 z-10 inline-flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent disabled:opacity-50"
>
<Pin
className={cn("h-3.5 w-3.5 transition-colors", isPinned && "fill-primary text-primary")}
/>
</button>
) : null}
</Card>
)
if (href) {
return (
<Link href={href} className="block h-full" aria-label={announcement.title}>
{card}
</Link>
)
}
return card
}