增加用户认证、等级、徽章与积分体系,并优化管理后台体验。
覆盖站长调账与积分解锁内容;后台按审核优先分组导航,仪表盘展示待办,用户管理改为成员目录式布局。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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) },
|
||||
|
||||
@@ -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>
|
||||
|
||||
138
frontend/src/components/PointsWalletPanel.tsx
Normal file
138
frontend/src/components/PointsWalletPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
71
frontend/src/components/UserBadges.tsx
Normal file
71
frontend/src/components/UserBadges.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />}
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
215
frontend/src/components/editor/PointsOnlyExtension.tsx
Normal file
215
frontend/src/components/editor/PointsOnlyExtension.tsx
Normal 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;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user