增加用户认证、等级、徽章与积分体系,并优化管理后台体验。

覆盖站长调账与积分解锁内容;后台按审核优先分组导航,仪表盘展示待办,用户管理改为成员目录式布局。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-06 02:42:18 +08:00
parent b075495540
commit 6b0a1d4281
44 changed files with 4455 additions and 254 deletions

View File

@@ -15,7 +15,7 @@ import {
FileCode, PenLine, Maximize2, Minimize2,
Columns2, PanelLeft, PanelRight, StretchHorizontal,
Table as TableIcon, BetweenHorizonalStart, BetweenVerticalStart, Rows3, Columns3,
MessageSquareLock,
MessageSquareLock, Coins,
} from 'lucide-react';
import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent';
import { htmlToMarkdown, markdownToHtml } from '../utils/markdownContent';
@@ -34,6 +34,7 @@ import { api } from '../api/client';
import { notify } from '@/lib/notify';
import { MembersOnly } from './editor/MembersOnlyExtension';
import { ReplyOnly } from './editor/ReplyOnlyExtension';
import { PointsOnly } from './editor/PointsOnlyExtension';
import { TabIndent } from './editor/TabIndentExtension';
import { ArticleImage, type ImageDisplay } from './editor/ArticleImageExtension';
import { ImageGroup, suggestImageGroupLayout } from './editor/ImageGroupExtension';
@@ -264,12 +265,16 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
if (node.type.name === 'paragraph' && node.parent?.type.name === 'replyOnly') {
return REPLY_ONLY_PLACEHOLDER;
}
if (node.type.name === 'paragraph' && node.parent?.type.name === 'pointsOnly') {
return '此处内容需积分解锁后可见…';
}
return placeholder;
},
includeChildren: true,
}),
MembersOnly,
ReplyOnly,
PointsOnly,
TabIndent,
],
content: sanitizeHtml(value) || '',
@@ -541,6 +546,20 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
editor.chain().focus().insertReplyOnly().run();
}, [editor]);
const wrapPointsOnly = useCallback(() => {
if (!editor) return;
if (editor.isActive('pointsOnly')) {
editor.chain().focus().exitPointsOnly().run();
return;
}
const { from, to, empty } = editor.state.selection;
if (!empty && from !== to) {
editor.chain().focus().wrapPointsOnly(10).run();
return;
}
editor.chain().focus().insertPointsOnly(10).run();
}, [editor]);
const switchToMarkdown = useCallback(() => {
if (!editor) return;
const html = sanitizeHtml(editor.getHTML());
@@ -694,10 +713,18 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
className: 'article-tool-btn--reply',
action: wrapReplyOnly,
},
{
icon: <Coins size={15} />,
title: '积分可见',
hint: '读者花费积分解锁;可设价格',
active: editor.isActive('pointsOnly'),
className: 'article-tool-btn--points',
action: wrapPointsOnly,
},
);
return tools;
}, [editor, openLinkDialog, openCodeBlockDialog, openTableDialog, setImage, wrapMembersOnly, wrapReplyOnly, wrapSelectedAsGroup, setImageDisplay]);
}, [editor, openLinkDialog, openCodeBlockDialog, openTableDialog, setImage, wrapMembersOnly, wrapReplyOnly, wrapPointsOnly, wrapSelectedAsGroup, setImageDisplay]);
const buildMarkdownTools = useCallback((): ToolBtn[] => [
{ icon: <strong>H</strong>, title: '标题', hint: 'H2 → H6 循环', action: withMarkdown(cycleMarkdownHeading) },

View File

@@ -248,6 +248,7 @@ function CommentItem({
<UserLink
user={c.user ?? { id: c.user_id, nickname: nick }}
className="waline-comment-author"
showBadges
/>
) : (
<span className="waline-comment-author">{nick}</span>

View File

@@ -0,0 +1,138 @@
import { useCallback, useEffect, useState } from 'react';
import { Coins, Dices, Gift } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import type { CheckInStatus, LotteryStatus, PointLedger } from '../api/types';
import { useAuth } from '../hooks/useAuth';
const REASON_LABEL: Record<string, string> = {
check_in: '签到',
lottery: '抽奖',
unlock_spend: '解锁内容',
creator_income: '创作分成',
admin_adjust: '站长调账',
};
/** 个人中心:积分余额、签到、抽奖、流水 */
export default function PointsWalletPanel() {
const { refresh } = useAuth();
const [loading, setLoading] = useState(true);
const [points, setPoints] = useState(0);
const [income, setIncome] = useState(0);
const [checkIn, setCheckIn] = useState<CheckInStatus | null>(null);
const [lottery, setLottery] = useState<LotteryStatus | null>(null);
const [ledger, setLedger] = useState<PointLedger[]>([]);
const [busy, setBusy] = useState(false);
const load = useCallback(() => {
setLoading(true);
api.mePoints(1)
.then(d => {
setPoints(d.points);
setIncome(d.creator_income_total);
setCheckIn(d.check_in);
setLottery(d.lottery);
setLedger(d.ledger ?? []);
})
.catch(e => notify.error(e.message))
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
const doCheckIn = async () => {
setBusy(true);
try {
const r = await api.checkIn();
notify.success(`签到成功,+${r.check_in.today_points} 积分`);
setPoints(r.points);
setCheckIn(r.check_in);
await refresh();
load();
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '签到失败');
} finally {
setBusy(false);
}
};
const doLottery = async () => {
setBusy(true);
try {
const r = await api.lotteryDraw();
notify.success(r.lottery.points > 0 ? `抽中 ${r.lottery.points} 积分` : '未中奖,明天再来');
setPoints(r.points);
setLottery(r.lottery);
await refresh();
load();
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '抽奖失败');
} finally {
setBusy(false);
}
};
if (loading) {
return (
<div className="points-wallet">
<div className="flex justify-center py-8"><Spinner /></div>
</div>
);
}
return (
<div className="points-wallet">
<div className="points-wallet-head">
<h3>
<Coins size={18} aria-hidden />
</h3>
<div className="points-wallet-balance">
<strong>{points}</strong>
<span></span>
<em title="累计创作分成"> {income}</em>
</div>
</div>
<div className="points-wallet-actions">
<Button
size="sm"
disabled={busy || !!checkIn?.checked_in}
onClick={doCheckIn}
>
<Gift size={14} />
{checkIn?.checked_in
? `已签到(连续 ${checkIn.streak} 天)`
: `签到 +${checkIn?.today_points ?? 5}`}
</Button>
<Button
size="sm"
variant="outline"
disabled={busy || !!lottery?.drawn}
onClick={doLottery}
>
<Dices size={14} />
{lottery?.drawn ? `今日已抽(${lottery.points}` : '每日抽奖'}
</Button>
</div>
<div className="points-wallet-ledger">
<h4></h4>
{ledger.length === 0 && <p className="points-wallet-empty"></p>}
<ul>
{ledger.map(row => (
<li key={row.id}>
<span className={row.delta >= 0 ? 'pos' : 'neg'}>
{row.delta >= 0 ? '+' : ''}{row.delta}
</span>
<span>{REASON_LABEL[row.reason] || row.reason}</span>
<time>{new Date(row.created_at).toLocaleString('zh-CN')}</time>
</li>
))}
</ul>
</div>
</div>
);
}

View File

@@ -70,7 +70,6 @@ export default function PostAuthorCard({
const nick = display.nickname || display.username || `用户 #${author.id}`;
const initial = nick.charAt(0) || '?';
const signature = (profile?.signature ?? author.signature ?? '').trim();
const isAdmin = display.role === 'admin';
const isSelf = !!me && me.id === author.id;
const profileHref = userPath(author.id);
@@ -102,8 +101,7 @@ export default function PostAuthorCard({
</UserLink>
<div className="widget-author-meta">
<div className="widget-author-name-row">
<UserLink user={display} className="widget-author-name" />
{isAdmin && <Badge variant="green" className="widget-author-badge"></Badge>}
<UserLink user={display} className="widget-author-name" showBadges />
{display.banned && <Badge variant="destructive" className="widget-author-badge"></Badge>}
</div>
{signature ? (

View File

@@ -1,10 +1,11 @@
import { useMemo, useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useParams } from 'react-router-dom';
import { renderPostContentHtml } from '../utils/postContent';
import { extractHeadingsFromHtml, type PostHeading } from '../utils/postHeadings';
import { loginPath, registerPath } from '../utils/authRedirect';
import { useForumLimits } from '../hooks/useForumLimits';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import ImageLightbox from './ImageLightbox';
interface Props {
@@ -15,20 +16,28 @@ interface Props {
onHeadingsChange?: (headings: PostHeading[]) => void;
/** 点击「回复可见」门控的「去回复」 */
onRequestReply?: () => void;
/** 积分解锁成功后刷新正文 */
onUnlocked?: () => void;
postId?: number;
}
/** 帖子正文渲染(含会员专属 / 回复可见区块、代码块美化、图片灯箱) */
/** 帖子正文渲染(含会员专属 / 回复可见 / 积分可见、代码块美化、图片灯箱) */
export default function PostContent({
html,
isLoggedIn,
className = 'post-detail-content',
onHeadingsChange,
onRequestReply,
onUnlocked,
postId: postIdProp,
}: Props) {
const nav = useNavigate();
const params = useParams();
const postId = postIdProp || Number(params.id) || 0;
const { limits } = useForumLimits();
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
const [lightboxAlt, setLightboxAlt] = useState('');
const [unlocking, setUnlocking] = useState(false);
const prepared = useMemo(() => {
const rendered = renderPostContentHtml(html, isLoggedIn, {
@@ -68,6 +77,25 @@ export default function PostContent({
nav(registerPath());
return;
}
const unlockBtn = target.closest<HTMLElement>('[data-points-unlock]');
if (unlockBtn) {
e.preventDefault();
const blockKey = unlockBtn.getAttribute('data-block-key') || '';
const cost = unlockBtn.getAttribute('data-cost') || '';
if (!postId || !blockKey || unlocking) return;
if (!window.confirm(`确认花费 ${cost} 积分解锁该内容?`)) return;
setUnlocking(true);
try {
await api.unlockPostBlock(postId, blockKey);
notify.success('解锁成功');
onUnlocked?.();
} catch (err: unknown) {
notify.error(err instanceof Error ? err.message : '解锁失败');
} finally {
setUnlocking(false);
}
return;
}
const zoomImg = target.closest<HTMLImageElement>('img.post-content-img--zoomable');
if (zoomImg) {
e.preventDefault();
@@ -106,7 +134,6 @@ export default function PostContent({
if (copyBtn) {
e.preventDefault();
const block = copyBtn.closest('.md-codeblock');
// 行号列不参与复制:取各行正文拼接
const bodies = block?.querySelectorAll('.md-code-line__body');
const text = bodies && bodies.length
? [...bodies].map(el => el.textContent ?? '').join('\n')
@@ -124,7 +151,7 @@ export default function PostContent({
notify.error('复制失败');
}
}
}, [nav, openLightbox, onRequestReply]);
}, [nav, openLightbox, onRequestReply, onUnlocked, postId, unlocking]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key !== 'Enter' && e.key !== ' ') return;

View File

@@ -71,7 +71,7 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) {
<div className="post-body">
<div className="post-head">
<div className="post-head-meta">
<UserLink user={post.user} stopPropagation className="post-author" />
<UserLink user={post.user} stopPropagation className="post-author" showBadges />
<span className="post-head-dot" aria-hidden>·</span>
<span className="post-time">{timeLabel}</span>
</div>

View File

@@ -0,0 +1,71 @@
import { BadgeCheck, Crown, type LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { UserBadge } from '../api/types';
import { badgeIcon } from '../utils/badgeIcons';
import { resolveUserLevel } from '../utils/userMeta';
type BadgeUser = {
role?: string;
verified?: boolean;
level?: number;
exp?: number;
badges?: UserBadge[];
} | null | undefined;
interface Props {
user: BadgeUser;
className?: string;
/** 用户名旁最多展示几枚成就徽章 */
maxAchievement?: number;
showLevel?: boolean;
compact?: boolean;
}
/** 用户名旁:站长/认证 + Lv + 成就徽章 */
export default function UserBadges({
user,
className,
maxAchievement = 2,
showLevel = true,
compact = true,
}: Props) {
if (!user) return null;
const level = resolveUserLevel(user);
const achievements = (user.badges ?? []).slice(0, maxAchievement);
const isAdmin = user.role === 'admin';
const isVerified = !!user.verified && !isAdmin;
if (!isAdmin && !isVerified && !showLevel && achievements.length === 0) return null;
const levelTone = level >= 9 ? 'gold' : level >= 7 ? 'amber' : level >= 4 ? 'blue' : 'muted';
return (
<span className={cn('user-badges', compact && 'user-badges--compact', className)}>
{isAdmin && (
<span className="user-badge user-badge--owner" title="站长">
<Crown size={compact ? 12 : 14} aria-hidden />
{!compact && <span></span>}
</span>
)}
{isVerified && (
<span className="user-badge user-badge--verified" title="认证用户">
<BadgeCheck size={compact ? 12 : 14} aria-hidden />
{!compact && <span></span>}
</span>
)}
{showLevel && (
<span className={cn('user-badge user-badge--level', `user-badge--level-${levelTone}`)} title={`经验 ${user.exp ?? 0}`}>
Lv.{level}
</span>
)}
{achievements.map(b => {
const Icon: LucideIcon = badgeIcon(b.icon);
return (
<span key={b.code} className="user-badge user-badge--ach" title={`${b.name}${b.description}`}>
<Icon size={compact ? 11 : 13} aria-hidden />
</span>
);
})}
</span>
);
}

View File

@@ -1,12 +1,19 @@
import type { MouseEvent, ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { cn } from '@/lib/utils';
import type { UserBadge } from '../api/types';
import { userPath } from '../utils/userPath';
import UserBadges from './UserBadges';
export type UserLinkUser = {
id?: number;
nickname?: string;
avatar?: string;
role?: string;
verified?: boolean;
level?: number;
exp?: number;
badges?: UserBadge[];
} | null | undefined;
interface Props {
@@ -16,6 +23,7 @@ interface Props {
nameClassName?: string;
showAvatar?: boolean;
showName?: boolean;
showBadges?: boolean;
/** 嵌在可点击父级内时阻止冒泡(如帖子列表行) */
stopPropagation?: boolean;
children?: ReactNode;
@@ -30,6 +38,7 @@ export default function UserLink({
nameClassName,
showAvatar = false,
showName = true,
showBadges = false,
stopPropagation = false,
children,
title,
@@ -53,6 +62,7 @@ export default function UserLink({
</span>
)}
{showName && <span className={cn('user-link-name', nameClassName)}>{nick}</span>}
{showBadges && showName && <UserBadges user={user} />}
</>
);

View File

@@ -0,0 +1,215 @@
import { Node, mergeAttributes } from '@tiptap/core';
import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
import {
ReactNodeViewRenderer,
NodeViewWrapper,
NodeViewContent,
type NodeViewProps,
} from '@tiptap/react';
import { Coins, Trash2 } from 'lucide-react';
function findPointsOnlyDepth($pos: {
depth: number;
node: (d: number) => { type: { name: string }; nodeSize: number };
before: (d: number) => number;
start: (d: number) => number;
}): number {
for (let d = $pos.depth; d > 0; d -= 1) {
if ($pos.node(d).type.name === 'pointsOnly') return d;
}
return -1;
}
function isPointsOnlyEmpty(node: ProseMirrorNode): boolean {
return node.textContent.trim().length === 0;
}
function PointsOnlyView({ selected, editor, node, getPos, updateAttributes }: NodeViewProps) {
const empty = isPointsOnlyEmpty(node);
const cost = Number(node.attrs.cost) || 10;
const deleteThisBlock = () => {
const pos = getPos();
if (typeof pos !== 'number') {
editor.chain().focus().removePointsOnly().run();
return;
}
editor.chain().focus().command(({ tr, dispatch }) => {
if (dispatch) tr.delete(pos, pos + node.nodeSize);
return true;
}).run();
};
return (
<NodeViewWrapper
className={`post-points-only post-points-only--visible editor-points-only${selected ? ' editor-points-only--selected' : ''}${empty ? ' editor-points-only--empty' : ''}`}
>
<div className="post-points-only__badge" contentEditable={false}>
<span className="post-points-only__badge-icon" aria-hidden="true">
<Coins size={12} />
</span>
<span></span>
<label className="post-points-only__cost">
<input
type="number"
min={1}
max={9999}
value={cost}
onMouseDown={e => e.preventDefault()}
onChange={e => {
const v = Math.max(1, Math.min(9999, Number(e.target.value) || 1));
updateAttributes({ cost: v });
}}
/>
</label>
<div className="post-points-only__badge-actions">
<button
type="button"
className="post-points-only__remove-btn"
title="删除积分可见区块"
onMouseDown={e => e.preventDefault()}
onClick={deleteThisBlock}
>
<Trash2 size={11} />
</button>
</div>
</div>
<NodeViewContent className="post-points-only__body" data-placeholder="此处内容需积分解锁后可见…" />
</NodeViewWrapper>
);
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
pointsOnly: {
insertPointsOnly: (cost?: number) => ReturnType;
wrapPointsOnly: (cost?: number) => ReturnType;
exitPointsOnly: () => ReturnType;
removePointsOnly: () => ReturnType;
};
}
}
/** TipTap积分可见内容区块 */
export const PointsOnly = Node.create({
name: 'pointsOnly',
group: 'block',
content: 'block+',
defining: true,
isolating: true,
addAttributes() {
return {
cost: {
default: 10,
parseHTML: el => {
const v = Number(el.getAttribute('data-cost'));
return v > 0 ? v : 10;
},
renderHTML: attrs => ({ 'data-cost': String(attrs.cost || 10) }),
},
};
},
parseHTML() {
return [{ tag: 'points-only' }];
},
renderHTML({ HTMLAttributes }) {
return ['points-only', mergeAttributes({ 'data-gate': 'points' }, HTMLAttributes), 0];
},
addNodeView() {
return ReactNodeViewRenderer(PointsOnlyView);
},
addKeyboardShortcuts() {
return {
Backspace: ({ editor }) => {
const { $from, empty } = editor.state.selection;
if (!empty) return false;
const depth = findPointsOnlyDepth($from);
if (depth < 0) return false;
const node = $from.node(depth);
if (!isPointsOnlyEmpty(node)) {
if ($from.parentOffset !== 0) return false;
if ($from.pos !== $from.start(depth)) return false;
}
return editor.commands.removePointsOnly();
},
Delete: ({ editor }) => {
const { $from, empty } = editor.state.selection;
if (!empty) return false;
const depth = findPointsOnlyDepth($from);
if (depth < 0) return false;
if (!isPointsOnlyEmpty($from.node(depth))) return false;
return editor.commands.removePointsOnly();
},
Enter: ({ editor }) => {
const { $from, empty } = editor.state.selection;
if (!empty) return false;
const depth = findPointsOnlyDepth($from);
if (depth < 0) return false;
const parent = $from.parent;
if ($from.parentOffset !== parent.content.size || parent.textContent.trim().length > 0) return false;
const node = $from.node(depth);
if (isPointsOnlyEmpty(node) && node.childCount <= 1) {
return editor.commands.removePointsOnly();
}
return editor.commands.exitPointsOnly();
},
'Mod-Enter': ({ editor }) => {
if (!editor.isActive('pointsOnly')) return false;
return editor.commands.exitPointsOnly();
},
};
},
addCommands() {
return {
insertPointsOnly: (cost = 10) => ({ chain }) => chain()
.insertContent({
type: this.name,
attrs: { cost },
content: [{ type: 'paragraph' }],
})
.run(),
wrapPointsOnly: (cost = 10) => ({ tr, state, dispatch }) => {
const { from, to, empty } = state.selection;
if (empty) return false;
const slice = state.doc.slice(from, to);
if (!slice.content.size) return false;
const node = state.schema.nodes.pointsOnly.create({ cost }, slice.content);
if (dispatch) tr.replaceRangeWith(from, to, node);
return true;
},
exitPointsOnly: () => ({ state, chain }) => {
const { $from } = state.selection;
const depth = findPointsOnlyDepth($from);
if (depth < 0) return false;
const pos = $from.before(depth);
const node = $from.node(depth);
const end = pos + node.nodeSize;
return chain()
.insertContentAt(end, { type: 'paragraph' })
.setTextSelection(end + 1)
.run();
},
removePointsOnly: () => ({ tr, state, dispatch }) => {
const { $from } = state.selection;
const depth = findPointsOnlyDepth($from);
if (depth < 0) return false;
const pos = $from.before(depth);
const node = $from.node(depth);
tr.delete(pos, pos + node.nodeSize);
if (dispatch) dispatch(tr);
return true;
},
};
},
});