feat: 新增站点公告系统与多项体验优化

此提交完成了以下核心功能与优化:
1. 新增站点公告全流程支持:包含公告模型、CRUD接口、管理后台页面、公告详情页与首页展示
2. 实现评论楼层化与回复嵌套:重构评论系统为树状结构,支持回复评论与层级限制
3. 添加用户在线状态追踪:异步更新用户最后活跃时间并做SQL限频
4. 优化用户体验:移动端浮动操作条、统一板块标签样式、用户页签到积分展示、注册即登录优化
5. 其他细节优化:SEO站点地图新增公告页、评论分页与统计、登录/注册表单增强等
This commit is contained in:
2026-09-13 21:38:01 +08:00
parent 9bae6f2513
commit 996a870015
41 changed files with 2846 additions and 786 deletions

View File

@@ -49,6 +49,10 @@ export interface OverviewStats {
users: number;
comments: number;
today_posts: number;
today_users: number;
today_comments: number;
online: number;
peak_online: number;
}
export interface ActiveUser {
@@ -56,19 +60,42 @@ export interface ActiveUser {
username: string;
nickname: string;
avatar: string;
activity_count: number;
post_count: number;
comment_count: number;
}
export interface BoardWithCount extends Board {
post_count: number;
}
export interface LatestComment {
// 公告标签预设色(与后端白名单一致)
export type AnnouncementColor =
| "blue"
| "green"
| "orange"
| "red"
| "purple"
| "gray";
// 右栏站点公告摘要(标签/日期/标题)
export interface AnnouncementSummary {
id: number;
content: string;
title: string;
tag: string;
tag_color: AnnouncementColor;
created_at: string;
user: { id: number; username: string; nickname: string; avatar: string };
post: { id: number; title: string };
}
// 公告详情/后台管理完整对象
export interface Announcement {
id: number;
title: string;
content: string;
tag: string;
tag_color: AnnouncementColor;
published: boolean;
created_at: string;
updated_at: string;
}
export interface NewUser {
@@ -91,7 +118,7 @@ export interface OverviewResponse {
hot: PostListItem[];
active_users: ActiveUser[];
boards: BoardWithCount[];
latest_comments: LatestComment[];
announcements: AnnouncementSummary[];
new_users: NewUser[];
checkin?: CheckinStatus | null;
}
@@ -143,12 +170,29 @@ export interface Comment {
id: number;
post_id: number;
user_id: number;
parent_id?: number | null; // 父评论 ID,null = 主评论(楼层)
root_id?: number | null; // 所属楼层 ID,子评论必填
depth?: number; // 层级:主评论 0,子评论 = 父 + 1
content: string;
status: string;
created_at: string;
user: User;
}
// 评论树节点:主评论为根,replies 为其全部子回复(时间正序,全量 SSR 直出)
export interface CommentNode extends Comment {
replies?: CommentNode[];
}
// 帖子评论楼层分页响应(时间正序;楼层号 = (page-1)*size + 序号 + 1,仅主评论占楼层)
export interface CommentsResponse {
comments: CommentNode[];
total: number; // 楼层数(主评论数)→ 分页与楼层号计算
total_comments: number; // 全部评论数(含回复)→ 头部徽标
page: number;
size: number;
}
export interface PostsResponse {
posts: PostListItem[];
total: number;
@@ -168,6 +212,8 @@ export interface UserProfile {
stats: {
post_count: number;
comment_count: number;
points: number;
streak: number;
};
posts: PostListItem[];
posts_total: number;
@@ -194,7 +240,7 @@ export interface NotificationItem {
id: number;
user_id: number;
actor_id: number;
type: "comment" | "like";
type: "comment" | "reply" | "like";
post_id: number;
comment_id: number;
content: string;
@@ -311,8 +357,9 @@ export async function fetchPostDetail(id: string, cookieHeader?: string): Promis
return res.json();
}
export async function fetchComments(postId: string): Promise<{ comments: Comment[] }> {
const res = await fetch(`${API_BASE}/api/posts/${postId}/comments`, ssrInit());
export async function fetchComments(postId: string, page = 1): Promise<CommentsResponse> {
const params = new URLSearchParams({ page: String(page), size: "20" });
const res = await fetch(`${API_BASE}/api/posts/${postId}/comments?${params}`, ssrInit());
if (!res.ok) throw new Error("获取评论失败");
return res.json();
}
@@ -346,6 +393,15 @@ export async function fetchUserComments(id: string, page = 1): Promise<UserComme
return res.json();
}
// SSR 直出通知列表(仅登录用户;401 由页面层转为登录引导)
export async function fetchNotifications(page = 1, cookieHeader?: string): Promise<NotificationsResponse> {
const params = new URLSearchParams({ page: String(page), size: "20" });
const res = await fetch(`${API_BASE}/api/notifications?${params}`, ssrInit(cookieHeaders(cookieHeader)));
if (res.status === 401) throw new Error("未登录");
if (!res.ok) throw new Error("获取通知失败");
return res.json();
}
// ===== 客户端 API 调用(走 rewrite,携带 cookie + CSRF) =====
// 获取当前登录用户(依赖 OptionalAuth,未登录返回 { user: null, unread_count: 0 })
@@ -400,11 +456,11 @@ export async function apiCreatePost(data: {
return res.json();
}
export async function apiCreateComment(postId: string, content: string) {
export async function apiCreateComment(postId: string, content: string, parentId?: number) {
const res = await fetchWithRefresh(`/api/posts/${postId}/comments`, {
method: "POST",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ content }),
body: JSON.stringify(parentId ? { content, parent_id: parentId } : { content }),
});
return res.json();
}
@@ -522,3 +578,69 @@ export async function apiDoCheckin(): Promise<CheckinStatus> {
if (!res.ok) throw new Error(data.error || "签到失败");
return data as CheckinStatus;
}
// ===== 站点公告 =====
// SSR 公告详情(仅已发布;草稿/不存在由调用方按 404 处理)
export async function fetchAnnouncementDetail(
id: string
): Promise<{ announcement: Announcement } | null> {
const res = await fetch(`${API_BASE}/api/announcements/${id}`, ssrInit());
if (res.status === 404) return null;
if (!res.ok) throw new Error("获取公告失败");
return res.json();
}
// 后台:公告列表(含草稿)
export async function apiAdminListAnnouncements(): Promise<{ announcements: Announcement[] }> {
const res = await fetchWithRefresh("/api/admin/announcements", {
headers: clientHeaders(),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "获取公告列表失败");
}
return res.json();
}
export interface AnnouncementInput {
title: string;
content: string;
tag: string;
tag_color: AnnouncementColor;
published: boolean;
}
async function saveAnnouncement(
method: "POST" | "PUT",
url: string,
input: AnnouncementInput
): Promise<{ announcement: Announcement }> {
const res = await fetchWithRefresh(url, {
method,
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(input),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "保存公告失败");
return data as { announcement: Announcement };
}
export function apiAdminCreateAnnouncement(input: AnnouncementInput) {
return saveAnnouncement("POST", "/api/admin/announcements", input);
}
export function apiAdminUpdateAnnouncement(id: number, input: AnnouncementInput) {
return saveAnnouncement("PUT", `/api/admin/announcements/${id}`, input);
}
export async function apiAdminDeleteAnnouncement(id: number): Promise<void> {
const res = await fetchWithRefresh(`/api/admin/announcements/${id}`, {
method: "DELETE",
headers: clientHeaders(),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "删除公告失败");
}
}