feat: 新增站点公告系统与多项体验优化
此提交完成了以下核心功能与优化: 1. 新增站点公告全流程支持:包含公告模型、CRUD接口、管理后台页面、公告详情页与首页展示 2. 实现评论楼层化与回复嵌套:重构评论系统为树状结构,支持回复评论与层级限制 3. 添加用户在线状态追踪:异步更新用户最后活跃时间并做SQL限频 4. 优化用户体验:移动端浮动操作条、统一板块标签样式、用户页签到积分展示、注册即登录优化 5. 其他细节优化:SEO站点地图新增公告页、评论分页与统计、登录/注册表单增强等
This commit is contained in:
@@ -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 || "删除公告失败");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user