diff --git a/src/app/(dashboard)/announcements/[id]/page.tsx b/src/app/(dashboard)/announcements/[id]/page.tsx index d10938d..0ec042b 100644 --- a/src/app/(dashboard)/announcements/[id]/page.tsx +++ b/src/app/(dashboard)/announcements/[id]/page.tsx @@ -5,7 +5,7 @@ import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" -import { getAnnouncementById } from "@/modules/announcements/data-access" +import { getAnnouncementById, isAnnouncementReadByUser } from "@/modules/announcements/data-access" import { AnnouncementDetail } from "@/modules/announcements/components/announcement-detail" export const dynamic = "force-dynamic" @@ -21,15 +21,18 @@ export default async function AnnouncementDetailPage({ params: Promise<{ id: string }> }): Promise { const { id } = await params - await requirePermission(Permissions.ANNOUNCEMENT_READ) + const ctx = await requirePermission(Permissions.ANNOUNCEMENT_READ) const announcement = await getAnnouncementById(id) if (!announcement) notFound() + // 获取当前用户的已读状态 + const isReadByCurrentUser = await isAnnouncementReadByUser(id, ctx.userId) + return (
diff --git a/src/modules/announcements/components/announcement-card.tsx b/src/modules/announcements/components/announcement-card.tsx index e69f5f6..ce53b5c 100644 --- a/src/modules/announcements/components/announcement-card.tsx +++ b/src/modules/announcements/components/announcement-card.tsx @@ -1,21 +1,57 @@ "use client" +import { useState } from "react" import Link from "next/link" +import { useRouter } from "next/navigation" +import { toast } from "sonner" import { useTranslations } from "next-intl" +import { Pin } from "lucide-react" import { Badge } from "@/shared/components/ui/badge" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" -import { formatDate } from "@/shared/lib/utils" +import { cn, formatDate } from "@/shared/lib/utils" +import { toggleAnnouncementPinAction } from "../actions" import type { Announcement } from "../types" export function AnnouncementCard({ announcement, href, + canManage, }: { announcement: Announcement href?: string + canManage?: boolean }) { const t = useTranslations("announcements") + const router = useRouter() + const [isPinned, setIsPinned] = useState(announcement.isPinned) + const [isToggling, setIsToggling] = useState(false) + + const handleTogglePin = async (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + setIsToggling(true) + const prevPinned = isPinned + // 乐观更新 + setIsPinned(!prevPinned) + try { + const res = await toggleAnnouncementPinAction(announcement.id) + if (res.success) { + toast.success(t("messages.pinToggled")) + router.refresh() + } else { + // 回滚 + setIsPinned(prevPinned) + toast.error(res.message) + } + } catch { + // 回滚 + setIsPinned(prevPinned) + toast.error(t("messages.publishFailed")) + } finally { + setIsToggling(false) + } + } const statusVariant: Record = { draft: "secondary", @@ -24,12 +60,37 @@ export function AnnouncementCard({ } const card = ( - + - {announcement.title} - - {t(`status.${announcement.status}`)} - + + {isPinned ? ( + +
+ {isPinned ? ( + + {t("status.pinned")} + + ) : null} + + {t(`status.${announcement.status}`)} + + {canManage ? ( + + ) : null} +

diff --git a/src/modules/announcements/components/announcement-detail.tsx b/src/modules/announcements/components/announcement-detail.tsx index 9cd9779..05c8a85 100644 --- a/src/modules/announcements/components/announcement-detail.tsx +++ b/src/modules/announcements/components/announcement-detail.tsx @@ -1,6 +1,6 @@ "use client" -import { useState } from "react" +import { useEffect, useState } from "react" import Link from "next/link" import { useRouter } from "next/navigation" import { toast } from "sonner" @@ -8,8 +8,10 @@ import { useTranslations } from "next-intl" import { Archive, ArrowLeft, + CheckCircle2, Megaphone, Pencil, + Pin, Send, Trash2, } from "lucide-react" @@ -18,12 +20,14 @@ import { Badge } from "@/shared/components/ui/badge" import { Button } from "@/shared/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { ConfirmDeleteDialog } from "@/shared/components/ui/confirm-delete-dialog" -import { formatDate } from "@/shared/lib/utils" +import { cn, formatDate } from "@/shared/lib/utils" import { archiveAnnouncementAction, deleteAnnouncementAction, + markAnnouncementAsReadAction, publishAnnouncementAction, + toggleAnnouncementPinAction, } from "../actions" import type { Announcement } from "../types" @@ -42,6 +46,31 @@ export function AnnouncementDetail({ const router = useRouter() const [isWorking, setIsWorking] = useState(false) const [deleteOpen, setDeleteOpen] = useState(false) + const [isPinned, setIsPinned] = useState(announcement.isPinned) + const [isTogglingPin, setIsTogglingPin] = useState(false) + const [isRead, setIsRead] = useState(announcement.isReadByCurrentUser ?? false) + + // 用户端自动标记已读(非管理端且未读时) + useEffect(() => { + if (canManage) return + if (isRead) return + + let cancelled = false + void markAnnouncementAsReadAction(announcement.id) + .then((res) => { + if (cancelled) return + if (res.success) { + setIsRead(true) + } + }) + .catch(() => { + // 静默处理 + }) + + return () => { + cancelled = true + } + }, [canManage, isRead, announcement.id]) const handlePublish = async () => { setIsWorking(true) @@ -96,6 +125,30 @@ export function AnnouncementDetail({ } } + const handleTogglePin = async () => { + setIsTogglingPin(true) + const prevPinned = isPinned + // 乐观更新 + setIsPinned(!prevPinned) + try { + const res = await toggleAnnouncementPinAction(announcement.id) + if (res.success) { + toast.success(t("messages.pinToggled")) + router.refresh() + } else { + // 回滚 + setIsPinned(prevPinned) + toast.error(res.message) + } + } catch { + // 回滚 + setIsPinned(prevPinned) + toast.error(t("messages.publishFailed")) + } finally { + setIsTogglingPin(false) + } + } + return (

