feat: [lock:] markdown 锁定(login/reply/password 真锁,服务端剥离 + 解锁接口 + SSR 打码)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user