首次提交:姜十三论坛
后端 Go+Gin:认证/CSRF/限流、板块、帖子、评论、点赞、通知、用户资料、置顶推荐;前端 Next.js 16:发帖/编辑/删除、搜索、分页、点赞、通知中心、设置;基础设施 docker-compose 与配置模板;添加 .gitignore 与专有许可证(保留所有权利)
This commit is contained in:
131
frontend/app/notifications/page.tsx
Normal file
131
frontend/app/notifications/page.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Heart, MessageCircle, CheckCheck } from "lucide-react";
|
||||
import {
|
||||
apiFetchNotifications,
|
||||
apiMarkRead,
|
||||
apiMarkAllRead,
|
||||
type NotificationItem,
|
||||
} from "@/lib/api";
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const router = useRouter();
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetchNotifications(1);
|
||||
setNotifications(res.notifications || []);
|
||||
} catch {
|
||||
setNotifications([]);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
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))
|
||||
);
|
||||
}
|
||||
router.push(`/post/${n.post_id}`);
|
||||
};
|
||||
|
||||
const handleMarkAll = async () => {
|
||||
await apiMarkAllRead();
|
||||
setNotifications((list) => list.map((item) => ({ ...item, is_read: true })));
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold" style={{ color: "var(--text)" }}>通知中心</h1>
|
||||
{hasUnread && (
|
||||
<button
|
||||
onClick={handleMarkAll}
|
||||
className="flex items-center gap-1 text-sm hover:opacity-80"
|
||||
style={{ color: "var(--primary)" }}
|
||||
>
|
||||
<CheckCheck size={16} /> 全部标记已读
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-center py-12" style={{ color: "var(--text-secondary)" }}>加载中...</p>
|
||||
) : notifications.length === 0 ? (
|
||||
<p className="text-center py-12" style={{ color: "var(--text-secondary)" }}>暂无通知</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{notifications.map((n) => (
|
||||
<button
|
||||
key={n.id}
|
||||
onClick={() => handleClick(n)}
|
||||
className="w-full text-left p-4 rounded-lg border hover:shadow-sm transition-shadow"
|
||||
style={{
|
||||
borderColor: "var(--border)",
|
||||
background: n.is_read ? "var(--bg)" : "var(--bg-secondary)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span style={{ color: n.type === "like" ? "#ef4444" : "var(--primary)" }}>
|
||||
{n.type === "like" ? <Heart size={18} /> : <MessageCircle size={18} />}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm" style={{ color: "var(--text)" }}>
|
||||
<Link
|
||||
href={`/u/${n.actor.id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{n.actor.nickname}
|
||||
</Link>{" "}
|
||||
<span style={{ color: "var(--text-secondary)" }}>
|
||||
{n.type === "comment" ? "评论了你的帖子" : "点赞了你的帖子"}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-sm mt-1 font-medium truncate" style={{ color: "var(--text)" }}>
|
||||
《{n.post.title}》
|
||||
</p>
|
||||
{n.content && (
|
||||
<p className="text-sm mt-1 line-clamp-2" style={{ color: "var(--text-secondary)" }}>
|
||||
{n.content}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs mt-2" style={{ color: "var(--text-secondary)" }}>
|
||||
{formatTime(n.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
{!n.is_read && (
|
||||
<span className="w-2.5 h-2.5 rounded-full shrink-0 mt-1.5" style={{ background: "var(--primary)" }} />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user