feat: 实现完整的板块级RBAC内容审核系统
- 新增站长/超级管理员/管理员/板块管理员四级角色体系 - 实现实时权限快照加载与细粒度权限校验 - 新增内容审核队列与前后端页面 - 重构用户权限管理与站点管理逻辑 - 新增评论/帖子审核状态与通知推送 - 优化前端权限控制与角色徽章展示 - 修正数据库迁移与默认值问题
This commit is contained in:
@@ -27,6 +27,8 @@ export interface User {
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
role: string;
|
||||
// 板块管理员被授权的板块(仅 /api/me 返回;前端据此渲染审核入口/按钮)
|
||||
board_ids?: number[];
|
||||
// 仅 /api/me 与 /api/profile 响应中返回(用户自身敏感信息)
|
||||
email?: string;
|
||||
signature?: string;
|
||||
@@ -215,6 +217,7 @@ export interface UserProfile {
|
||||
avatar: string;
|
||||
signature: string;
|
||||
role: string;
|
||||
board_ids?: number[];
|
||||
created_at: string;
|
||||
};
|
||||
stats: {
|
||||
@@ -248,7 +251,7 @@ export interface NotificationItem {
|
||||
id: number;
|
||||
user_id: number;
|
||||
actor_id: number;
|
||||
type: "comment" | "reply" | "like";
|
||||
type: "comment" | "reply" | "like" | "approved" | "rejected";
|
||||
post_id: number;
|
||||
comment_id: number;
|
||||
content: string;
|
||||
@@ -411,9 +414,16 @@ export async function fetchPostDetail(id: string, cookieHeader?: string): Promis
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchComments(postId: string, page = 1): Promise<CommentsResponse> {
|
||||
export async function fetchComments(
|
||||
postId: string,
|
||||
page = 1,
|
||||
cookieHeader?: string
|
||||
): Promise<CommentsResponse> {
|
||||
const params = new URLSearchParams({ page: String(page), size: "20" });
|
||||
const res = await fetch(`${API_BASE}/api/posts/${postId}/comments?${params}`, ssrInit());
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/posts/${postId}/comments?${params}`,
|
||||
ssrInit(cookieHeaders(cookieHeader))
|
||||
);
|
||||
if (!res.ok) throw new Error("获取评论失败");
|
||||
return res.json();
|
||||
}
|
||||
@@ -784,7 +794,7 @@ export async function apiAdminDeleteAnnouncement(id: number): Promise<void> {
|
||||
|
||||
// ===== 用户管理(管理员) =====
|
||||
|
||||
// 后台用户列表项:email/最近活跃仅管理员接口返回
|
||||
// 后台用户列表项:email/最近活跃/登录 IP 仅管理员接口返回
|
||||
export interface AdminUser {
|
||||
id: number;
|
||||
username: string;
|
||||
@@ -792,12 +802,33 @@ export interface AdminUser {
|
||||
email: string;
|
||||
avatar: string;
|
||||
signature: string;
|
||||
role: "user" | "admin" | string;
|
||||
role: string; // user | board_admin | admin | super_admin | owner
|
||||
board_ids: number[]; // 板块管理员的授权板块
|
||||
banned: boolean;
|
||||
post_count: number;
|
||||
comment_count: number;
|
||||
created_at: string;
|
||||
last_seen_at: string | null;
|
||||
online: boolean; // last_seen_at 在 5 分钟内
|
||||
last_login_ip: string;
|
||||
last_login_at: string | null;
|
||||
}
|
||||
|
||||
// 登录历史记录
|
||||
export interface LoginLog {
|
||||
id: number;
|
||||
user_id: number;
|
||||
username: string;
|
||||
ip: string;
|
||||
user_agent: string;
|
||||
success: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface LoginLogsResponse {
|
||||
logs: LoginLog[];
|
||||
total: number;
|
||||
page: number;
|
||||
}
|
||||
|
||||
export interface AdminUserSummary {
|
||||
@@ -846,6 +877,19 @@ export async function fetchAdminUsers(
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// SSR 待审核帖子首页(转发管理团队 cookie 直连后端);权限不足时抛错由页面层处理
|
||||
export async function fetchAdminPendingPosts(
|
||||
page = 1,
|
||||
cookieHeader?: string
|
||||
): Promise<{ posts: Post[]; total: number; page: number }> {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/admin/moderation/pending-posts?page=${page}`,
|
||||
ssrInit(cookieHeaders(cookieHeader))
|
||||
);
|
||||
if (!res.ok) throw new Error("获取待审核帖子失败");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// 客户端后台用户列表(走 rewrite,携带 cookie + CSRF)
|
||||
export async function apiAdminListUsers(query: AdminUsersQuery = {}): Promise<AdminUsersResponse> {
|
||||
const res = await fetchWithRefresh(`/api/admin/users?${adminUsersParams(query)}`, {
|
||||
@@ -856,21 +900,37 @@ export async function apiAdminListUsers(query: AdminUsersQuery = {}): Promise<Ad
|
||||
return data as AdminUsersResponse;
|
||||
}
|
||||
|
||||
// 变更用户角色:role 仅接受 "user" | "admin"
|
||||
// 变更用户管理角色与板块授权:
|
||||
// role 接受 user/board_admin/admin/super_admin(owner 不可授予);
|
||||
// board_admin 必须携带至少一个 board_ids
|
||||
export async function apiAdminSetUserRole(
|
||||
id: number,
|
||||
role: "user" | "admin"
|
||||
role: string,
|
||||
boardIds?: number[]
|
||||
): Promise<{ user: AdminUser }> {
|
||||
const res = await fetchWithRefresh(`/api/admin/users/${id}/role`, {
|
||||
method: "PUT",
|
||||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ role }),
|
||||
body: JSON.stringify({ role, board_ids: boardIds ?? [] }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "角色更新失败");
|
||||
return data as { user: AdminUser };
|
||||
}
|
||||
|
||||
// 某用户的登录历史(IP/UA/成败)
|
||||
export async function apiAdminUserLoginLogs(
|
||||
id: number,
|
||||
page = 1
|
||||
): Promise<LoginLogsResponse> {
|
||||
const res = await fetchWithRefresh(`/api/admin/users/${id}/login-logs?page=${page}`, {
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "获取登录历史失败");
|
||||
return data as LoginLogsResponse;
|
||||
}
|
||||
|
||||
// 封禁 / 解封用户
|
||||
export async function apiAdminSetUserBan(
|
||||
id: number,
|
||||
@@ -905,3 +965,80 @@ export async function apiUpdateSiteSettings(accent: string): Promise<PublicSetti
|
||||
if (!res.ok) throw new Error(data.error || "保存设置失败");
|
||||
return { accent: data.accent ?? "" };
|
||||
}
|
||||
|
||||
// ===== 板块(客户端,供角色授权弹窗勾选) =====
|
||||
|
||||
export async function apiListBoards(): Promise<{ boards: Board[] }> {
|
||||
const res = await fetchWithRefresh("/api/boards", { headers: clientHeaders() });
|
||||
if (!res.ok) throw new Error("获取板块失败");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ===== 内容审核队列 =====
|
||||
|
||||
export interface PendingCommentItem {
|
||||
id: number;
|
||||
post_id: number;
|
||||
post_title: string;
|
||||
board_id: number;
|
||||
board: Board;
|
||||
content: string;
|
||||
created_at: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface PendingCounts {
|
||||
posts: number;
|
||||
comments: number;
|
||||
}
|
||||
|
||||
export async function apiAdminPendingPosts(page = 1): Promise<{
|
||||
posts: Post[];
|
||||
total: number;
|
||||
page: number;
|
||||
}> {
|
||||
const res = await fetchWithRefresh(`/api/admin/moderation/pending-posts?page=${page}`, {
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "获取待审帖子失败");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function apiAdminPendingComments(page = 1): Promise<{
|
||||
comments: PendingCommentItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
}> {
|
||||
const res = await fetchWithRefresh(`/api/admin/moderation/pending-comments?page=${page}`, {
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "获取待审评论失败");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function apiAdminPendingCounts(): Promise<PendingCounts> {
|
||||
const res = await fetchWithRefresh("/api/admin/moderation/counts", {
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
if (!res.ok) return { posts: 0, comments: 0 };
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function moderationAction(url: string): Promise<void> {
|
||||
const res = await fetchWithRefresh(url, { method: "PUT", headers: clientHeaders() });
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || "操作失败");
|
||||
}
|
||||
}
|
||||
|
||||
export const apiAdminApprovePost = (id: number) =>
|
||||
moderationAction(`/api/admin/moderation/posts/${id}/approve`);
|
||||
export const apiAdminRejectPost = (id: number) =>
|
||||
moderationAction(`/api/admin/moderation/posts/${id}/reject`);
|
||||
export const apiAdminApproveComment = (id: number) =>
|
||||
moderationAction(`/api/admin/moderation/comments/${id}/approve`);
|
||||
export const apiAdminRejectComment = (id: number) =>
|
||||
moderationAction(`/api/admin/moderation/comments/${id}/reject`);
|
||||
|
||||
127
frontend/lib/roles.ts
Normal file
127
frontend/lib/roles.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
// 角色与权限中枢:与后端 model.Role / service.Actor 保持单一对应关系。
|
||||
// 前端只做"是否展示入口/按钮"的判定,所有最终权限以后端校验为准。
|
||||
|
||||
export const ROLES = {
|
||||
USER: "user",
|
||||
BOARD_ADMIN: "board_admin",
|
||||
ADMIN: "admin",
|
||||
SUPER_ADMIN: "super_admin",
|
||||
OWNER: "owner",
|
||||
} as const;
|
||||
|
||||
export type RoleKey = (typeof ROLES)[keyof typeof ROLES];
|
||||
|
||||
// 角色等级(与后端 model.RoleLevel 一致),未知角色按普通用户处理
|
||||
export const ROLE_LEVEL: Record<string, number> = {
|
||||
[ROLES.USER]: 0,
|
||||
[ROLES.BOARD_ADMIN]: 30,
|
||||
[ROLES.ADMIN]: 50,
|
||||
[ROLES.SUPER_ADMIN]: 80,
|
||||
[ROLES.OWNER]: 100,
|
||||
};
|
||||
|
||||
export function roleLevel(role?: string | null): number {
|
||||
return ROLE_LEVEL[role ?? ""] ?? 0;
|
||||
}
|
||||
|
||||
/** 管理团队成员(板块管理员及以上) */
|
||||
export function isStaff(role?: string | null): boolean {
|
||||
return roleLevel(role) >= ROLE_LEVEL[ROLES.BOARD_ADMIN];
|
||||
}
|
||||
|
||||
/** 管理员及以上(全站内容管理:置顶/加精/公告) */
|
||||
export function isAdminOrAbove(role?: string | null): boolean {
|
||||
return roleLevel(role) >= ROLE_LEVEL[ROLES.ADMIN];
|
||||
}
|
||||
|
||||
/** 超级管理员及以上(用户与权限、站点设置) */
|
||||
export function isSuperOrOwner(role?: string | null): boolean {
|
||||
return roleLevel(role) >= ROLE_LEVEL[ROLES.SUPER_ADMIN];
|
||||
}
|
||||
|
||||
/** 站长(唯一,不可被操作) */
|
||||
export function isOwner(role?: string | null): boolean {
|
||||
return role === ROLES.OWNER;
|
||||
}
|
||||
|
||||
/** 携带板块授权的最小用户形状(/me、帖子详情作者等均满足) */
|
||||
export interface RoleUserLike {
|
||||
role?: string | null;
|
||||
board_ids?: number[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 能否管理指定板块的内容(帖子/评论的编辑删除审核):
|
||||
* 管理员及以上不限板块;板块管理员仅限被授权板块
|
||||
*/
|
||||
export function canModerateBoard(
|
||||
user: RoleUserLike | null | undefined,
|
||||
boardId: number | null | undefined
|
||||
): boolean {
|
||||
if (!user) return false;
|
||||
if (isAdminOrAbove(user.role)) return true;
|
||||
if (user.role !== ROLES.BOARD_ADMIN || !boardId) return false;
|
||||
return (user.board_ids ?? []).includes(boardId);
|
||||
}
|
||||
|
||||
/** 是否拥有任意板块的内容审核权(后台"内容审核"入口) */
|
||||
export function canModerateAny(user: RoleUserLike | null | undefined): boolean {
|
||||
if (!user) return false;
|
||||
if (isAdminOrAbove(user.role)) return true;
|
||||
return user.role === ROLES.BOARD_ADMIN && (user.board_ids?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
/** 角色图标 key(RoleBadge 负责映射到具体图标,集中配置便于替换) */
|
||||
export type RoleIconKey = "crown" | "shield-star" | "shield" | "shield-board" | "user";
|
||||
|
||||
export interface RoleMeta {
|
||||
key: RoleKey;
|
||||
label: string;
|
||||
icon: RoleIconKey;
|
||||
/** 主色使用的 CSS token 名(RoleBadge 取 var(--token)) */
|
||||
colorToken: string;
|
||||
softToken: string;
|
||||
}
|
||||
|
||||
// 各管理角色的图标与配色集中配置;站长固定使用皇冠站长标识
|
||||
export const ROLE_META: Record<RoleKey, RoleMeta> = {
|
||||
[ROLES.OWNER]: {
|
||||
key: ROLES.OWNER,
|
||||
label: "站长",
|
||||
icon: "crown",
|
||||
colorToken: "--gold",
|
||||
softToken: "--gold-soft",
|
||||
},
|
||||
[ROLES.SUPER_ADMIN]: {
|
||||
key: ROLES.SUPER_ADMIN,
|
||||
label: "超级管理员",
|
||||
icon: "shield-star",
|
||||
colorToken: "--accent",
|
||||
softToken: "--accent-soft",
|
||||
},
|
||||
[ROLES.ADMIN]: {
|
||||
key: ROLES.ADMIN,
|
||||
label: "管理员",
|
||||
icon: "shield",
|
||||
colorToken: "--accent",
|
||||
softToken: "--accent-soft",
|
||||
},
|
||||
[ROLES.BOARD_ADMIN]: {
|
||||
key: ROLES.BOARD_ADMIN,
|
||||
label: "板块管理员",
|
||||
icon: "shield-board",
|
||||
colorToken: "--accent",
|
||||
softToken: "--accent-soft",
|
||||
},
|
||||
[ROLES.USER]: {
|
||||
key: ROLES.USER,
|
||||
label: "普通用户",
|
||||
icon: "user",
|
||||
colorToken: "--ink-3",
|
||||
softToken: "--panel-2",
|
||||
},
|
||||
};
|
||||
|
||||
export function roleMeta(role?: string | null): RoleMeta {
|
||||
return ROLE_META[(role as RoleKey)] ?? ROLE_META.user;
|
||||
}
|
||||
Reference in New Issue
Block a user