@@ -111,6 +164,10 @@ export function AnnouncementDetail({
{canManage ? (
+ {announcement.status !== "published" ? (
- {announcement.title} + + {isPinned ? ( +
diff --git a/src/modules/announcements/components/announcement-list.tsx b/src/modules/announcements/components/announcement-list.tsx index 7a492d0..cc09f24 100644 --- a/src/modules/announcements/components/announcement-list.tsx +++ b/src/modules/announcements/components/announcement-list.tsx @@ -116,6 +116,7 @@ export function AnnouncementList({ key={a.id} announcement={a} href={buildDetailHref(a.id)} + canManage={canManage} /> ))}
diff --git a/src/modules/messaging/components/message-compose.tsx b/src/modules/messaging/components/message-compose.tsx index 111b050..02ca4f1 100644 --- a/src/modules/messaging/components/message-compose.tsx +++ b/src/modules/messaging/components/message-compose.tsx @@ -1,11 +1,11 @@ "use client" -import { useState } from "react" +import { useEffect, useRef, useState } from "react" import Link from "next/link" import { useRouter } from "next/navigation" import { toast } from "sonner" import { useTranslations } from "next-intl" -import { ArrowLeft, Send } from "lucide-react" +import { ArrowLeft, Check, Loader2, Save, Send } from "lucide-react" import { Button } from "@/shared/components/ui/button" import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card" @@ -20,10 +20,14 @@ import { SelectValue, } from "@/shared/components/ui/select" -import { sendMessageAction } from "../actions" +import { deleteMessageDraftAction, saveMessageDraftAction, sendMessageAction } from "../actions" import type { RecipientOption } from "../types" type FieldErrors = Record +type SaveStatus = "idle" | "saving" | "saved" + +/** 自动保存防抖延迟(毫秒) */ +const AUTOSAVE_DEBOUNCE_MS = 2000 export function MessageCompose({ recipients, @@ -42,13 +46,61 @@ export function MessageCompose({ const router = useRouter() const [isWorking, setIsWorking] = useState(false) const [receiverId, setReceiverId] = useState(defaultReceiverId ?? "") + const [subject, setSubject] = useState(defaultSubject ?? "") + const [content, setContent] = useState("") const [fieldErrors, setFieldErrors] = useState({}) + const [draftId, setDraftId] = useState(null) + const [saveStatus, setSaveStatus] = useState("idle") + const draftIdRef = useRef(null) + + // Keep draftIdRef in sync so auto-save callback reads the latest value + useEffect(() => { + draftIdRef.current = draftId + }, [draftId]) const getFieldError = (field: string): string | null => { const errs = fieldErrors[field] return errs && errs.length > 0 ? errs[0] : null } + // 自动保存草稿(防抖 2 秒) + useEffect(() => { + // 没有内容时不保存 + if (!subject.trim() && !content.trim()) return + + let cancelled = false + const timeout = setTimeout(() => { + if (cancelled) return + const formData = new FormData() + const currentDraftId = draftIdRef.current + if (currentDraftId) formData.set("draftId", currentDraftId) + if (receiverId) formData.set("receiverId", receiverId) + if (subject) formData.set("subject", subject) + if (content) formData.set("content", content) + if (parentMessageId) formData.set("parentMessageId", parentMessageId) + + setSaveStatus("saving") + void saveMessageDraftAction(null, formData) + .then((res) => { + if (cancelled) return + if (res.success && res.data) { + setDraftId(res.data) + setSaveStatus("saved") + } else { + setSaveStatus("idle") + } + }) + .catch(() => { + if (!cancelled) setSaveStatus("idle") + }) + }, AUTOSAVE_DEBOUNCE_MS) + + return () => { + cancelled = true + clearTimeout(timeout) + } + }, [subject, content, receiverId, parentMessageId]) + const handleSubmit = async (formData: FormData) => { if (!receiverId) { toast.error(t("form.selectRecipient")) @@ -64,6 +116,10 @@ export function MessageCompose({ try { const res = await sendMessageAction(null, formData) if (res.success) { + // 发送成功后删除草稿(如有) + if (draftIdRef.current) { + void deleteMessageDraftAction(draftIdRef.current) + } toast.success(res.message) router.push("/messages") router.refresh() @@ -81,16 +137,59 @@ export function MessageCompose({ } } + const handleSaveDraft = async () => { + setSaveStatus("saving") + const formData = new FormData() + if (draftId) formData.set("draftId", draftId) + if (receiverId) formData.set("receiverId", receiverId) + if (subject) formData.set("subject", subject) + if (content) formData.set("content", content) + if (parentMessageId) formData.set("parentMessageId", parentMessageId) + + try { + const res = await saveMessageDraftAction(null, formData) + if (res.success && res.data) { + setDraftId(res.data) + setSaveStatus("saved") + toast.success(t("messages.draftSaved")) + } else { + setSaveStatus("idle") + toast.error(res.message) + } + } catch { + setSaveStatus("idle") + toast.error(t("messages.sendFailed")) + } + } + return ( -
- - {parentMessageId ? t("title.reply") : t("title.newMessage")} +
+
+ + {parentMessageId ? t("title.reply") : t("title.newMessage")} +
+ {/* 草稿保存状态指示器 */} + {saveStatus !== "idle" ? ( + + {saveStatus === "saving" ? ( + <> + + ) : null}
@@ -122,7 +221,8 @@ export function MessageCompose({ id="subject" name="subject" placeholder={t("form.subjectPlaceholder")} - defaultValue={defaultSubject ?? ""} + value={subject} + onChange={(e) => setSubject(e.target.value)} maxLength={255} aria-invalid={!!getFieldError("subject")} /> @@ -138,6 +238,8 @@ export function MessageCompose({ name="content" placeholder={t("form.contentPlaceholder")} className="min-h-[200px]" + value={content} + onChange={(e) => setContent(e.target.value)} required aria-invalid={!!getFieldError("content")} /> @@ -150,6 +252,15 @@ export function MessageCompose({ +
+ {canSend ? (
{message.subject ?? t("meta.noSubject")}
diff --git a/src/modules/messaging/components/message-list.tsx b/src/modules/messaging/components/message-list.tsx index df019bc..5359e8d 100644 --- a/src/modules/messaging/components/message-list.tsx +++ b/src/modules/messaging/components/message-list.tsx @@ -1,9 +1,10 @@ "use client" -import { useMemo, useState } from "react" +import { useCallback, useMemo, useState } from "react" import Link from "next/link" -import { Mail, MailOpen, Plus, Send, Inbox, Search, Loader2, ChevronLeft, ChevronRight } from "lucide-react" +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" @@ -15,7 +16,7 @@ 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 { getMessagesAction, toggleMessageStarAction } from "../actions" import { useMessageSearch } from "../hooks/use-message-search" import type { Message, MessageType } from "../types" @@ -36,6 +37,8 @@ export function MessageList({ 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 { hasPermission } = usePermission() const canSend = hasPermission(Permissions.MESSAGE_SEND) @@ -69,6 +72,38 @@ export function MessageList({ 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 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 (
@@ -128,6 +163,7 @@ export function MessageList({ const isReceived = m.receiverId === currentUserId const counterpart = isReceived ? m.senderName : m.receiverName const unread = isReceived && !m.isRead + const isStarred = getIsStarred(m) return ( @@ -143,14 +179,33 @@ export function MessageList({ {m.subject ?? t("meta.noSubject")} {unread ? {t("status.new")} : null} + {isStarred ? ( +

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

- - {formatDate(m.createdAt)} - +
+ + + {formatDate(m.createdAt)} + +

diff --git a/src/shared/i18n/messages/en/announcements.json b/src/shared/i18n/messages/en/announcements.json index f5dbc90..6ba7535 100644 --- a/src/shared/i18n/messages/en/announcements.json +++ b/src/shared/i18n/messages/en/announcements.json @@ -81,7 +81,8 @@ "publishedAt": "Published {date}", "createdAt": "Created {date}", "updatedAt": "Updated {date}", - "author": "by {name}" + "author": "by {name}", + "readCount": "{count} read" }, "empty": { "noAnnouncements": "No announcements", diff --git a/src/shared/i18n/messages/zh-CN/announcements.json b/src/shared/i18n/messages/zh-CN/announcements.json index 909a9b0..dda869a 100644 --- a/src/shared/i18n/messages/zh-CN/announcements.json +++ b/src/shared/i18n/messages/zh-CN/announcements.json @@ -81,7 +81,8 @@ "publishedAt": "发布于 {date}", "createdAt": "创建于 {date}", "updatedAt": "更新于 {date}", - "author": "作者:{name}" + "author": "作者:{name}", + "readCount": "{count} 人已读" }, "empty": { "noAnnouncements": "暂无公告",