Files
NextEdu/src/modules/announcements/components/announcement-list.tsx
SpecialX 276577b66c feat(messaging,announcements): 前端 UI 集成星标/草稿/置顶/已读回执
- 消息星标:MessageList 卡片星标按钮(乐观更新+回滚)、MessageDetail 头部星标切换
- 消息草稿:MessageCompose 自动保存(2s 防抖)+ 手动保存按钮 + 状态指示器 + 发送后清理草稿
- 公告置顶:AnnouncementCard 管理端置顶按钮、AnnouncementDetail 置顶切换、置顶 Badge
- 公告已读回执:用户端详情页自动标记已读 + 已读/未读 Badge、管理端已读人数显示
- i18n:新增 announcements.meta.readCount 翻译键
2026-06-23 17:24:26 +08:00

127 lines
3.8 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 Link from "next/link"
import { useRouter } from "next/navigation"
import { Plus, Megaphone } from "lucide-react"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { AnnouncementCard } from "./announcement-card"
import type { Announcement, AnnouncementStatus } from "../types"
type Filter = "all" | AnnouncementStatus
/**
* 公告列表组件。
*
* 过滤模式:纯服务端过滤。
* - Select 切换时更新 URL `?status=`,触发 RSC 重新渲染
* - 父页面根据 `?status=` 查询并传入 `announcements` prop
* - 组件不再做客户端二次过滤,避免双重过滤逻辑冗余
*
* 详情链接构建:
* - `detailHrefPrefix`:推荐方式,适用于 Server Component前缀 + id 拼接)
* - `detailHrefBuilder`:仅适用于 Client Component 之间的调用
*/
export function AnnouncementList({
announcements,
canManage,
createHref,
detailHrefBuilder,
detailHrefPrefix,
initialStatus,
}: {
announcements: Announcement[]
canManage?: boolean
createHref?: string
detailHrefBuilder?: (id: string) => string
detailHrefPrefix?: string
initialStatus?: Filter
}) {
const t = useTranslations("announcements")
const router = useRouter()
const filter: Filter = initialStatus ?? "all"
const filterOptions: { value: Filter; label: string }[] = [
{ value: "all", label: t("filter.all") },
{ value: "published", label: t("filter.published") },
{ value: "draft", label: t("filter.draft") },
{ value: "archived", label: t("filter.archived") },
]
const handleFilterChange = (value: string): void => {
const params = new URLSearchParams()
if (value !== "all") params.set("status", value)
const qs = params.toString()
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">
<Select value={filter} onValueChange={handleFilterChange}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder={t("filter.placeholder")} />
</SelectTrigger>
<SelectContent>
{filterOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{canManage && createHref ? (
<Button asChild>
<Link href={createHref}>
<Plus className="mr-2 h-4 w-4" />
{t("actions.new")}
</Link>
</Button>
) : null}
</div>
{announcements.length === 0 ? (
<EmptyState
title={t("empty.noAnnouncements")}
description={
filter === "all"
? t("empty.noAnnouncementsDesc")
: t("empty.noMatch")
}
icon={Megaphone}
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>
)}
</div>
)
}