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;
|
||||
|
||||
47
frontend/lib/badgeIcons.ts
Normal file
47
frontend/lib/badgeIcons.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
Medal,
|
||||
Award,
|
||||
Trophy,
|
||||
Crown,
|
||||
Star,
|
||||
Heart,
|
||||
Zap,
|
||||
Flame,
|
||||
Gem,
|
||||
Shield,
|
||||
Rocket,
|
||||
Sparkles,
|
||||
ThumbsUp,
|
||||
BookOpen,
|
||||
Code,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
|
||||
/**
|
||||
* 徽章图标白名单:后台管理以 lucide 图标名存储,前端仅渲染此表内的图标,
|
||||
* 未知/空名回落 Medal(与后端 model.Badge.Icon 注释约定一致)。
|
||||
*/
|
||||
export const BADGE_ICONS: Record<string, LucideIcon> = {
|
||||
medal: Medal,
|
||||
award: Award,
|
||||
trophy: Trophy,
|
||||
crown: Crown,
|
||||
star: Star,
|
||||
heart: Heart,
|
||||
zap: Zap,
|
||||
flame: Flame,
|
||||
gem: Gem,
|
||||
shield: Shield,
|
||||
rocket: Rocket,
|
||||
sparkles: Sparkles,
|
||||
thumbs_up: ThumbsUp,
|
||||
book_open: BookOpen,
|
||||
code: Code,
|
||||
wrench: Wrench,
|
||||
};
|
||||
|
||||
/** 取徽章图标组件;未知/空名回落 Medal */
|
||||
export function badgeIcon(name?: string | null): LucideIcon {
|
||||
return (name && BADGE_ICONS[name]) || Medal;
|
||||
}
|
||||
137
frontend/lib/levels.ts
Normal file
137
frontend/lib/levels.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
// 用户等级展示映射:等级体系(数量/阈值/颜色)由后台配置、后端下发
|
||||
// (PublicSettings.levels),本模块只负责把它翻译成 CSS 变量(--lv-N-fg/-bg),
|
||||
// 不持有阈值逻辑;等级数值由后端按累计获得积分计算(user.level),前端零计算。
|
||||
|
||||
/** 单级配置:threshold 为该级累计积分下限(Lv1 恒为 0),color 为色板 key */
|
||||
export interface LevelDef {
|
||||
threshold: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/** 单级色规格:bg 为完整 background 简写值(特效档为含高光段的流动渐变) */
|
||||
export interface LevelColorSpec {
|
||||
fg: string;
|
||||
bg: string;
|
||||
/** 内描边色(黑金档金边用),默认透明 */
|
||||
ring?: string;
|
||||
/** 文字渐变层(完整 background 值):存在时 fg 应为 transparent,
|
||||
* 文字以流动渐变呈现(鎏金字,金属质感的来源),见 .lv-text */
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 等级色板:低段淡雅、高段金属流光,等级专属固定色,不随明暗主题切换。
|
||||
* 段位语义:danger=红牌、accent=曜黑(黑金流光)、purple=鎏紫、gold=鎏金(暗底金渐变字)。
|
||||
* 流光不靠扫光伪元素,由渐变内嵌高光段随 background-position 流动自然扫过。
|
||||
*/
|
||||
export const LEVEL_COLOR_TOKENS: Record<string, LevelColorSpec> = {
|
||||
neutral: { fg: "#9aa3b2", bg: "rgba(148,158,176,0.16)" },
|
||||
info: { fg: "#4d8dff", bg: "rgba(59,118,240,0.15)" },
|
||||
teal: { fg: "#12a89b", bg: "rgba(14,148,136,0.15)" },
|
||||
ok: { fg: "#27a94f", bg: "rgba(31,156,71,0.15)" },
|
||||
danger: {
|
||||
fg: "#ffffff",
|
||||
// 90deg 水平渐变且首尾同色;size 固定 200%(配 lv-flow 位移 0→200% 严格无缝循环)
|
||||
bg: "linear-gradient(90deg, #b91c1c, #ef4444 25%, #fecaca 38%, #dc2626 52%, #991b1b 78%, #b91c1c) 0% 0% / 200% 100%",
|
||||
},
|
||||
accent: {
|
||||
fg: "#e9c46a",
|
||||
bg: "linear-gradient(90deg, #16171c, #23252c 22%, #4a3d1c 36%, #23252c 52%, #16171c 78%) 0% 0% / 200% 100%",
|
||||
ring: "rgba(233, 196, 106, 0.5)",
|
||||
},
|
||||
purple: {
|
||||
fg: "#ffffff",
|
||||
bg: "linear-gradient(90deg, #6d28d9, #a855f7 24%, #e9d5ff 37%, #8b5cf6 52%, #6d28d9 78%) 0% 0% / 200% 100%",
|
||||
},
|
||||
gold: {
|
||||
// 鎏金字:暗底衬托,亮金流动渐变只在文字上(金底小面积易显廉价);
|
||||
// 高光占比放大让 11px 小字保持干净亮泽
|
||||
fg: "transparent",
|
||||
bg: "linear-gradient(180deg, #221b0f, #100d07)",
|
||||
ring: "rgba(240, 200, 120, 0.45)",
|
||||
text: "linear-gradient(90deg, #c9973f, #ffe9a8 30%, #fff6d8 46%, #f0c964 66%, #c9973f) 0% 0% / 200% 100%",
|
||||
},
|
||||
};
|
||||
|
||||
export const LEVEL_COLOR_KEYS = Object.keys(LEVEL_COLOR_TOKENS);
|
||||
|
||||
/**
|
||||
* 等级徽章共享类:LevelBadge 与后台等级管理预览必须逐字同用,
|
||||
* 保证前后台胶囊大小完全一致(尺寸基准:text-11px + 紧凑行高 + px-2 圆角胶囊)
|
||||
*/
|
||||
export const LEVEL_BADGE_CLASS =
|
||||
"lv-badge inline-flex items-center justify-center rounded-full px-2 py-[2px] text-[11px] font-semibold leading-[1.35] whitespace-nowrap";
|
||||
|
||||
/**
|
||||
* 把 background 简写值拆为长写属性(React inline style 不允许简写与
|
||||
* backgroundClip 等长写混用)。支持三种形态:
|
||||
* "none" / 纯色(rgba、#hex)/ "linear-gradient(...) <position> / <size>"
|
||||
*/
|
||||
export function splitBackground(value: string): {
|
||||
image: string;
|
||||
color?: string;
|
||||
position: string;
|
||||
size: string;
|
||||
} {
|
||||
const v = value.trim();
|
||||
if (v === "" || v === "none") {
|
||||
return { image: "none", position: "0% 0%", size: "auto" };
|
||||
}
|
||||
// 渐变内部 stops 均为 hex 色,无嵌套括号,取到首个右括号即渐变结束
|
||||
const m = v.match(/^(linear-gradient\([^)]*\))\s*(.*)$/);
|
||||
if (m) {
|
||||
const rest = m[2].trim();
|
||||
const [pos, sizePart] = rest ? rest.split("/").map((s) => s.trim()) : [];
|
||||
return {
|
||||
image: m[1],
|
||||
position: pos || "0% 0%",
|
||||
size: sizePart || "auto",
|
||||
};
|
||||
}
|
||||
return { image: "none", color: v, position: "0% 0%", size: "auto" };
|
||||
}
|
||||
|
||||
/** 等级数量上限(CSS 变量按此档数固定下发;与后端 model.MaxLevelCount 同步) */
|
||||
export const MAX_LEVEL_COUNT = 12;
|
||||
|
||||
/** 默认 7 级(与后端 model.DefaultLevelDefs 保持同步,异常回落用) */
|
||||
export const DEFAULT_LEVEL_DEFS: LevelDef[] = [
|
||||
{ threshold: 0, color: "neutral" },
|
||||
{ threshold: 50, color: "info" },
|
||||
{ threshold: 200, color: "teal" },
|
||||
{ threshold: 500, color: "ok" },
|
||||
{ threshold: 1200, color: "accent" },
|
||||
{ threshold: 3000, color: "purple" },
|
||||
{ threshold: 8000, color: "gold" },
|
||||
];
|
||||
|
||||
/** 等级号钳制到 1..MAX_LEVEL_COUNT(游客/异常数据回落 Lv1) */
|
||||
export function clampLevel(level?: number | null): number {
|
||||
return Math.min(Math.max(Math.trunc(level ?? 1), 1), MAX_LEVEL_COUNT);
|
||||
}
|
||||
|
||||
function tokensOf(color: string) {
|
||||
return LEVEL_COLOR_TOKENS[color] ?? LEVEL_COLOR_TOKENS.neutral;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成等级颜色 CSS(:root 下的 --lv-N-fg / --lv-N-bg)。
|
||||
* 固定输出 1..MAX_LEVEL_COUNT 全部档位:超配置数量的档位重复末级颜色,
|
||||
* 保证配置缩级后旧数据/越界场景徽章仍有着色。
|
||||
*/
|
||||
export function levelVarsCSS(levels?: LevelDef[] | null): string {
|
||||
const defs = levels && levels.length > 0 ? levels : DEFAULT_LEVEL_DEFS;
|
||||
const lines: string[] = [];
|
||||
for (let n = 1; n <= MAX_LEVEL_COUNT; n++) {
|
||||
const idx = Math.min(n, defs.length) - 1;
|
||||
const t = tokensOf(defs[idx]?.color ?? "");
|
||||
// 文字渐变拆成 image/position/size 三个变量下发,
|
||||
// 供 inline style 全长写引用(避免 React 简写/长写混用警告)
|
||||
const text = splitBackground(t.text ?? "none");
|
||||
lines.push(
|
||||
`--lv-${n}-fg:${t.fg};--lv-${n}-bg:${t.bg};--lv-${n}-ring:${t.ring ?? "transparent"};` +
|
||||
`--lv-${n}-text-image:${text.image};--lv-${n}-text-position:${text.position};--lv-${n}-text-size:${text.size}`,
|
||||
);
|
||||
}
|
||||
return `:root{${lines.join(";")}}`;
|
||||
}
|
||||
@@ -7,6 +7,11 @@ const REASON_LABELS: Record<string, string> = {
|
||||
bounty_escrow: "悬赏托管",
|
||||
bounty_refund: "悬赏退回",
|
||||
bounty_award: "悬赏采纳",
|
||||
post_reward: "发帖奖励",
|
||||
reply_reward: "回复奖励",
|
||||
streak_bonus: "连续签到加成",
|
||||
recommend_reward: "帖子被推荐",
|
||||
admin_adjust: "管理员调整",
|
||||
};
|
||||
|
||||
export function pointReasonLabel(reason: string): string {
|
||||
|
||||
Reference in New Issue
Block a user