- 消息星标:MessageList 卡片星标按钮(乐观更新+回滚)、MessageDetail 头部星标切换 - 消息草稿:MessageCompose 自动保存(2s 防抖)+ 手动保存按钮 + 状态指示器 + 发送后清理草稿 - 公告置顶:AnnouncementCard 管理端置顶按钮、AnnouncementDetail 置顶切换、置顶 Badge - 公告已读回执:用户端详情页自动标记已读 + 已读/未读 Badge、管理端已读人数显示 - i18n:新增 announcements.meta.readCount 翻译键
127 lines
3.8 KiB
TypeScript
127 lines
3.8 KiB
TypeScript
"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}` : "?")
|
||
}
|
||
|
||
// 构建详情链接:优先使用 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">
|
||
<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>
|
||
)
|
||
}
|