- 新增审计报告 docs/architecture/audit/announcements-messages-audit-report.md - 新增中英双语 i18n 字典 announcements.json / messages.json(11/13 个命名空间) - 重构所有 announcements 和 messaging 组件接入 next-intl(useTranslations) - 所有页面 page.tsx 使用 generateMetadata + getTranslations 替代硬编码 metadata - 新增 7 个 error.tsx 错误边界(4 公告 + 3 消息),统一 EmptyState + i18n + 重试 - a11y 改进:announcement-card / message-list / notification-dropdown 添加 aria-label - 同步架构图 004 和 005:i18n.messages 清单 + 已知问题修复记录
174 lines
6.5 KiB
TypeScript
174 lines
6.5 KiB
TypeScript
"use client"
|
||
|
||
import { useEffect, useMemo, useState } from "react"
|
||
import Link from "next/link"
|
||
import { Mail, MailOpen, Plus, Send, Inbox, Search, Loader2 } from "lucide-react"
|
||
import { useTranslations } from "next-intl"
|
||
|
||
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 } from "../actions"
|
||
import type { Message, MessageType } from "../types"
|
||
|
||
type Tab = "inbox" | "sent"
|
||
|
||
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 [keyword, setKeyword] = useState("")
|
||
const [searchResults, setSearchResults] = useState<{ kw: string; tab: Tab; items: Message[] } | null>(null)
|
||
const { hasPermission } = usePermission()
|
||
const canSend = hasPermission(Permissions.MESSAGE_SEND)
|
||
|
||
// 防抖搜索:keyword 或 tab 变化时调用 getMessagesAction
|
||
useEffect(() => {
|
||
const kw = keyword.trim()
|
||
if (kw.length === 0) {
|
||
return
|
||
}
|
||
|
||
let cancelled = false
|
||
const timer = setTimeout(async () => {
|
||
if (cancelled) return
|
||
const res = await getMessagesAction({ type: tab, keyword: kw })
|
||
if (cancelled) return
|
||
if (res.success && res.data) {
|
||
setSearchResults({ kw, tab, items: res.data.items })
|
||
}
|
||
}, 400)
|
||
|
||
return () => {
|
||
cancelled = true
|
||
clearTimeout(timer)
|
||
}
|
||
}, [keyword, tab])
|
||
|
||
// 当前搜索结果是否匹配最新的 keyword 和 tab
|
||
const currentResults = searchResults && searchResults.kw === keyword.trim() && searchResults.tab === tab
|
||
? searchResults.items
|
||
: null
|
||
|
||
// 搜索中:keyword 非空且尚无匹配结果
|
||
const searching = keyword.trim().length > 0 && currentResults === null
|
||
|
||
// 当 keyword 为空时使用 prop messages,否则使用搜索结果
|
||
const displayMessages = currentResults ?? messages
|
||
|
||
const filtered = useMemo(() => {
|
||
if (tab === "inbox") return displayMessages.filter((m) => m.receiverId === currentUserId)
|
||
return displayMessages.filter((m) => m.senderId === currentUserId)
|
||
}, [displayMessages, tab, currentUserId])
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<Tabs value={tab} onValueChange={(v) => setTab(v as Tab)}>
|
||
<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) => setKeyword(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>
|
||
|
||
{filtered.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">
|
||
{filtered.map((m) => {
|
||
const isReceived = m.receiverId === currentUserId
|
||
const counterpart = isReceived ? m.senderName : m.receiverName
|
||
const unread = isReceived && !m.isRead
|
||
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}
|
||
</div>
|
||
<p className="text-muted-foreground text-xs">
|
||
{isReceived ? t("meta.from") : t("meta.to")}: {counterpart ?? t("meta.unknown")}
|
||
</p>
|
||
</div>
|
||
<span className="text-muted-foreground shrink-0 text-xs">
|
||
{formatDate(m.createdAt)}
|
||
</span>
|
||
</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>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|