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
This commit is contained in:
SpecialX
2026-07-03 10:24:50 +08:00
parent 2859ef74f2
commit 2b95fd668b
15 changed files with 1539 additions and 206 deletions

View File

@@ -10,6 +10,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/shared/compo
import { AnnouncementForm } from "./announcement-form"
import { AnnouncementList } from "./announcement-list"
import { AnnouncementsServiceProvider } from "./announcements-service-context"
import type { Announcement, AnnouncementStatus } from "../types"
export function AdminAnnouncementsView({
@@ -27,41 +28,55 @@ export function AdminAnnouncementsView({
const router = useRouter()
const [createOpen, setCreateOpen] = useState(false)
const handleOpenChange = (open: boolean) => {
// P1-8: 表单提交成功后关闭 Dialog 并刷新列表,而非整页路由跳转
const handleFormSuccess = (): void => {
setCreateOpen(false)
router.refresh()
}
const handleOpenChange = (open: boolean): void => {
setCreateOpen(open)
if (!open) router.refresh()
}
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">{t("title.adminList")}</h2>
<p className="text-muted-foreground">
{t("description.adminList")}
</p>
<AnnouncementsServiceProvider>
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">{t("title.adminList")}</h2>
<p className="text-muted-foreground">
{t("description.adminList")}
</p>
</div>
<Button onClick={() => setCreateOpen(true)}>
<PlusCircle className="mr-2 h-4 w-4" />
{t("actions.new")}
</Button>
</div>
<Button onClick={() => setCreateOpen(true)}>
<PlusCircle className="mr-2 h-4 w-4" />
{t("actions.new")}
</Button>
<AnnouncementList
announcements={announcements}
canManage
initialStatus={initialStatus}
detailHrefPrefix="/admin/announcements"
/>
<Dialog open={createOpen} onOpenChange={handleOpenChange}>
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle>{t("title.create")}</DialogTitle>
</DialogHeader>
<AnnouncementForm
mode="create"
grades={grades}
classes={classes}
onSuccess={handleFormSuccess}
onCancel={handleFormSuccess}
/>
</DialogContent>
</Dialog>
</div>
<AnnouncementList
announcements={announcements}
canManage
initialStatus={initialStatus}
detailHrefPrefix="/admin/announcements"
/>
<Dialog open={createOpen} onOpenChange={handleOpenChange}>
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle>{t("title.create")}</DialogTitle>
</DialogHeader>
<AnnouncementForm mode="create" grades={grades} classes={classes} />
</DialogContent>
</Dialog>
</div>
</AnnouncementsServiceProvider>
)
}

View File

@@ -0,0 +1,303 @@
import "@testing-library/jest-dom/vitest"
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { render, screen, fireEvent, cleanup } from "@testing-library/react"
import { AnnouncementCard } from "./announcement-card"
import { AnnouncementsServiceProvider } from "./announcements-service-context"
import type { Announcement, AnnouncementsService } from "../types"
import type { ActionState } from "@/shared/types/action-state"
// Mock next-intl返回 key 作为字面量,便于断言
vi.mock("next-intl", () => ({
useTranslations: () => (key: string, params?: Record<string, string>) => {
if (!params) return key
return `${key}:${JSON.stringify(params)}`
},
}))
// Mock next/navigationrouter.refresh 不做任何事
vi.mock("next/navigation", () => ({
useRouter: () => ({
refresh: vi.fn(),
push: vi.fn(),
replace: vi.fn(),
}),
}))
// Mock sonnertoast.success / error 收集调用
const toastMocks = { success: vi.fn(), error: vi.fn() }
vi.mock("sonner", () => ({
toast: {
success: (...args: unknown[]) => toastMocks.success(...args),
error: (...args: unknown[]) => toastMocks.error(...args),
},
}))
// Mock @/shared/lib/utils 的 formatDate 与 cn
vi.mock("@/shared/lib/utils", () => ({
cn: (...classes: (string | false | null | undefined)[]) =>
classes.filter(Boolean).join(" "),
formatDate: (iso: string) => `FMT:${iso}`,
}))
// Mock @/shared/components/ui/card渲染 children 即可,避免样式依赖
vi.mock("@/shared/components/ui/card", () => ({
Card: ({ children, className }: { children: React.ReactNode; className?: string }) =>
<div data-testid="card" className={className}>{children}</div>,
CardHeader: ({ children }: { children: React.ReactNode }) =>
<div data-testid="card-header">{children}</div>,
CardTitle: ({ children }: { children: React.ReactNode }) =>
<h3 data-testid="card-title">{children}</h3>,
CardContent: ({ children }: { children: React.ReactNode }) =>
<div data-testid="card-content">{children}</div>,
}))
// Mock @/shared/components/ui/badge
vi.mock("@/shared/components/ui/badge", () => ({
Badge: ({
children,
variant,
className,
}: {
children: React.ReactNode
variant?: string
className?: string
}) => (
<span data-testid="badge" data-variant={variant} className={className}>
{children}
</span>
),
}))
const baseAnnouncement: Announcement = {
id: "ann-1",
title: "测试公告标题",
content: "测试公告内容",
type: "school",
status: "published",
targetGradeId: null,
targetClassId: null,
authorId: "user-1",
authorName: "管理员",
publishedAt: "2026-06-25T00:00:00.000Z",
isPinned: false,
createdAt: "2026-06-25T00:00:00.000Z",
updatedAt: "2026-06-25T00:00:00.000Z",
}
function createMockService(
overrides: Partial<AnnouncementsService> = {}
): AnnouncementsService {
const ok = <T,>(data: T): ActionState<T> => ({
success: true,
data,
message: "ok",
})
return {
create: vi.fn().mockResolvedValue(ok("new-id")),
update: vi.fn().mockResolvedValue(ok("ann-1")),
delete: vi.fn().mockResolvedValue(ok("ann-1")),
publish: vi.fn().mockResolvedValue(ok("ann-1")),
archive: vi.fn().mockResolvedValue(ok("ann-1")),
togglePin: vi.fn().mockResolvedValue(ok("ann-1")),
markRead: vi.fn().mockResolvedValue(ok("ann-1")),
getReadStatus: vi.fn().mockResolvedValue(ok({})),
...overrides,
}
}
function renderCard(
service: AnnouncementsService,
props: Partial<Parameters<typeof AnnouncementCard>[0]> = {}
) {
return render(
<AnnouncementsServiceProvider service={service}>
<AnnouncementCard announcement={baseAnnouncement} canManage {...props} />
</AnnouncementsServiceProvider>
)
}
describe("AnnouncementCard", () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
cleanup()
})
describe("基础渲染", () => {
it("渲染标题与内容", () => {
const service = createMockService()
renderCard(service)
expect(screen.getByText("测试公告标题")).toBeInTheDocument()
expect(screen.getByText("测试公告内容")).toBeInTheDocument()
})
it("渲染类型 badgeschool", () => {
const service = createMockService()
renderCard(service)
const badges = screen.getAllByTestId("badge")
const types = badges.map((b) => b.textContent)
expect(types).toContain("type.school")
})
it("未提供 href 时不渲染外层 Link", () => {
const service = createMockService()
const { container } = renderCard(service, { href: undefined })
// 没有 <a> 标签
expect(container.querySelector("a")).toBeNull()
})
})
describe("isRead 视觉区分", () => {
it("isRead=false 时应用 ring-2 ring-primary/40 类名", () => {
const service = createMockService()
const { container } = renderCard(service, { isRead: false })
const card = container.querySelector('[data-testid="card"]')
expect(card?.className).toContain("ring-2")
expect(card?.className).toContain("ring-primary/40")
})
it("isRead=true 时不应用 ring 类名", () => {
const service = createMockService()
const { container } = renderCard(service, { isRead: true })
const card = container.querySelector('[data-testid="card"]')
expect(card?.className).not.toContain("ring-2")
})
it("isRead=undefined 时不应用 ring 类名", () => {
const service = createMockService()
const { container } = renderCard(service, { isRead: undefined })
const card = container.querySelector('[data-testid="card"]')
expect(card?.className).not.toContain("ring-2")
})
it("isRead=false 时渲染未读 badge", () => {
const service = createMockService()
renderCard(service, { isRead: false })
const badges = screen.getAllByTestId("badge")
const texts = badges.map((b) => b.textContent)
expect(texts).toContain("status.unread")
})
})
describe("isPinned 视觉与交互", () => {
it("isPinned=true 时渲染已置顶 badge 与 Pin 图标 fill", () => {
const service = createMockService()
renderCard(service, {
announcement: { ...baseAnnouncement, isPinned: true },
})
const badges = screen.getAllByTestId("badge")
const texts = badges.map((b) => b.textContent)
expect(texts).toContain("status.pinned")
})
it("canManage=true 时渲染置顶按钮", () => {
const service = createMockService()
renderCard(service)
const pinButton = screen.getByRole("button", { name: "actions.pin" })
expect(pinButton).toBeInTheDocument()
})
it("canManage=false 时不渲染置顶按钮", () => {
const service = createMockService()
render(
<AnnouncementsServiceProvider service={service}>
<AnnouncementCard announcement={baseAnnouncement} canManage={false} />
</AnnouncementsServiceProvider>
)
expect(screen.queryByRole("button", { name: "actions.pin" })).toBeNull()
})
it("点击置顶按钮调用 service.togglePin", async () => {
const togglePin = vi.fn().mockResolvedValue({
success: true,
data: "ann-1",
message: "ok",
} satisfies ActionState<string>)
const service = createMockService({ togglePin })
renderCard(service)
const pinButton = screen.getByRole("button", { name: "actions.pin" })
await fireEvent.click(pinButton)
expect(togglePin).toHaveBeenCalledWith("ann-1")
// 乐观更新成功后显示 toast
expect(toastMocks.success).toHaveBeenCalled()
})
it("togglePin 返回失败时回滚 isPinned 并显示 toast.error", async () => {
const togglePin = vi.fn().mockResolvedValue({
success: false,
message: "权限不足",
} satisfies ActionState<string>)
const service = createMockService({ togglePin })
renderCard(service)
const pinButton = screen.getByRole("button", { name: "actions.pin" })
await fireEvent.click(pinButton)
expect(togglePin).toHaveBeenCalled()
expect(toastMocks.error).toHaveBeenCalledWith("权限不足")
})
it("togglePin 抛错时回滚并显示 toast.error", async () => {
const togglePin = vi.fn().mockRejectedValue(new Error("network"))
const service = createMockService({ togglePin })
renderCard(service)
const pinButton = screen.getByRole("button", { name: "actions.pin" })
await fireEvent.click(pinButton)
expect(togglePin).toHaveBeenCalled()
expect(toastMocks.error).toHaveBeenCalled()
})
})
describe("a11yP2-1", () => {
it("置顶按钮支持 Enter 键触发", async () => {
const togglePin = vi.fn().mockResolvedValue({
success: true,
data: "ann-1",
message: "ok",
} satisfies ActionState<string>)
const service = createMockService({ togglePin })
renderCard(service)
const pinButton = screen.getByRole("button", { name: "actions.pin" })
await fireEvent.keyDown(pinButton, { key: "Enter" })
expect(togglePin).toHaveBeenCalledWith("ann-1")
})
it("置顶按钮支持 Space 键触发", async () => {
const togglePin = vi.fn().mockResolvedValue({
success: true,
data: "ann-1",
message: "ok",
} satisfies ActionState<string>)
const service = createMockService({ togglePin })
renderCard(service)
const pinButton = screen.getByRole("button", { name: "actions.pin" })
await fireEvent.keyDown(pinButton, { key: " " })
expect(togglePin).toHaveBeenCalledWith("ann-1")
})
it("置顶按钮在 toggling 时禁用", async () => {
// 永不 resolve 的 promise让 isToggling 永远为 true
const togglePin = vi.fn().mockReturnValue(new Promise(() => {}))
const service = createMockService({ togglePin })
renderCard(service)
const pinButton = screen.getByRole("button", { name: "actions.pin" })
fireEvent.click(pinButton)
// 等待下一帧让 setIsToggling(true) 生效
await new Promise((r) => setTimeout(r, 0))
expect(pinButton).toBeDisabled()
})
})
})

