feat: 帖子收藏与用户关注

- 新增收藏功能:favorite 服务与 handler、FavoriteButton 组件
- 新增关注功能:follow 服务与 handler、FollowButton 组件
- 用户主页适配关注/粉丝与收藏展示
- flash 消息工具、广告与侧边栏组件微调
This commit is contained in:
2026-09-26 03:02:40 +08:00
parent 31f5360c26
commit d7c6b3a961
40 changed files with 1528 additions and 268 deletions

View File

@@ -295,6 +295,7 @@ export interface PublicAdItem {
title?: string;
text_color?: string;
bg_color?: string;
ends_at?: string | null;
}
export interface PublicSponsorItem {
@@ -392,6 +393,8 @@ export interface Post {
view_count: number;
comment_count: number;
liked: boolean;
/** 详情接口返回:当前查看者是否已收藏 */
favorited?: boolean;
/** 详情接口返回:是否存在编辑历史快照(列表接口无此字段) */
edited?: boolean;
created_at: string;
@@ -584,12 +587,18 @@ export interface UserProfile {
total_points?: number;
// 徽章墙(全量,含授予时间)
badges?: UserBadgeView[];
/** 访客(登录态)与该用户的关注关系;本人访问恒 false */
is_following?: boolean;
/** 仅本人访问时下发的打码邮箱(j***@163.com) */
email?: string;
};
stats: {
post_count: number;
comment_count: number;
points: number;
streak: number;
favorite_count: number;
following_count: number;
};
posts: PostListItem[];
posts_total: number;
@@ -612,11 +621,34 @@ export interface UserCommentsResponse {
size: number;
}
export interface UserFavoritesResponse {
posts: PostListItem[];
total: number;
page: number;
size: number;
}
export interface UserFollowingItem {
id: number;
username: string;
nickname: string;
avatar: string;
level?: number;
signature?: string;
}
export interface UserFollowingResponse {
users: UserFollowingItem[];
total: number;
page: number;
size: number;
}
export interface NotificationItem {
id: number;
user_id: number;
actor_id: number;
type: "comment" | "reply" | "like" | "approved" | "rejected" | "mention" | "pending_review" | "deleted" | "badge" | "necro_reply";
type: "comment" | "reply" | "like" | "approved" | "rejected" | "mention" | "pending_review" | "deleted" | "badge" | "necro_reply" | "follow";
post_id: number;
comment_id: number;
room_id?: number;
@@ -1286,8 +1318,25 @@ export async function fetchComments(
return res.json();
}
export async function fetchUserProfile(id: string, page = 1, cookieHeader?: string): Promise<UserProfile> {
const params = new URLSearchParams({ page: String(page), size: "20" });
// 评论定位:返回楼层号(与楼层分页口径一致),0 = 不存在。
// 通知/主页评论深链 #comment-{id} 跨页时用它换到正确页码。
export async function fetchCommentLocation(
postId: string | number,
commentId: number
): Promise<number> {
const res = await fetch(`${API_BASE}/api/posts/${postId}/comments/${commentId}`);
if (!res.ok) return 0;
const data = (await res.json()) as { floor?: number };
return data.floor || 0;
}
export async function fetchUserProfile(
id: string,
page = 1,
cookieHeader?: string,
sort: string = "new"
): Promise<UserProfile> {
const params = new URLSearchParams({ page: String(page), size: "20", sort });
const res = await fetch(`${API_BASE}/api/users/${id}?${params}`, ssrInit(cookieHeaders(cookieHeader)));
if (res.status === 404) throw new Error("用户不存在");
if (!res.ok) throw new Error("获取用户资料失败");
@@ -1307,9 +1356,11 @@ export async function fetchMe(cookieHeader?: string): Promise<MeResponse> {
}
}
export async function fetchUserComments(id: string, page = 1): Promise<UserCommentsResponse> {
// cookieHeader:SSR 时必须转发访问者 cookie——后端开启「评论需登录」时,
// 匿名请求会拿到空列表(表现为所有人主页回帖都显示暂无评论)
export async function fetchUserComments(id: string, page = 1, cookieHeader?: string): Promise<UserCommentsResponse> {
const params = new URLSearchParams({ page: String(page), size: "20" });
const res = await fetch(`${API_BASE}/api/users/${id}/comments?${params}`, ssrInit());
const res = await fetch(`${API_BASE}/api/users/${id}/comments?${params}`, ssrInit(cookieHeaders(cookieHeader)));
if (res.status === 404) throw new Error("用户不存在");
if (!res.ok) throw new Error("获取评论失败");
const data = (await res.json()) as UserCommentsResponse;
@@ -1317,6 +1368,24 @@ export async function fetchUserComments(id: string, page = 1): Promise<UserComme
return { ...data, comments: data.comments ?? [] };
}
export async function fetchUserFavorites(id: string, page = 1): Promise<UserFavoritesResponse> {
const params = new URLSearchParams({ page: String(page), size: "20" });
const res = await fetch(`${API_BASE}/api/users/${id}/favorites?${params}`, ssrInit());
if (res.status === 404) throw new Error("用户不存在");
if (!res.ok) throw new Error("获取收藏失败");
const data = (await res.json()) as UserFavoritesResponse;
return { ...data, posts: data.posts ?? [] };
}
export async function fetchUserFollowing(id: string, page = 1): Promise<UserFollowingResponse> {
const params = new URLSearchParams({ page: String(page), size: "20" });
const res = await fetch(`${API_BASE}/api/users/${id}/following?${params}`, ssrInit());
if (res.status === 404) throw new Error("用户不存在");
if (!res.ok) throw new Error("获取关注列表失败");
const data = (await res.json()) as UserFollowingResponse;
return { ...data, users: data.users ?? [] };
}
// SSR 直出通知列表(仅登录用户;401 由页面层转为登录引导)
export async function fetchNotifications(page = 1, cookieHeader?: string): Promise<NotificationsResponse> {
const params = new URLSearchParams({ page: String(page), size: "20" });
@@ -2242,6 +2311,22 @@ export async function apiToggleLike(postId: string) {
return res.json();
}
export async function apiToggleFavorite(postId: string) {
const res = await fetchWithRefresh(`/api/posts/${postId}/favorite`, {
method: "POST",
headers: clientHeaders(),
});
return res.json();
}
export async function apiToggleFollow(userId: string | number) {
const res = await fetchWithRefresh(`/api/users/${userId}/follow`, {
method: "POST",
headers: clientHeaders(),
});
return res.json();
}
export async function apiDeleteComment(
postId: string,
commentId: number,