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 || "删除公告失败");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,3 +12,38 @@ export function formatRelative(dateStr: string): string {
|
||||
if (diff < 365 * day) return `${Math.floor(diff / (30 * day))}个月前`;
|
||||
return `${Math.floor(diff / (365 * day))}年前`;
|
||||
}
|
||||
|
||||
/** 去掉末尾多余的 .0(12.0 → 12,1.25 → 1.3 由 toFixed 保证) */
|
||||
function trimTenths(v: number): string {
|
||||
return String(Number(v.toFixed(1)));
|
||||
}
|
||||
|
||||
/** 紧凑数字:999 / 1.2K / 128K / 2.3M(站点数据卡) */
|
||||
export function formatCompact(n: number): string {
|
||||
if (!Number.isFinite(n)) return "0";
|
||||
if (n < 1000) return String(Math.round(n));
|
||||
if (n < 1_000_000) {
|
||||
const v = n / 1000;
|
||||
return `${v >= 100 ? Math.round(v) : trimTenths(v)}K`;
|
||||
}
|
||||
return `${trimTenths(n / 1_000_000)}M`;
|
||||
}
|
||||
|
||||
function pad2(n: number) {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
/** 短日期:今年显示 MM-DD,往年显示 YYYY-MM-DD(右栏公告用) */
|
||||
export function formatShortDate(dateStr: string): string {
|
||||
const d = new Date(dateStr);
|
||||
const md = `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||||
return d.getFullYear() === new Date().getFullYear()
|
||||
? md
|
||||
: `${d.getFullYear()}-${md}`;
|
||||
}
|
||||
|
||||
/** 完整日期 YYYY-MM-DD */
|
||||
export function formatDate(dateStr: string): string {
|
||||
const d = new Date(dateStr);
|
||||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { cache } from "react";
|
||||
import { fetchMe } from "./api";
|
||||
import { fetchBoards, fetchMe } from "./api";
|
||||
|
||||
// 同一 SSR 请求内去重:layout 与页面可能同时需要当前用户,
|
||||
// 相同 Cookie 头参数下只产生一次 /api/me 调用。
|
||||
export const getMeCached = cache((cookieHeader?: string) => fetchMe(cookieHeader));
|
||||
|
||||
// 全站导航需要板块列表:layout 每请求调用,失败降级为空列表(导航隐藏,不阻塞页面)
|
||||
export const getBoardsCached = cache(async () => {
|
||||
try {
|
||||
const { boards } = await fetchBoards();
|
||||
return boards;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user