增加用户认证、等级、徽章与积分体系,并优化管理后台体验。
覆盖站长调账与积分解锁内容;后台按审核优先分组导航,仪表盘展示待办,用户管理改为成员目录式布局。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
64
frontend/src/utils/badgeIcons.ts
Normal file
64
frontend/src/utils/badgeIcons.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
Award,
|
||||
Calendar,
|
||||
CalendarHeart,
|
||||
Coins,
|
||||
Flame,
|
||||
Gem,
|
||||
Heart,
|
||||
HeartHandshake,
|
||||
Medal,
|
||||
Shield,
|
||||
Sparkles,
|
||||
Star,
|
||||
Trophy,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
/** 徽章可用图标(与前端展示、后台选择器一致) */
|
||||
export const BADGE_ICON_OPTIONS: { key: string; label: string; Icon: LucideIcon }[] = [
|
||||
{ key: 'star', label: '星星', Icon: Star },
|
||||
{ key: 'award', label: '奖章', Icon: Award },
|
||||
{ key: 'medal', label: '勋章', Icon: Medal },
|
||||
{ key: 'trophy', label: '奖杯', Icon: Trophy },
|
||||
{ key: 'sparkles', label: '闪光', Icon: Sparkles },
|
||||
{ key: 'shield', label: '盾牌', Icon: Shield },
|
||||
{ key: 'heart', label: '爱心', Icon: Heart },
|
||||
{ key: 'heart-handshake', label: '人心', Icon: HeartHandshake },
|
||||
{ key: 'flame', label: '火焰', Icon: Flame },
|
||||
{ key: 'coins', label: '金币', Icon: Coins },
|
||||
{ key: 'gem', label: '宝石', Icon: Gem },
|
||||
{ key: 'calendar', label: '日历', Icon: Calendar },
|
||||
{ key: 'calendar-heart', label: '纪念日', Icon: CalendarHeart },
|
||||
];
|
||||
|
||||
const ICON_MAP: Record<string, LucideIcon> = Object.fromEntries(
|
||||
BADGE_ICON_OPTIONS.map(o => [o.key, o.Icon]),
|
||||
);
|
||||
|
||||
export function badgeIcon(key?: string): LucideIcon {
|
||||
if (!key) return Star;
|
||||
return ICON_MAP[key] || Star;
|
||||
}
|
||||
|
||||
export const BADGE_METRIC_OPTIONS: { value: string; label: string; hint: string }[] = [
|
||||
{ value: 'tenure_days', label: '注册天数', hint: '账号注册满 N 天自动获得' },
|
||||
{ value: 'likes_received', label: '帖子获赞', hint: '公开帖累计获赞达到 N' },
|
||||
{ value: 'creator_income', label: '创作收入', hint: '积分解锁分成累计达到 N' },
|
||||
];
|
||||
|
||||
export function metricLabel(metric?: string): string {
|
||||
return BADGE_METRIC_OPTIONS.find(m => m.value === metric)?.label || metric || '—';
|
||||
}
|
||||
|
||||
export function formatBadgeCondition(b: {
|
||||
kind?: string;
|
||||
metric?: string;
|
||||
threshold?: number;
|
||||
description?: string;
|
||||
}): string {
|
||||
if (b.kind === 'auto') {
|
||||
return `${metricLabel(b.metric)} ≥ ${b.threshold ?? 0}`;
|
||||
}
|
||||
return b.description?.trim() || '由站长手动颁发';
|
||||
}
|
||||
@@ -9,9 +9,9 @@ import { enhanceHeadingAnchors } from './postHeadings';
|
||||
* 全局选择器仍会污染整页,故显式禁止。
|
||||
*/
|
||||
export const POST_CONTENT_PURIFY_CONFIG: Config = {
|
||||
ADD_TAGS: ['members-only', 'reply-only'],
|
||||
ADD_TAGS: ['members-only', 'reply-only', 'points-only'],
|
||||
ADD_ATTR: [
|
||||
'data-locked', 'data-length', 'data-gate', 'target', 'rel',
|
||||
'data-locked', 'data-length', 'data-gate', 'data-cost', 'data-block-key', 'target', 'rel',
|
||||
'data-code-copy', 'data-code-fold', 'data-lang', 'data-full',
|
||||
'data-code-style', 'data-line-numbers', 'data-collapsed', 'data-line-count', 'data-lineno-digits',
|
||||
'data-image-group', 'data-layout', 'data-display',
|
||||
@@ -27,6 +27,29 @@ const LOCK_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height
|
||||
|
||||
const REPLY_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 15v4a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h10"/><path d="M20 7V3"/><path d="M22 5h-4"/></svg>`;
|
||||
|
||||
/** 积分解锁门控 */
|
||||
function buildPointsLockedGateHtml(charLength: number, cost: number, blockKey: string, isLoggedIn: boolean): string {
|
||||
const lengthHint = charLength > 0 ? `约 ${charLength} 字` : '付费内容';
|
||||
const actions = isLoggedIn
|
||||
? `<button type="button" class="post-points-only__gate-btn" data-points-unlock data-block-key="${blockKey}" data-cost="${cost}">花费 ${cost} 积分解锁</button>`
|
||||
: `<button type="button" class="post-points-only__gate-btn" data-members-login>登录后解锁</button>
|
||||
<button type="button" class="post-points-only__gate-link" data-members-register>免费注册</button>`;
|
||||
|
||||
return `
|
||||
<div class="post-points-only__locked-wrap">
|
||||
<div class="post-points-only__gate">
|
||||
<span class="post-points-only__gate-icon" aria-hidden="true">${LOCK_ICON_SVG}</span>
|
||||
<div class="post-points-only__gate-text">
|
||||
<p class="post-points-only__gate-title">积分可见(${lengthHint})</p>
|
||||
<p class="post-points-only__gate-desc">解锁需 ${cost} 积分,作者获得分成</p>
|
||||
</div>
|
||||
<div class="post-points-only__gate-actions">
|
||||
${actions}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** 游客看到的锁定区块:流内嵌条 + 登录引导(精简高度) */
|
||||
function buildLockedGateHtml(charLength: number): string {
|
||||
const lengthHint = charLength > 0
|
||||
@@ -150,6 +173,27 @@ export function renderPostContentHtml(
|
||||
el.innerHTML = `<div class="post-reply-only__body">${innerHtml}</div>`;
|
||||
});
|
||||
|
||||
doc.querySelectorAll('points-only').forEach(el => {
|
||||
const locked = el.getAttribute('data-locked') === 'true';
|
||||
const cost = parseInt(el.getAttribute('data-cost') || '10', 10) || 10;
|
||||
const blockKey = el.getAttribute('data-block-key') || '';
|
||||
|
||||
if (locked) {
|
||||
const charLength = parseInt(el.getAttribute('data-length') || '0', 10) || 0;
|
||||
el.className = 'post-points-only post-points-only--locked';
|
||||
el.innerHTML = buildPointsLockedGateHtml(charLength, cost, blockKey, isLoggedIn);
|
||||
return;
|
||||
}
|
||||
|
||||
const innerHtml = extractGatedInnerHtml(
|
||||
el,
|
||||
'post-points-only__body',
|
||||
'post-points-only__badge',
|
||||
);
|
||||
el.className = 'post-points-only post-points-only--visible';
|
||||
el.innerHTML = `<div class="post-points-only__body">${innerHtml}</div>`;
|
||||
});
|
||||
|
||||
doc.querySelectorAll('img').forEach(img => {
|
||||
if (!img.getAttribute('loading')) img.setAttribute('loading', 'lazy');
|
||||
if (!img.getAttribute('decoding')) img.setAttribute('decoding', 'async');
|
||||
|
||||
20
frontend/src/utils/userMeta.ts
Normal file
20
frontend/src/utils/userMeta.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/** 等级门槛(与后端 model.LevelThresholds 一致) */
|
||||
export const LEVEL_THRESHOLDS = [0, 20, 50, 100, 200, 400, 800, 1500, 3000, 5000];
|
||||
|
||||
export function levelFromExp(exp = 0): number {
|
||||
let level = 1;
|
||||
for (let i = 0; i < LEVEL_THRESHOLDS.length; i += 1) {
|
||||
if (exp >= LEVEL_THRESHOLDS[i]) level = i + 1;
|
||||
}
|
||||
return level;
|
||||
}
|
||||
|
||||
export function resolveUserLevel(u?: { level?: number; exp?: number } | null): number {
|
||||
if (u?.level && u.level > 0) return u.level;
|
||||
return levelFromExp(u?.exp ?? 0);
|
||||
}
|
||||
|
||||
/** 是否免审发帖/评论 */
|
||||
export function skipsModeration(u?: { role?: string; verified?: boolean } | null): boolean {
|
||||
return !!u && (u.role === 'admin' || !!u.verified);
|
||||
}
|
||||
Reference in New Issue
Block a user