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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
303
src/modules/announcements/components/announcement-card.test.tsx
Normal file
303
src/modules/announcements/components/announcement-card.test.tsx
Normal 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/navigation:router.refresh 不做任何事
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
refresh: vi.fn(),
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock sonner:toast.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("渲染类型 badge(school)", () => {
|
||||
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("a11y(P2-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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}` : "?")
|
||||
}
|
||||
|
||||
// 构建详情链接:优先使用 detailHrefPrefix(Server 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>
|
||||
|
||||
116
src/modules/announcements/components/announcement-pagination.tsx
Normal file
116
src/modules/announcements/components/announcement-pagination.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
Reference in New Issue
Block a user