feat: 用户徽章与等级体系,积分经济管理重构

- 新增徽章系统:badge 服务与 handler、管理端配置页、BadgeChip/LevelBadge 展示组件
- 新增等级模型:model/level 及前端 levels 元数据
- 管理端经济模块拆分为 points/badges/levels 独立页面,EconomySettings 迁入设置
- 积分、签到、评论等服务适配等级与徽章逻辑
This commit is contained in:
2026-09-25 16:38:29 +08:00
parent 7b327bc8cc
commit 4ae57d9392
57 changed files with 3939 additions and 147 deletions

View File

@@ -12,6 +12,7 @@ import {
type FooterLink,
type FooterLinksAlign,
} from "./brand";
import { DEFAULT_LEVEL_DEFS, LEVEL_COLOR_KEYS, MAX_LEVEL_COUNT, type LevelDef } from "./levels";
import { CSRF_COOKIE } from "./cookies";
import { publishPublicMetaSnapshot } from "./publicMetaSnapshot";
import { emitForceLogout } from "./userEvents";
@@ -47,6 +48,27 @@ function ssrInit(headers?: HeadersInit): RequestInit {
}
// 类型定义
export interface Badge {
id: number;
name: string;
/** lucide 图标名(前端白名单渲染,未知回落 Medal) */
icon: string;
/** CSS token 色板键(gold/accent/ok/danger/info/purple/teal) */
color: string;
created_at: string;
updated_at: string;
}
/** 徽章授予记录(badge 为嵌套定义,后端 Preload 填充) */
export interface UserBadgeView {
id: number;
badge_id: number;
user_id: number;
badge: Badge;
awarded_by: number;
awarded_at: string;
}
export interface User {
id: number;
username: string;
@@ -62,6 +84,11 @@ export interface User {
// 仅 /api/me 与 /api/profile 响应中返回(用户自身敏感信息)
email?: string;
signature?: string;
// 用户等级(后端按累计获得积分计算;1 起)与累计获得积分
level?: number;
total_points?: number;
// 管理员颁布的徽章(/me、用户资料页全量;作者点处仅展示前 2 枚)
badges?: UserBadgeView[];
}
// /api/me 响应:用户信息 + 未读通知数(SSR layout 一次请求直出)
@@ -544,6 +571,11 @@ export interface UserProfile {
created_at: string;
/** 仅超管/站长访客可见(公开资料不下发) */
banned?: boolean;
// 用户等级(后端按累计获得积分计算)与累计获得积分
level?: number;
total_points?: number;
// 徽章墙(全量,含授予时间)
badges?: UserBadgeView[];
};
stats: {
post_count: number;
@@ -576,7 +608,7 @@ export interface NotificationItem {
id: number;
user_id: number;
actor_id: number;
type: "comment" | "reply" | "like" | "approved" | "rejected" | "mention" | "pending_review" | "deleted";
type: "comment" | "reply" | "like" | "approved" | "rejected" | "mention" | "pending_review" | "deleted" | "badge";
post_id: number;
comment_id: number;
room_id?: number;
@@ -797,6 +829,26 @@ export interface PublicSettings {
bg_admin_url: string;
/** 后台铺放模式 */
bg_admin_mode: string;
/** 签到基础分;0=关闭;缺省 5 */
points_checkin_base: number;
/** 每满 N 天连续签到触发加成;0=关闭;缺省 7 */
points_streak_every_days: number;
/** 连续签到加成分值;缺省 0 */
points_streak_bonus: number;
/** 发帖奖励;0=关闭;缺省 0 */
points_post_reward: number;
/** 发帖奖励每日上限笔数;0=不限;缺省 0 */
points_post_daily_cap: number;
/** 回复奖励;0=关闭;缺省 0 */
points_reply_reward: number;
/** 回复奖励每日上限笔数;0=不限;缺省 0 */
points_reply_daily_cap: number;
/** 帖子被推荐奖励(每帖仅一次);0=关闭;缺省 0 */
points_recommend_reward: number;
/** 用户等级体系(数量/每级阈值/每级色板 key);等级数值由后端计算 */
levels: LevelDef[];
/** 等级特效动画总开关(流光动画);缺省开 */
levels_fx: boolean;
}
/** 与后端 DefaultAttachmentExts 保持同步 */
@@ -847,6 +899,16 @@ const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
bg_site_mode: "cover",
bg_admin_url: "",
bg_admin_mode: "cover",
points_checkin_base: 5,
points_streak_every_days: 7,
points_streak_bonus: 0,
points_post_reward: 0,
points_post_daily_cap: 0,
points_reply_reward: 0,
points_reply_daily_cap: 0,
points_recommend_reward: 0,
levels: [...DEFAULT_LEVEL_DEFS],
levels_fx: true,
};
function normalizeAttachmentExts(raw: unknown): string[] {
@@ -932,9 +994,70 @@ function normalizePublicSettings(data: Partial<PublicSettings> | null | undefine
bg_site_mode: typeof data?.bg_site_mode === "string" ? data.bg_site_mode : "cover",
bg_admin_url: typeof data?.bg_admin_url === "string" ? data.bg_admin_url : "",
bg_admin_mode: typeof data?.bg_admin_mode === "string" ? data.bg_admin_mode : "cover",
...normalizePointsSettings(data),
levels: normalizeLevels(data?.levels),
levels_fx: data?.levels_fx !== false,
};
}
/** 积分规则 8 键:0–100 有效整数,越界/异常回落默认值 */
function normalizePointsSettings(
data: Partial<PublicSettings> | null | undefined,
): Pick<
PublicSettings,
| "points_checkin_base"
| "points_streak_every_days"
| "points_streak_bonus"
| "points_post_reward"
| "points_post_daily_cap"
| "points_reply_reward"
| "points_reply_daily_cap"
| "points_recommend_reward"
> {
const num = (key: keyof PublicSettings): number | undefined => {
const v = data?.[key];
return typeof v === "number" && v >= 0 && v <= 100 ? Math.round(v) : undefined;
};
return {
points_checkin_base: num("points_checkin_base") ?? DEFAULT_PUBLIC_SETTINGS.points_checkin_base,
points_streak_every_days:
num("points_streak_every_days") ?? DEFAULT_PUBLIC_SETTINGS.points_streak_every_days,
points_streak_bonus: num("points_streak_bonus") ?? DEFAULT_PUBLIC_SETTINGS.points_streak_bonus,
points_post_reward: num("points_post_reward") ?? DEFAULT_PUBLIC_SETTINGS.points_post_reward,
points_post_daily_cap:
num("points_post_daily_cap") ?? DEFAULT_PUBLIC_SETTINGS.points_post_daily_cap,
points_reply_reward: num("points_reply_reward") ?? DEFAULT_PUBLIC_SETTINGS.points_reply_reward,
points_reply_daily_cap:
num("points_reply_daily_cap") ?? DEFAULT_PUBLIC_SETTINGS.points_reply_daily_cap,
points_recommend_reward:
num("points_recommend_reward") ?? DEFAULT_PUBLIC_SETTINGS.points_recommend_reward,
};
}
/** 等级配置:2–12 级、阈值非负且严格递增、首级 0、色板 key 合法;异常回落默认 */
function normalizeLevels(raw: unknown): LevelDef[] {
if (!Array.isArray(raw) || raw.length < 2 || raw.length > MAX_LEVEL_COUNT) {
return [...DEFAULT_LEVEL_DEFS];
}
const out: LevelDef[] = [];
let prev = -1;
for (const item of raw) {
const threshold =
typeof item?.threshold === "number" && Number.isFinite(item.threshold) && item.threshold >= 0
? Math.round(item.threshold)
: NaN;
const color =
typeof item?.color === "string" && LEVEL_COLOR_KEYS.includes(item.color) ? item.color : "";
if (Number.isNaN(threshold) || !color || threshold <= prev) {
return [...DEFAULT_LEVEL_DEFS];
}
out.push({ threshold, color });
prev = threshold;
}
if (out[0]?.threshold !== 0) return [...DEFAULT_LEVEL_DEFS];
return out;
}
// SSR 读取公开站点设置(主题色注入用);后端不可用时降级为默认色,不阻塞渲染
export async function fetchPublicSettings(): Promise<PublicSettings> {
try {
@@ -2139,6 +2262,98 @@ export async function apiAdminToggleAnnouncementPin(
return data as { announcement: Announcement };
}
// ---------- 后台徽章管理 ----------
export interface BadgeWithHolders extends Badge {
holders: number;
}
export interface BadgeHolder {
user_id: number;
username: string;
nickname: string;
awarded_at: string;
}
export async function apiAdminListBadges(): Promise<{ badges: BadgeWithHolders[] }> {
const res = await fetchWithRefresh("/api/admin/badges", { headers: clientHeaders() });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "获取徽章列表失败");
}
return res.json();
}
export interface BadgeInput {
name: string;
icon: string;
color: string;
}
async function saveBadge(method: "POST" | "PUT", url: string, input: BadgeInput): Promise<Badge> {
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.badge ?? data) as Badge;
}
export function apiAdminCreateBadge(input: BadgeInput) {
return saveBadge("POST", "/api/admin/badges", input);
}
export function apiAdminUpdateBadge(id: number, input: BadgeInput) {
return saveBadge("PUT", `/api/admin/badges/${id}`, input);
}
export async function apiAdminDeleteBadge(id: number): Promise<void> {
const res = await fetchWithRefresh(`/api/admin/badges/${id}`, {
method: "DELETE",
headers: clientHeaders(),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "删除徽章失败");
}
}
export async function apiAdminGrantBadge(id: number, userId: number): Promise<void> {
const res = await fetchWithRefresh(`/api/admin/badges/${id}/grant`, {
method: "POST",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ user_id: userId }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "授予失败");
}
}
export async function apiAdminRevokeBadge(id: number, userId: number): Promise<void> {
const res = await fetchWithRefresh(`/api/admin/badges/${id}/grant/${userId}`, {
method: "DELETE",
headers: clientHeaders(),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "撤销失败");
}
}
export async function apiAdminBadgeHolders(id: number): Promise<{ holders: BadgeHolder[] }> {
const res = await fetchWithRefresh(`/api/admin/badges/${id}/holders`, {
headers: clientHeaders(),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "获取持有者失败");
}
return res.json();
}
/** SSR / 公开:分页公告列表 */
export async function fetchAnnouncementsPage(
page = 1,
@@ -2257,6 +2472,8 @@ export interface AdminUser {
banned: boolean;
can_manage_messages?: boolean;
points?: number;
total_points?: number;
level?: number;
post_count: number;
comment_count: number;
created_at: string;
@@ -2397,6 +2614,41 @@ export async function apiAdminSetUserMessages(
return data as { user: AdminUser };
}
// 管理员手动调整用户积分(加分计累计可升级;扣分仅扣余额不降级),理由必填
export interface AdminAdjustPointsResult {
balance: number;
total_points: number;
level: number;
}
export async function apiAdminAdjustUserPoints(
id: number,
delta: number,
note: string
): Promise<AdminAdjustPointsResult> {
const res = await fetchWithRefresh(`/api/admin/users/${id}/points`, {
method: "PUT",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ delta, note }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "积分调整失败");
return data as AdminAdjustPointsResult;
}
// 管理端查询某用户积分流水(分页,id DESC)
export async function apiAdminUserPointLedger(
id: number,
page = 1
): Promise<{ items: PointLedgerItem[]; total: number; page: number; size: number }> {
const res = await fetchWithRefresh(`/api/admin/users/${id}/points/ledger?page=${page}`, {
headers: clientHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取积分流水失败");
return data as { items: PointLedgerItem[]; total: number; page: number; size: number };
}
// 某用户的登录历史(IP/UA/成败)
export async function apiAdminUserLoginLogs(
id: number,
@@ -2759,6 +3011,17 @@ export type UpdateSiteSettingsBody = {
comments_require_login?: boolean;
allow_messages?: boolean;
post_cooldown_hours?: number;
points_checkin_base?: number;
points_streak_every_days?: number;
points_streak_bonus?: number;
points_post_reward?: number;
points_post_daily_cap?: number;
points_reply_reward?: number;
points_reply_daily_cap?: number;
points_recommend_reward?: number;
/** 用户等级体系(数量/每级阈值/每级色板 key);整体替换保存 */
levels?: LevelDef[];
levels_fx?: boolean;
code_block_auto_fold?: boolean;
code_block_fold_lines?: number;
ui_animations?: boolean;