后台可注入全站 CSS/JS(SSR 生效,CSS 可热换);发帖/编辑改为锁一屏工作室;经济看板增加存量健康度;主题令牌与弹层误关一并收紧。 Co-authored-by: Cursor <cursoragent@cursor.com>
184 lines
7.3 KiB
TypeScript
184 lines
7.3 KiB
TypeScript
"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 } from "lucide-react";
|
||
import { apiMarkRead, apiMarkAllRead, type NotificationsResponse, type NotificationItem } from "@/lib/api";
|
||
|
||
/**
|
||
* 通知列表交互层:数据由服务端 SSR 直出,这里只承担已读标记等客户端操作。
|
||
*/
|
||
export default function NotificationsClient({ initialData }: { initialData: NotificationsResponse }) {
|
||
const router = useRouter();
|
||
const [notifications, setNotifications] = useState<NotificationItem[]>(initialData.notifications || []);
|
||
|
||
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))
|
||
);
|
||
}
|
||
// @提及通知跳到群聊房间;其余跳到帖子/评论
|
||
if (n.type === "mention" && n.room_id) {
|
||
router.push(`/chat/${n.room_id}`);
|
||
} else if (n.type === "pending_review") {
|
||
router.push(n.post_id ? `/post/${n.post_id}` : "/admin/content?status=pending");
|
||
} else {
|
||
router.push(`/post/${n.post_id}`);
|
||
}
|
||
};
|
||
|
||
const handleMarkAll = async () => {
|
||
await apiMarkAllRead();
|
||
setNotifications((list) => list.map((item) => ({ ...item, is_read: true })));
|
||
// 同步刷新 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 hasUnread = notifications.some((n) => !n.is_read);
|
||
|
||
return (
|
||
<>
|
||
{hasUnread && (
|
||
<div className="flex justify-end mb-4">
|
||
<button onClick={handleMarkAll} className="btn btn-line btn-sm">
|
||
<CheckCheck size={14} /> 全部已读
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{notifications.length === 0 ? (
|
||
<div className="panel text-center py-20 px-6">
|
||
<span
|
||
className="inline-flex w-14 h-14 rounded-full items-center justify-center mb-4"
|
||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||
>
|
||
<Bell size={26} />
|
||
</span>
|
||
<p className="font-bold text-lg" style={{ color: "var(--ink)" }}>还没有通知</p>
|
||
<p className="meta mt-1.5">当有人评论或点赞你的帖子时,会在这里提醒你</p>
|
||
</div>
|
||
) : (
|
||
<div className="panel overflow-hidden p-0">
|
||
{notifications.map((n, i) => {
|
||
const approval = n.type === "approved" || n.type === "rejected";
|
||
const iconNode = n.type === "approved"
|
||
? <CheckCircle2 size={16} />
|
||
: n.type === "rejected" || n.type === "deleted"
|
||
? <XCircle size={16} />
|
||
: n.type === "like"
|
||
? <Heart size={16} />
|
||
: n.type === "mention"
|
||
? <AtSign size={16} />
|
||
: n.type === "pending_review"
|
||
? <ClipboardList size={16} />
|
||
: <MessageCircle size={16} />;
|
||
const iconColor = n.type === "approved"
|
||
? "var(--ok)"
|
||
: n.type === "rejected" || n.type === "deleted"
|
||
? "var(--danger)"
|
||
: n.type === "like"
|
||
? "var(--clay)"
|
||
: "var(--accent)";
|
||
const iconBg = n.type === "approved"
|
||
? "var(--ok-soft)"
|
||
: n.type === "rejected" || n.type === "deleted"
|
||
? "var(--danger-soft)"
|
||
: n.type === "like"
|
||
? "var(--clay-soft)"
|
||
: "var(--accent-soft)";
|
||
return (
|
||
<button
|
||
key={n.id}
|
||
onClick={() => handleClick(n)}
|
||
className={`w-full text-left px-4 sm:px-5 py-4 flex items-start gap-3.5 transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)] ${
|
||
n.is_read ? "" : "bg-[var(--panel-2)]"
|
||
}`}
|
||
style={{
|
||
borderTop: i === 0 ? "none" : "1px solid var(--line)",
|
||
}}
|
||
>
|
||
<span
|
||
className="w-10 h-10 shrink-0 rounded-full flex items-center justify-center"
|
||
style={{ color: iconColor, background: iconBg }}
|
||
>
|
||
{iconNode}
|
||
</span>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm leading-snug" style={{ color: "var(--ink)" }}>
|
||
{approval ? (
|
||
<span
|
||
className="font-bold"
|
||
style={{ color: n.type === "approved" ? "var(--ok)" : "var(--danger)" }}
|
||
>
|
||
{n.type === "approved" ? "审核通过" : "未通过审核"}
|
||
</span>
|
||
) : (
|
||
<>
|
||
<Link
|
||
href={`/u/${n.actor.id}`}
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="font-semibold hover:text-[var(--accent)] transition-colors"
|
||
>
|
||
{n.actor.nickname}
|
||
</Link>{" "}
|
||
<span className="meta">
|
||
{n.type === "comment"
|
||
? "评论了你的帖子"
|
||
: n.type === "reply"
|
||
? "回复了你的评论"
|
||
: n.type === "mention"
|
||
? "在消息中提到了你"
|
||
: n.type === "pending_review"
|
||
? "有新内容待审核"
|
||
: n.type === "deleted"
|
||
? "删除了你的帖子"
|
||
: "点赞了你的帖子"}
|
||
</span>
|
||
</>
|
||
)}
|
||
</p>
|
||
{n.type === "mention" ? (
|
||
n.room && (
|
||
<p className="text-sm mt-1 truncate" style={{ color: "var(--ink-2)" }}>
|
||
# {n.room.name}
|
||
</p>
|
||
)
|
||
) : (
|
||
n.post && (
|
||
<p className="text-sm mt-1 truncate" style={{ color: "var(--ink-2)" }}>
|
||
《{n.post.title}》
|
||
</p>
|
||
)
|
||
)}
|
||
{n.content && (
|
||
<p className="text-[13px] mt-1.5 line-clamp-2" style={{ color: "var(--ink-3)" }}>
|
||
{n.content}
|
||
</p>
|
||
)}
|
||
<p className="meta text-xs mt-2">{formatTime(n.created_at)}</p>
|
||
</div>
|
||
{!n.is_read && (
|
||
<span className="w-2.5 h-2.5 shrink-0 mt-2 rounded-full" style={{ background: "var(--clay)" }} />
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|