- audit: update retention.ts, types.ts, audit-retention-settings.tsx - auth: update actions.ts and types.ts - course-plans: update actions.ts, course-plan-detail.tsx, template-picker-dialog.tsx, add lib/track-event.ts - announcements: update page.tsx, announcement-list.tsx, announcement-pagination.tsx - exams: update actions.ts
166 lines
5.2 KiB
TypeScript
166 lines
5.2 KiB
TypeScript
"use client"
|
||
|
||
import { useEffect, useState } from "react"
|
||
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 { AnnouncementPagination } from "./announcement-pagination"
|
||
import { useAnnouncementsService } from "./announcements-service-context"
|
||
import type { Announcement, AnnouncementStatus } from "../types"
|
||
|
||
type Filter = "all" | AnnouncementStatus
|
||
|
||
/**
|
||
* 公告列表组件。
|
||
*
|
||
* 过滤模式:纯服务端过滤。
|
||
* - Select 切换时更新 URL `?status=`,触发 RSC 重新渲染
|
||
* - 父页面根据 `?status=` 查询并传入 `announcements` prop
|
||
* - 组件不再做客户端二次过滤,避免双重过滤逻辑冗余
|
||
*
|
||
* P2-4: 删除了死 prop `detailHrefBuilder`,仅保留 `detailHrefPrefix`(Server Component 安全)。
|
||
* P1-2: 用户端(canManage=false)时调用 `getReadStatus` 批量获取已读状态,
|
||
* 传入卡片做已读/未读视觉区分。
|
||
* P2-6: 新增分页支持。传入 `pagination` prop 时在列表底部渲染 `AnnouncementPagination`。
|
||
* v3: pagination 不再接收 `buildPageHref` 函数(Server Component 不能传函数),
|
||
* 改为传 `basePath` + `statusFilter`,由 AnnouncementPagination 自行构建 URL。
|
||
*/
|
||
export function AnnouncementList({
|
||
announcements,
|
||
canManage,
|
||
createHref,
|
||
detailHrefPrefix,
|
||
initialStatus,
|
||
pagination,
|
||
}: {
|
||
announcements: Announcement[]
|
||
canManage?: boolean
|
||
createHref?: string
|
||
detailHrefPrefix?: string
|
||
initialStatus?: Filter
|
||
pagination?: {
|
||
page: number
|
||
pageSize: number
|
||
total: number
|
||
basePath: string
|
||
statusFilter?: 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") },
|
||
{ 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}` : "?")
|
||
}
|
||
|
||
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="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}
|
||
basePath={pagination.basePath}
|
||
statusFilter={pagination.statusFilter}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|