feat: 新增站点公告系统与多项体验优化
此提交完成了以下核心功能与优化: 1. 新增站点公告全流程支持:包含公告模型、CRUD接口、管理后台页面、公告详情页与首页展示 2. 实现评论楼层化与回复嵌套:重构评论系统为树状结构,支持回复评论与层级限制 3. 添加用户在线状态追踪:异步更新用户最后活跃时间并做SQL限频 4. 优化用户体验:移动端浮动操作条、统一板块标签样式、用户页签到积分展示、注册即登录优化 5. 其他细节优化:SEO站点地图新增公告页、评论分页与统计、登录/注册表单增强等
This commit is contained in:
124
frontend/app/notifications/NotificationsClient.tsx
Normal file
124
frontend/app/notifications/NotificationsClient.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Heart, MessageCircle, CheckCheck, Bell } 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))
|
||||
);
|
||||
}
|
||||
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) => (
|
||||
<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)]"
|
||||
style={{
|
||||
borderTop: i === 0 ? "none" : "1px solid var(--line)",
|
||||
background: n.is_read ? "transparent" : "var(--panel-2)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="w-10 h-10 shrink-0 rounded-full flex items-center justify-center"
|
||||
style={{
|
||||
color: n.type === "like" ? "var(--clay)" : "var(--accent)",
|
||||
background:
|
||||
n.type === "like" ? "var(--clay-soft)" : "var(--accent-soft)",
|
||||
}}
|
||||
>
|
||||
{n.type === "like" ? <Heart size={16} /> : <MessageCircle size={16} />}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm leading-snug" style={{ color: "var(--ink)" }}>
|
||||
<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"
|
||||
? "回复了你的评论"
|
||||
: "点赞了你的帖子"}
|
||||
</span>
|
||||
</p>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Heart, MessageCircle, CheckCheck, Bell, ChevronRight } from "lucide-react";
|
||||
import {
|
||||
apiFetchNotifications,
|
||||
apiMarkRead,
|
||||
apiMarkAllRead,
|
||||
type NotificationItem,
|
||||
} from "@/lib/api";
|
||||
import { cookies } from "next/headers";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { fetchNotifications } from "@/lib/api";
|
||||
import Pagination from "@/components/Pagination";
|
||||
import NotificationsClient from "./NotificationsClient";
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const router = useRouter();
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
interface NotificationsPageProps {
|
||||
searchParams: Promise<{ page?: string }>;
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetchNotifications(1);
|
||||
setNotifications(res.notifications || []);
|
||||
} catch {
|
||||
setNotifications([]);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
// SSR 直出通知列表:F5 直接看到内容,不再先渲染"正在加载…"
|
||||
export default async function NotificationsPage({ searchParams }: NotificationsPageProps) {
|
||||
const sp = await searchParams;
|
||||
const page = Math.max(1, parseInt(sp.page || "1", 10) || 1);
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
if (!me.user) {
|
||||
redirect(`/login?redirect=${encodeURIComponent(`/notifications`)}`);
|
||||
}
|
||||
|
||||
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);
|
||||
let data = null;
|
||||
let loadError = false;
|
||||
try {
|
||||
data = await fetchNotifications(page, cookie);
|
||||
} catch {
|
||||
loadError = true;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
@@ -66,81 +38,26 @@ export default function NotificationsPage() {
|
||||
<span style={{ color: "var(--accent)" }}>通知中心</span>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-end justify-between mb-6 gap-4">
|
||||
<h1 className="text-[26px] sm:text-[30px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
|
||||
通知中心
|
||||
</h1>
|
||||
{hasUnread && (
|
||||
<button onClick={handleMarkAll} className="btn btn-line btn-sm">
|
||||
<CheckCheck size={14} /> 全部已读
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-[26px] sm:text-[30px] font-extrabold tracking-tight mb-6" style={{ color: "var(--ink)" }}>
|
||||
通知中心
|
||||
</h1>
|
||||
|
||||
{loading ? (
|
||||
<div className="panel text-center py-16">
|
||||
<p className="meta">正在加载通知…</p>
|
||||
</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>
|
||||
{loadError || !data ? (
|
||||
<div className="panel text-center py-16 px-6" role="alert">
|
||||
<p className="text-sm font-semibold" style={{ color: "var(--ink-2)" }}>通知加载失败</p>
|
||||
<p className="meta mt-1.5 mb-4">网络异常,请稍后重试</p>
|
||||
<Link href="/notifications" className="btn btn-line btn-sm">重新加载</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="panel overflow-hidden p-0">
|
||||
{notifications.map((n, i) => (
|
||||
<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)]"
|
||||
style={{
|
||||
borderTop: i === 0 ? "none" : "1px solid var(--line)",
|
||||
background: n.is_read ? "transparent" : "var(--panel-2)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="w-10 h-10 shrink-0 rounded-full flex items-center justify-center"
|
||||
style={{
|
||||
color: n.type === "like" ? "var(--clay)" : "var(--accent)",
|
||||
background:
|
||||
n.type === "like" ? "var(--clay-soft)" : "var(--accent-soft)",
|
||||
}}
|
||||
>
|
||||
{n.type === "like" ? <Heart size={16} /> : <MessageCircle size={16} />}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm leading-snug" style={{ color: "var(--ink)" }}>
|
||||
<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" ? "评论了你的帖子" : "点赞了你的帖子"}</span>
|
||||
</p>
|
||||
<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>
|
||||
<>
|
||||
<NotificationsClient initialData={data} />
|
||||
<Pagination
|
||||
total={data.total}
|
||||
page={data.page}
|
||||
size={data.size}
|
||||
basePath="/notifications"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user