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

@@ -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>