feat(admin): 完善站点设置、内容回收站与后台菜单分组

新增站点设置与内容管理(含已删除恢复/清除),用户页改用独有运营指标,并按概览/内容/成员与经济/站点重组侧栏。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 05:56:27 +08:00
parent be47b38370
commit dd7bd010b8
25 changed files with 2193 additions and 323 deletions

View File

@@ -533,20 +533,46 @@ export async function fetchBoards(): Promise<{ boards: BoardWithCount[] }> {
export interface PublicSettings {
accent: string;
trust_reviewed_publish: boolean;
site_name: string;
site_description: string;
allow_register: boolean;
post_cooldown_hours: number;
}
const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
accent: "",
trust_reviewed_publish: true,
site_name: "姜十三论坛",
site_description: "姜十三论坛 - 技术分享与讨论社区",
allow_register: true,
post_cooldown_hours: 24,
};
function normalizePublicSettings(data: Partial<PublicSettings> | null | undefined): PublicSettings {
return {
accent: data?.accent ?? "",
trust_reviewed_publish: data?.trust_reviewed_publish !== false,
site_name: (data?.site_name || "").trim() || DEFAULT_PUBLIC_SETTINGS.site_name,
site_description:
data?.site_description !== undefined
? String(data.site_description).trim()
: DEFAULT_PUBLIC_SETTINGS.site_description,
allow_register: data?.allow_register !== false,
post_cooldown_hours:
typeof data?.post_cooldown_hours === "number" && data.post_cooldown_hours >= 0
? data.post_cooldown_hours
: DEFAULT_PUBLIC_SETTINGS.post_cooldown_hours,
};
}
// SSR 读取公开站点设置(主题色注入用);后端不可用时降级为默认色,不阻塞渲染
export async function fetchPublicSettings(): Promise<PublicSettings> {
try {
const res = await fetch(`${API_BASE}/api/settings`, ssrInit());
if (!res.ok) return { accent: "", trust_reviewed_publish: true };
const data = (await res.json()) as Partial<PublicSettings>;
return {
accent: data.accent ?? "",
trust_reviewed_publish: data.trust_reviewed_publish !== false,
};
if (!res.ok) return { ...DEFAULT_PUBLIC_SETTINGS };
return normalizePublicSettings((await res.json()) as Partial<PublicSettings>);
} catch {
return { accent: "", trust_reviewed_publish: true };
return { ...DEFAULT_PUBLIC_SETTINGS };
}
}
@@ -1182,10 +1208,13 @@ export interface LoginLogsResponse {
}
export interface AdminUserSummary {
online: number;
in_cooldown: number;
active_7d: number;
failed_logins_24h: number;
total: number;
admins: number;
banned: number;
today_new: number;
}
export interface AdminUsersResponse {
@@ -1423,16 +1452,16 @@ export async function apiGetSettings(): Promise<PublicSettings> {
// no-store:主题同步必须读到管理员刚保存的最新配色,不走浏览器缓存
const res = await fetchWithRefresh("/api/settings", { method: "GET", cache: "no-store" });
if (!res.ok) throw new Error("获取站点设置失败");
const data = (await res.json()) as Partial<PublicSettings>;
return {
accent: data.accent ?? "",
trust_reviewed_publish: data.trust_reviewed_publish !== false,
};
return normalizePublicSettings(await res.json());
}
export type UpdateSiteSettingsBody = {
accent?: string;
trust_reviewed_publish?: boolean;
site_name?: string;
site_description?: string;
allow_register?: boolean;
post_cooldown_hours?: number;
};
// 更新站点设置(字段可选);accent 传空串恢复默认主题色
@@ -1444,10 +1473,7 @@ export async function apiUpdateSiteSettings(body: UpdateSiteSettingsBody): Promi
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "保存设置失败");
return {
accent: data.accent ?? "",
trust_reviewed_publish: data.trust_reviewed_publish !== false,
};
return normalizePublicSettings(data as Partial<PublicSettings>);
}
// ===== 板块(客户端,供角色授权弹窗勾选) =====
@@ -1527,6 +1553,137 @@ export const apiAdminApproveComment = (id: number) =>
export const apiAdminRejectComment = (id: number) =>
moderationAction(`/api/admin/moderation/comments/${id}/reject`);
// ===== 内容库(帖子 / 评论 + 回收站) =====
export type AdminContentStatus = "live" | "pending" | "rejected" | "published" | "deleted";
export interface AdminContentCounts {
posts_live: number;
posts_pending: number;
posts_rejected: number;
posts_deleted: number;
comments_live: number;
comments_pending: number;
comments_rejected: number;
comments_deleted: number;
}
export interface AdminContentPost {
id: number;
title: string;
status: string;
deleted: boolean;
deleted_at?: string;
created_at: string;
comment_count: number;
board_id: number;
board: Board;
user: User;
}
export interface AdminContentComment {
id: number;
post_id: number;
post_title: string;
board_id: number;
content: string;
status: string;
deleted: boolean;
deleted_at?: string;
created_at: string;
board: Board;
user: User;
}
export async function apiAdminContentCounts(): Promise<AdminContentCounts> {
const res = await fetchWithRefresh("/api/admin/content/counts", { headers: clientHeaders() });
if (!res.ok) throw new Error("获取内容统计失败");
return res.json();
}
export async function apiAdminContentPosts(
page = 1,
status: AdminContentStatus = "live",
q = ""
): Promise<{ posts: AdminContentPost[]; total: number; page: number }> {
const params = new URLSearchParams({ page: String(page), status });
if (q.trim()) params.set("q", q.trim());
const res = await fetchWithRefresh(`/api/admin/content/posts?${params}`, {
headers: clientHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取帖子列表失败");
return data;
}
export async function apiAdminContentComments(
page = 1,
status: AdminContentStatus = "live",
q = ""
): Promise<{ comments: AdminContentComment[]; total: number; page: number }> {
const params = new URLSearchParams({ page: String(page), status });
if (q.trim()) params.set("q", q.trim());
const res = await fetchWithRefresh(`/api/admin/content/comments?${params}`, {
headers: clientHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取评论列表失败");
return data;
}
async function contentAction(url: string, method: string): Promise<void> {
const res = await fetchWithRefresh(url, { method, headers: clientHeaders() });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "操作失败");
}
}
export const apiAdminSoftDeletePost = (id: number) =>
contentAction(`/api/admin/content/posts/${id}`, "DELETE");
export const apiAdminRestorePost = (id: number) =>
contentAction(`/api/admin/content/posts/${id}/restore`, "PUT");
export const apiAdminPurgePost = (id: number) =>
contentAction(`/api/admin/content/posts/${id}/purge`, "DELETE");
export const apiAdminSoftDeleteComment = (id: number) =>
contentAction(`/api/admin/content/comments/${id}`, "DELETE");
export const apiAdminRestoreComment = (id: number) =>
contentAction(`/api/admin/content/comments/${id}/restore`, "PUT");
export const apiAdminPurgeComment = (id: number) =>
contentAction(`/api/admin/content/comments/${id}/purge`, "DELETE");
export async function fetchAdminContentCounts(
cookieHeader?: string
): Promise<AdminContentCounts> {
const res = await fetch(`${API_BASE}/api/admin/content/counts`, ssrInit(cookieHeaders(cookieHeader)));
if (!res.ok) {
return {
posts_live: 0,
posts_pending: 0,
posts_rejected: 0,
posts_deleted: 0,
comments_live: 0,
comments_pending: 0,
comments_rejected: 0,
comments_deleted: 0,
};
}
return res.json();
}
export async function fetchAdminContentPosts(
page = 1,
status: AdminContentStatus = "live",
cookieHeader?: string
): Promise<{ posts: AdminContentPost[]; total: number; page: number }> {
const res = await fetch(
`${API_BASE}/api/admin/content/posts?page=${page}&status=${status}`,
ssrInit(cookieHeaders(cookieHeader))
);
if (!res.ok) throw new Error("获取帖子列表失败");
return res.json();
}
// ===== 群聊(二期) =====
// 群聊房间基础模型(与后端 model.ChatRoom 对齐)