Files
NextEdu/src/modules/messaging/components/message-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

251 lines
9.9 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 { useCallback, useMemo, useState } from "react"
import Link from "next/link"
import { Mail, MailOpen, Plus, Send, Inbox, Search, Loader2, ChevronLeft, ChevronRight, Star } from "lucide-react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Input } from "@/shared/components/ui/input"
import { Tabs, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
import { cn, formatDate } from "@/shared/lib/utils"
import { usePermission } from "@/shared/hooks/use-permission"
import { Permissions } from "@/shared/types/permissions"
import { getMessagesAction, toggleMessageStarAction } from "../actions"
import { useMessageSearch } from "../hooks/use-message-search"
import type { Message, MessageType } from "../types"
type Tab = "inbox" | "sent"
/** 客户端分页大小 */
const PAGE_SIZE = 20
export function MessageList({
messages,
currentUserId,
initialType = "inbox",
}: {
messages: Message[]
currentUserId: string
initialType?: MessageType
}) {
const t = useTranslations("messages")
const [tab, setTab] = useState<Tab>(initialType === "sent" ? "sent" : "inbox")
const [currentPage, setCurrentPage] = useState(1)
const [starredOverride, setStarredOverride] = useState<Record<string, boolean>>({})
const [togglingStarId, setTogglingStarId] = useState<string | null>(null)
const { hasPermission } = usePermission()
const canSend = hasPermission(Permissions.MESSAGE_SEND)
const { keyword, setKeyword, results, searching, isUsingInitial } = useMessageSearch({
searchAction: getMessagesAction,
tab,
})
// 客户端过滤仅在初始数据type=all时需要搜索结果已由服务端按 tab 过滤
const filtered = useMemo(() => {
const displayMessages = isUsingInitial ? messages : (results ?? [])
if (!isUsingInitial) return displayMessages
if (tab === "inbox") return displayMessages.filter((m) => m.receiverId === currentUserId)
return displayMessages.filter((m) => m.senderId === currentUserId)
}, [messages, results, tab, currentUserId, isUsingInitial])
// 客户端分页:超过 PAGE_SIZE 条时显示分页 UI
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
const safePage = Math.min(currentPage, totalPages)
const paged = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE)
const showPagination = filtered.length > PAGE_SIZE
// 切换 tab 或搜索时重置分页
const handleTabChange = (v: string): void => {
setTab(v as Tab)
setCurrentPage(1)
}
const handleKeywordChange = (v: string): void => {
setKeyword(v)
setCurrentPage(1)
}
const getIsStarred = (m: Message): boolean => {
const override = starredOverride[m.id]
return override === undefined ? m.isStarred : override
}
const handleToggleStar = useCallback(
async (e: React.MouseEvent, messageId: string, currentStarred: boolean): Promise<void> => {
e.preventDefault()
e.stopPropagation()
setTogglingStarId(messageId)
// 乐观更新
setStarredOverride((prev) => ({ ...prev, [messageId]: !currentStarred }))
try {
const res = await toggleMessageStarAction(messageId)
if (res.success) {
toast.success(t("messages.starToggled"))
} else {
// 回滚
setStarredOverride((prev) => ({ ...prev, [messageId]: currentStarred }))
toast.error(res.message)
}
} catch {
// 回滚
setStarredOverride((prev) => ({ ...prev, [messageId]: currentStarred }))
toast.error(t("messages.sendFailed"))
} finally {
setTogglingStarId(null)
}
},
[t]
)
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<Tabs value={tab} onValueChange={handleTabChange}>
<TabsList>
<TabsTrigger value="inbox" className="gap-2">
<Inbox className="h-4 w-4" />
{t("tabs.inbox")}
</TabsTrigger>
<TabsTrigger value="sent" className="gap-2">
<Send className="h-4 w-4" />
{t("tabs.sent")}
</TabsTrigger>
</TabsList>
</Tabs>
{canSend ? (
<Button asChild>
<Link href="/messages/compose">
<Plus className="mr-2 h-4 w-4" />
{t("actions.compose")}
</Link>
</Button>
) : null}
</div>
{/* 搜索框 */}
<div className="relative">
<Search className="text-muted-foreground absolute left-3 top-1/2 size-4 -translate-y-1/2" aria-hidden="true" />
<Input
type="search"
aria-label={t("search.placeholder")}
placeholder={t("search.placeholder")}
value={keyword}
onChange={(e) => handleKeywordChange(e.target.value)}
className="pl-9"
/>
{searching ? (
<Loader2 className="text-muted-foreground absolute right-3 top-1/2 size-4 -translate-y-1/2 animate-spin" aria-hidden="true" />
) : null}
</div>
{paged.length === 0 ? (
<EmptyState
title={tab === "inbox" ? t("empty.inboxEmpty") : t("empty.sentEmpty")}
description={
tab === "inbox"
? t("empty.inboxEmptyDesc")
: t("empty.sentEmptyDesc")
}
icon={Mail}
className="h-auto border-none shadow-none"
/>
) : (
<>
<div className="space-y-3">
{paged.map((m) => {
const isReceived = m.receiverId === currentUserId
const counterpart = isReceived ? m.senderName : m.receiverName
const unread = isReceived && !m.isRead
const isStarred = getIsStarred(m)
return (
<Link key={m.id} href={`/messages/${m.id}`} className="block" aria-label={m.subject ?? t("meta.noSubject")}>
<Card className={cn("transition-colors hover:bg-accent/50", unread && "border-primary/40")}>
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0 pb-3">
<div className="space-y-1">
<div className="flex items-center gap-2">
{unread ? (
<Mail className="h-4 w-4 text-primary" aria-hidden="true" />
) : (
<MailOpen className="text-muted-foreground h-4 w-4" aria-hidden="true" />
)}
<span className={cn("text-sm font-medium", unread && "text-primary")}>
{m.subject ?? t("meta.noSubject")}
</span>
{unread ? <Badge variant="default" className="text-xs">{t("status.new")}</Badge> : null}
{isStarred ? (
<Star className="h-3.5 w-3.5 fill-yellow-400 text-yellow-400" aria-hidden="true" />
) : null}
</div>
<p className="text-muted-foreground text-xs">
{isReceived ? t("meta.from") : t("meta.to")}: {counterpart ?? t("meta.unknown")}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onClick={(e) => void handleToggleStar(e, m.id, isStarred)}
disabled={togglingStarId === m.id}
aria-label={isStarred ? t("actions.unstar") : t("actions.star")}
className="text-muted-foreground hover:text-foreground inline-flex size-7 items-center justify-center rounded-md transition-colors hover:bg-accent disabled:opacity-50"
>
<Star
className={cn(
"h-4 w-4 transition-colors",
isStarred && "fill-yellow-400 text-yellow-400"
)}
/>
</button>
<span className="text-muted-foreground text-xs">
{formatDate(m.createdAt)}
</span>
</div>
</CardHeader>
<CardContent className="pt-0">
<p className="text-muted-foreground line-clamp-2 text-sm whitespace-pre-wrap">
{m.content}
</p>
</CardContent>
</Card>
</Link>
)
})}
</div>
{showPagination ? (
<div className="flex items-center justify-center gap-4 pt-2" role="navigation" aria-label={t("tabs.inbox")}>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={safePage <= 1}
aria-label="Previous page"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-muted-foreground text-sm" aria-live="polite">
{safePage} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={safePage >= totalPages}
aria-label="Next page"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
) : null}
</>
)}
</div>
)
}