feat: [lock:] markdown 锁定(login/reply/password 真锁,服务端剥离 + 解锁接口 + SSR 打码)

This commit is contained in:
2026-08-12 23:39:12 +08:00
parent 620d931fa6
commit 38be22700d
13 changed files with 767 additions and 25 deletions
+93
View File
@@ -0,0 +1,93 @@
import React, { useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
/**
* markdown 锁定块占位(未解锁时渲染,MD3 风格):
* - login → 「登录后查看」+ 去登录按钮
* - reply → 「评论后可见」+ 去评论按钮(onGoComment 滚动到评论区)
* - password → 密码输入 + 解锁按钮(onUnlock(index, password),失败显示错误态)
* 键盘:密码框 Enter 提交、Esc 清空;Tab 自然可达。
*/
export default function LockBlock({ index, type = 'password', onUnlock, onGoComment }) {
const [pwd, setPwd] = useState('');
const [err, setErr] = useState('');
const [busy, setBusy] = useState(false);
const pwdRef = useRef(null);
// 弹窗/焦点管理不适用这里(块内内联控件),但复用 useDialog 的 Esc 习惯:直接监听
useEffect(() => {
if (type === 'password') {
const h = (e) => {
if (e.key === 'Escape') { setPwd(''); setErr(''); }
};
window.addEventListener('keydown', h);
return () => window.removeEventListener('keydown', h);
}
return undefined;
}, [type]);
const tryUnlock = async () => {
if (busy) return;
if (!pwd) { setErr('请输入密码'); if (pwdRef.current) pwdRef.current.focus(); return; }
setBusy(true);
setErr('');
try {
await onUnlock(index, pwd);
// 成功后父级重渲染(unlocked/lockContent 更新),本组件被内容替换
} catch (e) {
setErr((e && e.message) || '密码错误');
}
setBusy(false);
};
if (type === 'login') {
return (
<div className="lock-placeholder">
<span className="material-icons lock-icon" aria-hidden="true">lock</span>
<div className="lock-title">登录后查看</div>
<div className="lock-desc">登录后可查看此内容</div>
<Link to="/login.html" className="btn btn-filled btn-sm">去登录</Link>
</div>
);
}
if (type === 'reply') {
return (
<div className="lock-placeholder">
<span className="material-icons lock-icon" aria-hidden="true">lock</span>
<div className="lock-title">评论后可见</div>
<div className="lock-desc">发表评论后即可查看此内容</div>
<button type="button" className="btn btn-filled btn-sm" onClick={onGoComment}>去评论</button>
</div>
);
}
// password
return (
<div className="lock-placeholder">
<span className="material-icons lock-icon" aria-hidden="true">lock</span>
<div className="lock-title">密码可见</div>
<div className="lock-desc">输入访问密码解锁内容</div>
<div className="lock-form">
<input
ref={pwdRef}
type="password"
className={'lock-pwd-input' + (err ? ' error' : '')}
value={pwd}
placeholder="访问密码"
aria-label="访问密码"
autoComplete="off"
onChange={(e) => { setPwd(e.target.value); if (err) setErr(''); }}
onKeyDown={(e) => {
if (e.key === 'Enter') tryUnlock();
else if (e.key === 'Escape') { setPwd(''); setErr(''); }
}}
/>
<button type="button" className="btn btn-filled btn-sm" onClick={tryUnlock} disabled={busy}>
{busy ? '解锁中…' : '解锁'}
</button>
</div>
{err && <div className="lock-error" role="alert"><span className="material-icons" style={{ fontSize: 14 }}>error_outline</span> {err}</div>}
</div>
);
}
+88 -2
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import { uploadFile } from '../api/upload.js';
import { showSnackbar } from '../lib/utils.js';
import { showSnackbar, useDialog } from '../lib/utils.js';
import MarkdownRenderer from './MarkdownRenderer.jsx';
/**
@@ -46,6 +46,18 @@ export default function MarkdownEditor({
const [dragging, setDragging] = useState(false);
const [uploadStatus, setUploadStatus] = useState('');
// 锁定块插入弹窗
const [lockOpen, setLockOpen] = useState(false);
const [lockType, setLockType] = useState('login');
const [lockPwd, setLockPwd] = useState('');
const { dialogRef: lockDialogRef, onKeyDown: lockDialogKey } = useDialog(lockOpen, () => setLockOpen(false));
const LOCK_TYPES = [
{ type: 'login', label: '登录可见', icon: 'person' },
{ type: 'reply', label: '评论后可见', icon: 'chat_bubble' },
{ type: 'password', label: '密码可见', icon: 'lock' },
];
// 右侧预览的防抖值:停止输入 debounceMs 后才刷新
const previewValue = useDebounced(value, debounceMs);
const pending = value !== previewValue;
@@ -127,6 +139,19 @@ export default function MarkdownEditor({
}
};
/**
* 插入锁定块:有选中文本则包裹,无选中插入占位文本。
* 语法约定:`[lock:login]` / `[lock:reply]` / `[lock:password:密码]`(密码型带明文密码参数,后端解析存库)
*/
const doInsertLock = () => {
const tag = lockType === 'password'
? `[lock:password:${lockPwd.trim()}]`
: `[lock:${lockType}]`;
wrapSelection(tag + '\n', '\n[/lock]', '锁定内容');
setLockOpen(false);
setLockPwd('');
};
const handleFileSelect = (e) => {
const f = e.target.files && e.target.files[0];
if (f) doUpload(f);
@@ -160,6 +185,9 @@ export default function MarkdownEditor({
<button type="button" className="btn-icon" title="上传图片或附件" aria-label="上传图片或附件" onClick={() => fileInputRef.current && fileInputRef.current.click()}>
<span className="material-icons">upload</span>
</button>
<button type="button" className="btn-icon" title="插入锁定内容(登录/评论/密码可见)" aria-label="插入锁定内容" onClick={() => setLockOpen(true)}>
<span className="material-icons">lock</span>
</button>
{uploadStatus && <span className="md-editor-upload-status">{uploadStatus}</span>}
</div>
@@ -198,7 +226,7 @@ export default function MarkdownEditor({
</div>
<div className="md-editor-preview-body" role="region" aria-label="Markdown 实时预览">
{previewValue.trim() ? (
<MarkdownRenderer content={previewValue} useMarkdown />
<MarkdownRenderer content={previewValue} useMarkdown previewMode />
) : (
<div className="md-editor-preview-empty">预览区 输入内容后稍候自动更新</div>
)}
@@ -207,6 +235,64 @@ export default function MarkdownEditor({
</div>
<input ref={fileInputRef} type="file" style={{ display: 'none' }} onChange={handleFileSelect} />
{/* 锁定块插入弹窗 */}
{lockOpen && (
<div
ref={lockDialogRef}
className="dialog-overlay active"
role="dialog"
aria-modal="true"
aria-label="插入锁定内容"
style={{ display: 'flex', zIndex: 9999 }}
onMouseDown={(e) => { if (e.target === e.currentTarget) setLockOpen(false); }}
onKeyDown={lockDialogKey}
>
<div className="dialog">
<h3>插入锁定内容</h3>
<p className="text-muted" style={{ fontSize: 13, marginBottom: 12 }}>
选择可见条件满足条件后读者才可查看锁定部分
</p>
<div className="lock-type-options" role="radiogroup" aria-label="可见条件">
{LOCK_TYPES.map((o) => (
<button
key={o.type}
type="button"
role="radio"
aria-checked={lockType === o.type}
className={'lock-type-option' + (lockType === o.type ? ' active' : '')}
onClick={() => setLockType(o.type)}
>
<span className="material-icons" style={{ fontSize: 18 }}>{o.icon}</span> {o.label}
</button>
))}
</div>
{lockType === 'password' && (
<div className="form-group" style={{ marginTop: 12 }}>
<label htmlFor="lockPwdInput">访问密码</label>
<input
id="lockPwdInput"
type="text"
value={lockPwd}
onChange={(e) => setLockPwd(e.target.value)}
placeholder="读者解锁时输入的密码"
autoComplete="off"
/>
</div>
)}
<div className="actions">
<button className="btn btn-text" onClick={() => setLockOpen(false)}>取消</button>
<button
className="btn btn-filled"
onClick={doInsertLock}
disabled={lockType === 'password' && !lockPwd.trim()}
>
插入
</button>
</div>
</div>
</div>
)}
</div>
);
}
+91 -3
View File
@@ -3,6 +3,7 @@ import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { escapeHtml } from '../lib/utils.js';
import { getToken } from '../api/client.js';
import LockBlock from './LockBlock.jsx';
/** [image:文件名] → /uploads/ 图片 */
function imageTag(filename) {
@@ -21,6 +22,7 @@ function fileTag(filename) {
/**
* 内容渲染:useMarkdown=false 走纯文本(转义 + 标签替换 + <br>);
* useMarkdown=true 先抽取 [image:]/[file:] 标签,marked 渲染后再还原(照搬 render.js)。
* 本函数只处理单段文本,lock 分段在组件层完成。
*/
export function renderContent(content, useMarkdown) {
if (content == null) return '';
@@ -52,7 +54,93 @@ export function renderContent(content, useMarkdown) {
return html;
}
export default function MarkdownRenderer({ content = '', useMarkdown = true }) {
const html = useMemo(() => renderContent(content, useMarkdown), [content, useMarkdown]);
return <div className="md-body" dangerouslySetInnerHTML={{ __html: html }} />;
/** 单段文本块:dangerouslySetInnerHTML 包装(renderContent 已 DOMPurify 净化) */
function RawBlock({ html }) {
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
/**
* 构建渲染节点:
* 1. 详情页(locks 非空):按服务端剥离的 @@LOCK<n>@@ 占位符分段——
* 已解锁且有内容(lockContent)的块递归渲染(.lock-reveal 淡入),否则渲染 LockBlock 占位
* 2. 编辑器作者预览(previewMode):剥离 [lock:type]...[/lock] 配对标签,直接展开内容
* 3. 普通:整段原样渲染
*/
function buildNodes({ content, useMarkdown, locks, unlocked, lockContent, onUnlock, onGoComment, previewMode }) {
if (content == null) return null;
const str = String(content);
const hasLocks = Array.isArray(locks) && locks.length > 0;
if (hasLocks) {
// 服务端已把未解锁块替换为 @@LOCK<n>@@split 交替产出 [文本, 索引, 文本, 索引…]
const parts = str.split(/@@LOCK(\d+)@@/);
const nodes = [];
let textIdx = 0;
for (let i = 0; i < parts.length; i += 1) {
const p = parts[i];
if (i % 2 === 1) {
const n = parseInt(p, 10);
const meta = locks.find((l) => l.index === n) || {};
const type = meta.type || 'password';
const revealed = unlocked && unlocked.has(n) && lockContent && lockContent.get(n);
if (revealed) {
nodes.push(
<div key={'lock' + n} className="lock-reveal">
<MarkdownRenderer
content={revealed}
useMarkdown={useMarkdown}
locks={locks}
unlocked={unlocked}
lockContent={lockContent}
onUnlock={onUnlock}
onGoComment={onGoComment}
/>
</div>
);
} else {
nodes.push(<LockBlock key={'lock' + n} index={n} type={type} onUnlock={onUnlock} onGoComment={onGoComment} />);
}
} else if (p) {
nodes.push(<RawBlock key={'text' + textIdx++} html={renderContent(p, useMarkdown)} />);
}
}
return nodes;
}
// 编辑器作者预览:锁定块直接展开内容(作者视角)
if (previewMode) {
const stripped = str.replace(/\[lock:(\w+)\]([\s\S]*?)\[\/lock\]/g, '$2');
return [<RawBlock key="text0" html={renderContent(stripped, useMarkdown)} />];
}
return [<RawBlock key="text0" html={renderContent(content, useMarkdown)} />];
}
/**
* Markdown 渲染器(前台统一入口):
* props:
* content / useMarkdown — 同原有
* locks — [{index, type}] 详情接口返回的锁定块元信息
* unlocked — Set<number> 已解锁索引(login/reply 由服务端判定后内容直接给原文;
* password 类解锁后内容存 lockContent
* lockContent — Map<number, string> password 块解锁拿到的 markdown(仅内存态)
* onUnlock — async (index, password?) → 解锁;失败 throw
* onGoComment — reply 块「去评论」滚动回调
* previewMode — 编辑器作者预览:锁定标签直接展开
*/
export default function MarkdownRenderer({
content = '',
useMarkdown = true,
locks,
unlocked,
lockContent,
onUnlock,
onGoComment,
previewMode = false,
}) {
const nodes = useMemo(
() => buildNodes({ content, useMarkdown, locks, unlocked, lockContent, onUnlock, onGoComment, previewMode }),
[content, useMarkdown, locks, unlocked, lockContent, onUnlock, onGoComment, previewMode]
);
return <div className="md-body">{nodes}</div>;
}