Files
jiang13-bbs/frontend/app/notifications/NotificationsClient.tsx
freefire ff2ab286fb feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
2026-10-01 03:06:05 +08:00

247 lines
11 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 { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Heart, MessageCircle, CheckCheck, Bell, CheckCircle2, XCircle, AtSign, ClipboardList, Award, AlertTriangle, UserPlus, ChevronRight } from "lucide-react";
import { apiMarkRead, apiMarkAllRead, type NotificationsResponse, type NotificationItem } from "@/lib/api";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { postPath, userPath, commentAnchor } from "@/lib/urlStyle";
/**
* 通知列表交互层:数据由服务端 SSR 直出,这里只承担已读标记等客户端操作。
* 布局:左栏 sticky 概览(未读数/全部已读)+ 右列紧凑列表(单条两行内)。
*/
export default function NotificationsClient({
initialData,
initialUnread,
children,
}: {
initialData: NotificationsResponse;
initialUnread: number;
children?: React.ReactNode;
}) {
const router = useRouter();
const urlStyle = useUrlStyle();
const [notifications, setNotifications] = useState<NotificationItem[]>(initialData.notifications || []);
const [unreadCount, setUnreadCount] = useState(initialUnread);
const handleClick = async (n: NotificationItem) => {
if (!n.is_read) {
apiMarkRead(n.id).catch(() => {});
setNotifications((list) =>
list.map((item) => (item.id === n.id ? { ...item, is_read: true } : item))
);
setUnreadCount((c) => Math.max(0, c - 1));
}
// @提及通知跳到群聊房间;徽章通知无关联帖子(仅标已读);关注通知跳到关注者主页;其余跳到帖子/评论
if (n.type === "mention" && n.room_id) {
router.push(`/chat/${n.room_id}`);
} else if (n.type === "pending_review") {
router.push(n.post_id ? postPath(urlStyle, n.post_id) : "/admin/content?status=pending");
} else if (n.type === "badge") {
return;
} else if (n.type === "follow") {
router.push(userPath(urlStyle, n.actor_id));
} else if (n.comment_id > 0 && n.comment_floor) {
// 评论/回复/点赞等:直达评论所在楼层(跨页由帖子页深链定位换页)
router.push(
`${postPath(urlStyle, n.post_id)}${commentAnchor(n.comment_floor, n.comment_is_root ?? true, n.comment_id)}`
);
} else {
router.push(postPath(urlStyle, n.post_id));
}
};
const handleMarkAll = async () => {
await apiMarkAllRead();
setNotifications((list) => list.map((item) => ({ ...item, is_read: true })));
setUnreadCount(0);
// 同步刷新 Header 未读红点(layout 重新取 unread_count)
router.refresh();
};
const formatTime = (dateStr: string) => {
const diff = Date.now() - new Date(dateStr).getTime();
const min = Math.floor(diff / 60000);
if (min < 1) return "刚刚";
if (min < 60) return `${min} 分钟前`;
const hour = Math.floor(min / 60);
if (hour < 24) return `${hour} 小时前`;
return `${Math.floor(hour / 24)} 天前`;
};
const iconOf = (n: NotificationItem) => {
const t = n.type;
const node =
t === "approved" ? <CheckCircle2 size={15} />
: t === "rejected" || t === "deleted" ? <XCircle size={15} />
: t === "necro_reply" ? <AlertTriangle size={15} />
: t === "like" ? <Heart size={15} />
: t === "mention" ? <AtSign size={15} />
: t === "pending_review" ? <ClipboardList size={15} />
: t === "badge" ? <Award size={15} />
: t === "follow" ? <UserPlus size={15} />
: <MessageCircle size={15} />;
const color =
t === "approved" ? "var(--ok)"
: t === "rejected" || t === "deleted" || t === "necro_reply" ? "var(--danger)"
: t === "like" ? "var(--clay)"
: t === "badge" ? "var(--gold)"
: "var(--accent)";
const bg =
t === "approved" ? "var(--ok-soft)"
: t === "rejected" || t === "deleted" || t === "necro_reply" ? "var(--danger-soft)"
: t === "like" ? "var(--clay-soft)"
: t === "badge" ? "var(--gold-soft)"
: "var(--accent-soft)";
return { node, color, bg };
};
return (
<>
{/* 左栏:概览 + 全部已读(lg 起 sticky) */}
<aside className="panel p-4 sm:p-5 lg:sticky lg:top-[var(--rail-top)]">
<p className="text-xs font-bold tracking-widest uppercase" style={{ color: "var(--ink-3)" }}>概览</p>
<div className="mt-2.5 flex items-baseline gap-1.5">
<span
className="text-[30px] leading-none font-extrabold"
style={{ color: unreadCount > 0 ? "var(--accent)" : "var(--ink)", fontVariantNumeric: "tabular-nums" }}
>
{unreadCount}
</span>
<span className="meta">条未读</span>
</div>
<p className="meta mt-1.5">共 {initialData.total} 条通知</p>
<div className="my-3.5" style={{ borderTop: "1px solid var(--line)" }} />
{unreadCount > 0 ? (
<button onClick={handleMarkAll} className="btn btn-line btn-sm w-full justify-center">
<CheckCheck size={14} /> 全部已读
</button>
) : (
<p className="meta flex items-center gap-1.5" style={{ color: "var(--ok)" }}>
<CheckCircle2 size={14} /> 通知都已读完
</p>
)}
</aside>
{/* 右列:紧凑通知列表 + 分页 */}
<div className="min-w-0">
{notifications.length === 0 ? (
<div className="panel text-center py-16 px-6">
<span
className="inline-flex w-12 h-12 rounded-full items-center justify-center mb-3.5"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<Bell size={22} />
</span>
<p className="font-bold" style={{ color: "var(--ink)" }}>还没有通知</p>
<p className="meta mt-1">当有人评论或点赞你的帖子时,会在这里提醒你</p>
</div>
) : (
<div className="panel overflow-hidden p-0">
{notifications.map((n, i) => {
const approval = n.type === "approved" || n.type === "rejected";
// 系统提醒(ActorID=0):不渲染操作者链接,仅展示标题与内容
const system = n.type === "necro_reply";
const icon = iconOf(n);
return (
<button
key={n.id}
onClick={() => handleClick(n)}
className="relative w-full text-left pl-4 pr-3 sm:pr-4 py-2.5 flex items-center gap-3 transition-colors hover:bg-[var(--item-hover-bg)] focus-visible:bg-[var(--item-hover-bg)]"
style={{
borderTop: i === 0 ? "none" : "1px solid var(--line)",
}}
>
{/* 未读:左侧主题色竖条(已读透明占位,文本基线对齐不跳) */}
<span
aria-hidden
className="absolute left-0 top-2 bottom-2 w-[3px] rounded-full"
style={{ background: n.is_read ? "transparent" : "var(--accent)" }}
/>
<span
className="w-[30px] h-[30px] shrink-0 rounded-[9px] flex items-center justify-center"
style={{ color: icon.color, background: icon.bg }}
>
{icon.node}
</span>
<div className="flex-1 min-w-0">
<p className="text-[13.5px] leading-snug flex items-baseline gap-x-1.5" style={{ color: "var(--ink)" }}>
{approval ? (
<span
className="font-bold shrink-0"
style={{ color: n.type === "approved" ? "var(--ok)" : "var(--danger)" }}
>
{n.type === "approved" ? "审核通过" : "未通过审核"}
</span>
) : system ? (
<span className="font-bold shrink-0" style={{ color: "var(--danger)" }}>
旧帖回复提醒
</span>
) : (
<>
<Link
href={userPath(urlStyle, n.actor.id)}
onClick={(e) => e.stopPropagation()}
className="font-semibold shrink-0 max-w-[10em] truncate hover:text-[var(--accent)] transition-colors"
>
{n.actor.nickname}
</Link>
<span className="meta shrink-0">
{n.type === "comment"
? "评论了你的帖子"
: n.type === "reply"
? "回复了你的评论"
: n.type === "mention"
? "在群中提到了你"
: n.type === "pending_review"
? "有新内容待审核"
: n.type === "deleted"
? "删除了你的帖子"
: n.type === "badge"
? "向你颁布了徽章"
: n.type === "follow"
? "关注了你"
: "点赞了你的帖子"}
</span>
</>
)}
{n.type === "mention"
? n.room && (
<span className="truncate min-w-0" style={{ color: "var(--ink-2)" }}>
# {n.room.name}
</span>
)
: n.post && (
<span className="truncate min-w-0" style={{ color: "var(--ink-2)" }}>
《{n.post.title}》
</span>
)}
<span className="meta text-xs shrink-0 ml-auto pl-2">{formatTime(n.created_at)}</span>
</p>
{n.content && (
<p className="text-[13px] mt-0.5 truncate" style={{ color: "var(--ink-3)" }}>
{n.content}
</p>
)}
</div>
{n.type === "follow" && (
<span
className="shrink-0 inline-flex items-center gap-0.5 text-xs font-semibold"
style={{ color: "var(--accent)" }}
>
查看主页 <ChevronRight size={13} />
</span>
)}
</button>
);
})}
</div>
)}
{children}
</div>
</>
);
}