feat: 用户徽章与等级体系,积分经济管理重构
- 新增徽章系统:badge 服务与 handler、管理端配置页、BadgeChip/LevelBadge 展示组件 - 新增等级模型:model/level 及前端 levels 元数据 - 管理端经济模块拆分为 points/badges/levels 独立页面,EconomySettings 迁入设置 - 积分、签到、评论等服务适配等级与徽章逻辑
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user