"use client" import { useCallback, useMemo, useState } from "react" import Link from "next/link" import { Mail, MailOpen, Plus, Send, Inbox, Search, Loader2, ChevronLeft, ChevronRight, Star, RotateCcw, Users } 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 { useMessageSearch } from "../hooks/use-message-search" import { useMessageListService } from "../services/message-list-service-context" import type { Message, MessageType } from "../types" // P1-2: 新增 "starred" Tab 类型 type Tab = "inbox" | "sent" | "starred" const isTab = (v: string): v is Tab => v === "inbox" || v === "sent" || v === "starred" /** 客户端分页大小 */ const PAGE_SIZE = 20 export function MessageList({ messages, currentUserId, initialType = "inbox", canGroupSend = false, }: { messages: Message[] currentUserId: string initialType?: MessageType canGroupSend?: boolean }) { const t = useTranslations("messages") const [tab, setTab] = useState(initialType === "sent" ? "sent" : "inbox") const [currentPage, setCurrentPage] = useState(1) const [starredOverride, setStarredOverride] = useState>({}) const [togglingStarId, setTogglingStarId] = useState(null) const [starredMessages, setStarredMessages] = useState(null) const [loadingStarred, setLoadingStarred] = useState(false) // P2-4: 撤回状态覆盖(按消息 ID 维度,撤回成功后立即隐藏原内容) const [recalledOverride, setRecalledOverride] = useState>({}) const [recallingId, setRecallingId] = useState(null) const { hasPermission } = usePermission() const canSend = hasPermission(Permissions.MESSAGE_SEND) // P1-8: 通过依赖注入获取消息列表服务,而非直接 import actions const messageService = useMessageListService() const { keyword, setKeyword, results, searching, isUsingInitial } = useMessageSearch({ searchAction: messageService.search, tab: tab === "starred" ? "inbox" : tab, }) // P1-2: 切换到 starred Tab 时单独加载星标消息(type=all + starredOnly=true) // 由于 useMessageSearch 仅支持 inbox/sent 搜索,starred Tab 使用独立加载逻辑 const loadStarred = useCallback(async () => { setLoadingStarred(true) try { const res = await messageService.search({ type: "all", starredOnly: true }) setStarredMessages(res.success && res.data ? res.data.items : []) } catch { setStarredMessages([]) } finally { setLoadingStarred(false) } }, [messageService]) // 客户端过滤仅在初始数据(type=all)时需要,搜索结果已由服务端按 tab 过滤 // P1-2: starred Tab 使用 starredMessages 状态 const filtered = useMemo(() => { if (tab === "starred") { // starred Tab:若有搜索关键字则用搜索结果,否则用 starredMessages if (keyword.trim().length > 0 && results) { return results.filter((m) => m.receiverId === currentUserId) } return starredMessages ?? [] } 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, starredMessages, keyword]) // 客户端分页:超过 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 => { if (!isTab(v)) return setTab(v) setCurrentPage(1) // P1-2: 切换到 starred 时触发加载 if (v === "starred") { void loadStarred() } } 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 => { e.preventDefault() e.stopPropagation() setTogglingStarId(messageId) // 乐观更新 setStarredOverride((prev) => ({ ...prev, [messageId]: !currentStarred })) try { const res = await messageService.toggleStar(messageId) if (res.success) { toast.success(t("messages.starToggled")) // P1-2: 当前在 starred Tab 时重新加载星标列表 if (tab === "starred") { void loadStarred() } } 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, tab, loadStarred, messageService] ) // P2-4: 撤回消息(仅发送方,2 分钟窗口内) const handleRecall = useCallback( async (e: React.MouseEvent, message: Message): Promise => { e.preventDefault() e.stopPropagation() setRecallingId(message.id) try { const res = await messageService.recall(message.id) if (res.success) { // 同步 UI:立即显示"已撤回"占位 setRecalledOverride((prev) => ({ ...prev, [message.id]: true })) toast.success(t("messages.recalled")) } else { if (res.message === "Recall window expired") { toast.error(t("messages.recallExpired")) } else { toast.error(res.message || t("messages.recallFailed")) } } } catch { toast.error(t("messages.recallFailed")) } finally { setRecallingId(null) } }, [t, messageService] ) const getIsRecalled = (m: Message): boolean => { const override = recalledOverride[m.id] return override === undefined ? m.recalledAt !== null : override } // P1-4: 空状态文案按 Tab 区分 const getEmptyState = () => { if (tab === "starred") { return { title: t("empty.noStarred"), description: t("empty.inboxEmptyDesc"), } } return tab === "inbox" ? { title: t("empty.inboxEmpty"), description: t("empty.inboxEmptyDesc") } : { title: t("empty.sentEmpty"), description: t("empty.sentEmptyDesc") } } return (
{t("tabs.inbox")} {t("tabs.sent")} {/* P1-2: 新增星标 Tab */} {t("actions.star")} {canSend ? (
{canGroupSend ? ( ) : null}
) : null}
{/* 搜索框 */}
{paged.length === 0 ? ( ) : ( <>
{paged.map((m) => { const isReceived = m.receiverId === currentUserId const counterpart = isReceived ? m.senderName : m.receiverName const unread = isReceived && !m.isRead const isStarred = getIsStarred(m) // P2-4: 撤回状态 const isRecalled = getIsRecalled(m) const canRecallItem = !isReceived && canSend && !isRecalled && Date.now() - new Date(m.createdAt).getTime() <= 2 * 60 * 1000 return (
{isRecalled ? (

{isReceived ? t("meta.from") : t("meta.to")}: {counterpart ?? t("meta.unknown")}

{/* P2-4: 撤回按钮(仅发送方在 2 分钟窗口内可见) */} {canRecallItem ? ( ) : null} {formatDate(m.createdAt)}
{/* P2-4: 已撤回消息显示占位文案,隐藏原始内容 */} {isRecalled ? (

{t("status.recalled")}

) : (

{m.content}

)}
) })}
{showPagination ? (
{t("pagination.page", { current: safePage, total: totalPages })}
) : null} )}
) }