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