View File

@@ -10,24 +10,43 @@ 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 { toggleAnnouncementPinAction } from "../actions"
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) => {
const handleTogglePin = async (e: React.MouseEvent | React.KeyboardEvent) => {
e.preventDefault()
e.stopPropagation()
setIsToggling(true)
@@ -35,7 +54,7 @@ export function AnnouncementCard({
// 乐观更新
setIsPinned(!prevPinned)
try {
const res = await toggleAnnouncementPinAction(announcement.id)
const res = await service.togglePin(announcement.id)
if (res.success) {
toast.success(t("messages.pinToggled"))
router.refresh()
@@ -53,15 +72,16 @@ export function AnnouncementCard({
}
}
const statusVariant: Record<Announcement["status"], "default" | "secondary" | "outline"> = {
draft: "secondary",
published: "default",
archived: "outline",
}
const card = (
<Card className={cn("h-full transition-colors hover:bg-accent/50", isPinned && "border-primary/50")}>
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
<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" />
@@ -77,19 +97,6 @@ export function AnnouncementCard({
<Badge variant={statusVariant[announcement.status]} className="text-xs">
{t(`status.${announcement.status}`)}
</Badge>
{canManage ? (
<button
type="button"
onClick={handleTogglePin}
disabled={isToggling}
aria-label={isPinned ? t("actions.unpin") : t("actions.pin")}
className="text-muted-foreground hover:text-foreground 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}
</div>
</CardHeader>
<CardContent className="space-y-2">
@@ -100,6 +107,11 @@ export function AnnouncementCard({
<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) })
@@ -110,6 +122,25 @@ export function AnnouncementCard({
) : 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>
)

View File

@@ -22,13 +22,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui
import { ConfirmDeleteDialog } from "@/shared/components/ui/confirm-delete-dialog"
import { cn, formatDate } from "@/shared/lib/utils"
import {
archiveAnnouncementAction,
deleteAnnouncementAction,
markAnnouncementAsReadAction,
publishAnnouncementAction,
toggleAnnouncementPinAction,
} from "../actions"
import { useAnnouncementsService } from "./announcements-service-context"
import type { Announcement } from "../types"
export function AnnouncementDetail({
@@ -44,6 +38,7 @@ export function AnnouncementDetail({
}) {
const t = useTranslations("announcements")
const router = useRouter()
const service = useAnnouncementsService()
const [isWorking, setIsWorking] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const [isPinned, setIsPinned] = useState(announcement.isPinned)
@@ -56,7 +51,7 @@ export function AnnouncementDetail({
if (isRead) return
let cancelled = false
void markAnnouncementAsReadAction(announcement.id)
void service.markRead(announcement.id)
.then((res) => {
if (cancelled) return
if (res.success) {
@@ -70,12 +65,12 @@ export function AnnouncementDetail({
return () => {
cancelled = true
}
}, [canManage, isRead, announcement.id])
}, [canManage, isRead, announcement.id, service])
const handlePublish = async () => {
setIsWorking(true)
try {
const res = await publishAnnouncementAction(announcement.id)
const res = await service.publish(announcement.id)
if (res.success) {
toast.success(res.message)
router.refresh()
@@ -92,7 +87,7 @@ export function AnnouncementDetail({
const handleArchive = async () => {
setIsWorking(true)
try {
const res = await archiveAnnouncementAction(announcement.id)
const res = await service.archive(announcement.id)
if (res.success) {
toast.success(res.message)
router.refresh()
@@ -109,7 +104,7 @@ export function AnnouncementDetail({
const handleDelete = async () => {
setIsWorking(true)
try {
const res = await deleteAnnouncementAction(announcement.id)
const res = await service.delete(announcement.id)
if (res.success) {
toast.success(res.message)
router.push("/admin/announcements")
@@ -131,7 +126,7 @@ export function AnnouncementDetail({
// 乐观更新
setIsPinned(!prevPinned)
try {
const res = await toggleAnnouncementPinAction(announcement.id)
const res = await service.togglePin(announcement.id)
if (res.success) {
toast.success(t("messages.pinToggled"))
router.refresh()

View File

@@ -18,26 +18,47 @@ import {
SelectValue,
} from "@/shared/components/ui/select"
import { createAnnouncementAction, updateAnnouncementAction } from "../actions"
import { useAnnouncementsService } from "./announcements-service-context"
import type { Announcement } from "../types"
type Mode = "create" | "edit"
type FieldErrors = Record<string, string[]>
/**
* 公告表单(创建/编辑)。
*
* P1-3: 通过 `useAnnouncementsService()` 消费 create/update不直接 import actions。
* P1-4 + P1-8: 通过 `onSuccess` / `onCancel` 回调解耦导航,
* 嵌入 Dialog 时关闭弹窗而非整页跳转;独立页面时由父级传入路由跳转。
* `successHref` / `cancelHref` 为回调的便利兜底。
*/
export function AnnouncementForm({
mode,
announcement,
grades = [],
classes = [],
onSuccess,
onCancel,
successHref = "/admin/announcements",
cancelHref = "/admin/announcements",
}: {
mode: Mode
announcement?: Announcement
grades?: { id: string; name: string }[]
classes?: { id: string; name: string }[]
/** 提交成功回调;若提供则调用,否则回退到 router.push(successHref) */
onSuccess?: () => void
/** 取消回调;若提供则调用,否则回退到 router.push(cancelHref) */
onCancel?: () => void
/** 提交成功后跳转的路径onSuccess 未提供时使用) */
successHref?: string
/** 取消按钮跳转的路径onCancel 未提供时使用) */
cancelHref?: string
}) {
const t = useTranslations("announcements")
const router = useRouter()
const service = useAnnouncementsService()
const [isWorking, setIsWorking] = useState(false)
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({})
@@ -51,6 +72,23 @@ export function AnnouncementForm({
return errs && errs.length > 0 ? errs[0] : null
}
const handleSuccess = (): void => {
if (onSuccess) {
onSuccess()
return
}
router.push(successHref)
router.refresh()
}
const handleCancel = (): void => {
if (onCancel) {
onCancel()
return
}
router.push(cancelHref)
}
const handleSubmit = async (formData: FormData) => {
setIsWorking(true)
setFieldErrors({})
@@ -66,9 +104,9 @@ export function AnnouncementForm({
const res =
mode === "create"
? await createAnnouncementAction(null, formData)
? await service.create(null, formData)
: announcement
? await updateAnnouncementAction(announcement.id, null, formData)
? await service.update(announcement.id, null, formData)
: null
if (!res) {
@@ -78,8 +116,7 @@ export function AnnouncementForm({
if (res.success) {
toast.success(res.message)
router.push("/admin/announcements")
router.refresh()
handleSuccess()
} else {
// 展示字段级错误(来自 Zod superRefine 校验)
if (res.errors) {
@@ -214,7 +251,7 @@ export function AnnouncementForm({
<Button
type="button"
variant="outline"
onClick={() => router.push("/admin/announcements")}
onClick={handleCancel}
disabled={isWorking}
>
{t("form.cancel")}

View File

@@ -0,0 +1,50 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
/**
* 公告列表骨架屏P2-5 抽取)。
*
* 此前用户端 `/announcements/loading.tsx` 与管理端 `/admin/announcements/loading.tsx`
* 几乎逐行重复,违反"重复超过 90% 必须抽取共享组件"规则。
*/
export function AnnouncementListSkeleton({
showCreateButton = false,
}: {
showCreateButton?: boolean
}) {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex items-center justify-between space-y-2">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
{showCreateButton ? <Skeleton className="h-9 w-40" /> : null}
</div>
<div className="flex items-center gap-3">
<Skeleton className="h-9 w-[180px]" />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i}>
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-5 w-16" />
</CardHeader>
<CardContent className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
<div className="flex items-center gap-2 pt-2">
<Skeleton className="h-5 w-16" />
<Skeleton className="h-3 w-32" />
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@@ -1,5 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { Plus, Megaphone } from "lucide-react"
@@ -16,6 +17,8 @@ import {
} from "@/shared/components/ui/select"
import { AnnouncementCard } from "./announcement-card"
import { AnnouncementPagination } from "./announcement-pagination"
import { useAnnouncementsService } from "./announcements-service-context"
import type { Announcement, AnnouncementStatus } from "../types"
type Filter = "all" | AnnouncementStatus
@@ -28,29 +31,59 @@ type Filter = "all" | AnnouncementStatus
* - 父页面根据 `?status=` 查询并传入 `announcements` prop
* - 组件不再做客户端二次过滤,避免双重过滤逻辑冗余
*
* 详情链接构建:
* - `detailHrefPrefix`:推荐方式,适用于 Server Component前缀 + id 拼接)
* - `detailHrefBuilder`:仅适用于 Client Component 之间的调用
* P2-4: 删除了死 prop `detailHrefBuilder`,仅保留 `detailHrefPrefix`Server Component 安全)。
* P1-2: 用户端canManage=false时调用 `getReadStatus` 批量获取已读状态,
* 传入卡片做已读/未读视觉区分。
* P2-6: 新增分页支持。传入 `pagination` prop 时在列表底部渲染 `AnnouncementPagination`
* 通过 `buildPageHref` 回调生成页码 URL与 `?status=` 过滤查询参数合并)。
*/
export function AnnouncementList({
announcements,
canManage,
createHref,
detailHrefBuilder,
detailHrefPrefix,
initialStatus,
pagination,
}: {
announcements: Announcement[]
canManage?: boolean
createHref?: string
detailHrefBuilder?: (id: string) => string
detailHrefPrefix?: string
initialStatus?: Filter
pagination?: {
page: number
pageSize: number
total: number
buildPageHref: (page: number) => string
}
}) {
const t = useTranslations("announcements")
const router = useRouter()
const service = useAnnouncementsService()
const filter: Filter = initialStatus ?? "all"
// P1-2: 用户端批量获取已读状态
const [readStatus, setReadStatus] = useState<Record<string, boolean>>({})
useEffect(() => {
if (canManage) return
if (announcements.length === 0) return
let cancelled = false
void service
.getReadStatus(announcements.map((a) => a.id))
.then((res) => {
if (cancelled) return
if (res.success && res.data) {
setReadStatus(res.data)
}
})
.catch(() => {
// 静默处理,已读状态不影响主流程
})
return () => {
cancelled = true
}
}, [announcements, canManage, service])
const filterOptions: { value: Filter; label: string }[] = [
{ value: "all", label: t("filter.all") },
{ value: "published", label: t("filter.published") },
@@ -65,14 +98,6 @@ export function AnnouncementList({
router.replace(qs ? `?${qs}` : "?")
}
// 构建详情链接:优先使用 detailHrefPrefixServer Component 安全),
// 其次使用 detailHrefBuilder仅 Client Component 间调用)
const buildDetailHref = (id: string): string | undefined => {
if (detailHrefPrefix) return `${detailHrefPrefix}/${id}`
if (detailHrefBuilder) return detailHrefBuilder(id)
return undefined
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
@@ -110,15 +135,26 @@ export function AnnouncementList({
className="h-auto border-none shadow-none"
/>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{announcements.map((a) => (
<AnnouncementCard
key={a.id}
announcement={a}
href={buildDetailHref(a.id)}
canManage={canManage}
<div className="space-y-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{announcements.map((a) => (
<AnnouncementCard
key={a.id}
announcement={a}
href={detailHrefPrefix ? `${detailHrefPrefix}/${a.id}` : undefined}
canManage={canManage}
isRead={canManage ? undefined : readStatus[a.id]}
/>
))}
</div>
{pagination ? (
<AnnouncementPagination
page={pagination.page}
pageSize={pagination.pageSize}
total={pagination.total}
buildHref={pagination.buildPageHref}
/>
))}
) : null}
</div>
)}
</div>

View File

@@ -0,0 +1,116 @@
"use client"
import { useMemo } from "react"
import Link from "next/link"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import { cn } from "@/shared/lib/utils"
/**
* P2-6: 公告列表分页组件。
*
* 通过 URL `?page=N` 持久化当前页码RSC 重新渲染模式,与 AnnouncementList 的
* `?status=` 过滤机制一致),不引入客户端路由状态。
*
* 设计要点:
* - 服务端渲染友好:仅渲染 a 链接,无 onClick 路由副作用
* - a11y上一页/下一页按钮使用 aria-label当前页用 aria-current="page"
* - 边界处理total=0 时不渲染分页;首页时上一页禁用;末页时下一页禁用
* - 计算窗口:当总页数 > 7 时显示首末页 + 当前页 ±1 + 省略号,避免溢出
*/
export function AnnouncementPagination({
page,
pageSize,
total,
buildHref,
className,
}: {
page: number
pageSize: number
total: number
buildHref: (page: number) => string
className?: string
}) {
const t = useTranslations("announcements")
const totalPages = Math.max(1, Math.ceil(total / pageSize))
const currentPage = Math.min(Math.max(1, page), totalPages)
const pages = useMemo(() => {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1)
}
// 窗口策略:首末页 + 当前页 ±1 + 省略号占位
const result: (number | "...")[] = [1]
const start = Math.max(2, currentPage - 1)
const end = Math.min(totalPages - 1, currentPage + 1)
if (start > 2) result.push("...")
for (let i = start; i <= end; i++) result.push(i)
if (end < totalPages - 1) result.push("...")
result.push(totalPages)
return result
}, [currentPage, totalPages])
if (total === 0) return null
const isFirst = currentPage === 1
const isLast = currentPage === totalPages
return (
<nav
aria-label={t("pagination.label")}
className={cn("flex items-center justify-center gap-1", className)}
>
<Button
variant="outline"
size="icon"
asChild
aria-disabled={isFirst}
aria-label={t("pagination.prev")}
className={cn(isFirst && "pointer-events-none opacity-50")}
>
<Link href={buildHref(Math.max(1, currentPage - 1))} aria-disabled={isFirst}>
<ChevronLeft className="h-4 w-4" />
</Link>
</Button>
{pages.map((p, idx) =>
p === "..." ? (
<span
key={`ellipsis-${idx}`}
className="px-2 text-muted-foreground"
aria-hidden="true"
>
</span>
) : (
<Button
key={p}
variant={p === currentPage ? "default" : "outline"}
size="icon"
asChild
aria-current={p === currentPage ? "page" : undefined}
aria-label={t("pagination.page", { page: p })}
className={cn("min-w-[2.5rem]", p === currentPage && "pointer-events-none")}
>
<Link href={buildHref(p)}>{p}</Link>
</Button>
)
)}
<Button
variant="outline"
size="icon"
asChild
aria-disabled={isLast}
aria-label={t("pagination.next")}
className={cn(isLast && "pointer-events-none opacity-50")}
>
<Link href={buildHref(Math.min(totalPages, currentPage + 1))} aria-disabled={isLast}>
<ChevronRight className="h-4 w-4" />
</Link>
</Button>
</nav>
)
}

View File

@@ -0,0 +1,42 @@
"use client"
import { createContext, useContext, type ReactNode } from "react"
import type { AnnouncementsService } from "../types"
import { defaultAnnouncementsService } from "./default-announcements-service"
/**
* P1-3: 公告服务 React Context。
*
* 通过页面层注入 AnnouncementsService 实现,组件层使用 useAnnouncementsService() 消费,
* 避免直接 import 业务模块的 actions实现完全解耦
* - 默认实现调用真实 Server Actions
* - 测试时可注入 mock 实现
* - 不同角色可注入不同实现
*
* 未包裹 Provider 时回退到默认实现,保证向后兼容。
*/
const AnnouncementsServiceContext = createContext<AnnouncementsService | null>(null)
interface AnnouncementsServiceProviderProps {
/** 注入的服务实现;不传则使用默认实现(调用真实 Server Actions */
service?: AnnouncementsService
children: ReactNode
}
export function AnnouncementsServiceProvider({
service,
children,
}: AnnouncementsServiceProviderProps): ReactNode {
const value = service ?? defaultAnnouncementsService
return (
<AnnouncementsServiceContext.Provider value={value}>
{children}
</AnnouncementsServiceContext.Provider>
)
}
export function useAnnouncementsService(): AnnouncementsService {
const ctx = useContext(AnnouncementsServiceContext)
return ctx ?? defaultAnnouncementsService
}

View File

@@ -0,0 +1,30 @@
"use client"
import {
createAnnouncementAction,
updateAnnouncementAction,
deleteAnnouncementAction,
publishAnnouncementAction,
archiveAnnouncementAction,
toggleAnnouncementPinAction,
markAnnouncementAsReadAction,
getAnnouncementReadStatusAction,
} from "../actions"
import type { AnnouncementsService } from "../types"
/**
* P1-3: 公告服务的默认实现。
*
* 直接代理到真实 Server Actions。
* 测试或角色定制时可在 Provider 中注入其他实现以覆盖此默认行为。
*/
export const defaultAnnouncementsService: AnnouncementsService = {
create: (prevState, formData) => createAnnouncementAction(prevState, formData),
update: (id, prevState, formData) => updateAnnouncementAction(id, prevState, formData),
delete: (id) => deleteAnnouncementAction(id),
publish: (id) => publishAnnouncementAction(id),
archive: (id) => archiveAnnouncementAction(id),
togglePin: (id) => toggleAnnouncementPinAction(id),
markRead: (announcementId) => markAnnouncementAsReadAction(announcementId),
getReadStatus: (announcementIds) => getAnnouncementReadStatusAction(announcementIds),
}