import React, { useMemo } from 'react'; 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'; /** * 锁块附件鉴权地址:后端 /api/upload/locked 按 (ref_type, ref_id, block, file) + token 校验, * 只允许取「该帖该块」已解锁的附件。缺 refType/refId/token 返回 null → 调用方回退原直链。 */ function lockedAttachmentUrl(refType, refId, blockIndex, filename, token) { if (!refType || refId == null || !token) return null; return `/api/upload/locked?ref_type=${encodeURIComponent(refType)}&ref_id=${refId}&block=${encodeURIComponent(blockIndex)}&file=${encodeURIComponent(filename)}&token=${encodeURIComponent(token)}`; } /** [image:文件名] → 图片标签;lockedUrl 存在则 src 走鉴权接口,否则 /uploads/ 直链 */ function imageTag(filename, lockedUrl) { if (!filename) return ''; const src = lockedUrl || `/uploads/${encodeURIComponent(filename)}`; return ``; } /** [file:文件名] → 下载链接;lockedUrl 存在则 href 走鉴权接口(保留 target=_blank 与样式),否则原下载逻辑 */ function fileTag(filename, lockedUrl) { if (!filename) return ''; const token = getToken() || ''; const href = lockedUrl || `/api/upload/download/${encodeURIComponent(filename)}?token=${encodeURIComponent(token)}`; return ` attachment ${escapeHtml(filename)}`; } /** * 剥离内联 [lock:] 成对标签,保留块内内容(只去标签不去内容)。 * 保留导出供外部/预览使用;renderContent 不再调用——组件层已用切段方案接管(保留锁块容器)。 */ export function stripLockTags(content) { return String(content).replace(/\[lock(?::[^\]]*)?\]([\s\S]*?)\[\/lock\]/g, '$1'); } /** 从整块标签提取 lock 类型参数:'login' / 'reply' / 'password'(无参数返回 '') */ function extractLockType(block) { const m = String(block).match(/^\[lock(?::([^\]]*))?\]/); const param = (m && m[1]) || ''; return param.split(':')[0] || ''; } /** * 按内联 [lock:] 成对标签切段(split 带捕获组): * 交替产出 [文本, 整块(含标签), 块内内容, 文本, …](i%3:0=文本 / 1=整块 / 2=块内内容) */ const INLINE_LOCK_RE = /(\[lock(?::[^\]]*)?\]([\s\S]*?)\[\/lock\])/g; /** 统计文本段内内联 [lock:] 成对标签数量(全文锁块索引游标推进用) */ function countInlineLocks(text) { const m = String(text).match(INLINE_LOCK_RE); return m ? m.length : 0; } /** * 渲染一段文本:文本段走 renderContent;内联 [lock:] 段渲染「已解锁内容」容器。 * 已解锁块(admin/作者/登录或评论后可见)后端在 content 里原样保留 [lock:...]...[/lock] 标签, * 这里按标签切成锁块容器(同款背景+边框 + 🔓 已解锁头部),内容递归 MarkdownRenderer 渲染。 * rest.inlineStartIdx:本段起始内联块在全文中的索引(buildNodes 用锁块游标给出), * 用于查 locks 数组拿该块的附件 token(块内 [image:]/[file:] 走鉴权接口)。 */ function renderSegments(text, useMarkdown, rest) { const { locks, refType, refId, blockCtx: parentCtx } = rest; const parts = String(text).split(INLINE_LOCK_RE); const nodes = []; let textIdx = 0; let lockIdx = 0; let tagIdx = rest.inlineStartIdx || 0; // 内联块在全文中的索引游标 for (let i = 0; i < parts.length; i += 1) { const p = parts[i]; if (i % 3 === 1) { // 整块(含标签),紧随其后 i+1 是对应块内内容 const inner = parts[i + 1] || ''; const type = extractLockType(p); const idx = tagIdx++; // 内联块附件上下文:已处锁块内(parentCtx)时沿用父块 token(嵌套块文件同属父块); // 顶层则按本段索引查 locks 数组拿该块的 token(无 token 保持直链向后兼容) let childCtx = null; if (parentCtx) { childCtx = parentCtx; } else { const meta = (Array.isArray(locks) && locks.find((l) => l.index === idx)) || {}; if (meta.token) childCtx = { index: idx, token: meta.token }; } nodes.push(
已解锁内容 {type ? {type} : null}
); i += 1; // 跳过内容段(已作为 inner 渲染) } else if (p) { // 文本段:仅当外层处于已解锁锁块内(parentCtx)时附件才改走鉴权接口 const attach = parentCtx ? { refType, refId, blockIndex: parentCtx.index, token: parentCtx.token || '' } : null; nodes.push(); } } return nodes; } /** * 内容渲染:useMarkdown=false 走纯文本(转义 + 标签替换 +
); * useMarkdown=true 先抽取 [image:]/[file:] 标签,marked 渲染后再还原(照搬 render.js)。 * attach(可选)= { refType, refId, blockIndex, token }:处于已解锁锁块内时的附件鉴权上下文; * 缺省则 [image:]/[file:] 照旧 /uploads/ 直链与下载逻辑。本函数只处理单段文本,lock 分段在组件层完成。 */ export function renderContent(content, useMarkdown, attach) { if (content == null) return ''; // 锁块附件地址生成器:无 attach 上下文或无 token 时返回 null → 走原直链/下载逻辑 const lockedUrl = (filename) => (attach ? lockedAttachmentUrl(attach.refType, attach.refId, attach.blockIndex, filename, attach.token) : null); if (!useMarkdown) { let html = escapeHtml(content); html = html.replace(/\[image:([^\]]+)\]/g, (m, f) => imageTag(f, lockedUrl(f))); html = html.replace(/\[file:([^\]]+)\]/g, (m, f) => fileTag(f, lockedUrl(f))); html = html.replace(/\n/g, '
'); return DOMPurify.sanitize(html); } // Markdown 模式:先抽取自定义标签,避免被 marked 转义 const images = []; const files = []; let html = content.replace(/\[image:([^\]]+)\]/g, (m, f) => { images.push(f); return `\x00IMG${images.length - 1}\x00`; }); html = html.replace(/\[file:([^\]]+)\]/g, (m, f) => { files.push(f); return `\x00FILE${files.length - 1}\x00`; }); html = marked.parse(html, { breaks: true, gfm: true }); html = html.replace(/\x00IMG(\d+)\x00/g, (m, i) => imageTag(images[parseInt(i)], lockedUrl(images[parseInt(i)]))); html = html.replace(/\x00FILE(\d+)\x00/g, (m, i) => fileTag(files[parseInt(i)], lockedUrl(files[parseInt(i)]))); html = DOMPurify.sanitize(html); // 给 h2 注入稳定锚点 id(在 HTML 字符串内生成,重渲一致,供目录锚点定位; // 不在渲染后 DOM 上赋 id——React 重渲可能替换 DOM 导致 id 丢失) let h2Index = 0; html = html.replace(/]*\bid=)/gi, () => `

; } /** * 构建渲染节点(两层分段): * 1. @@LOCK@@ 占位分段(详情页 locks 非空)——lock 段:已解锁+有内容 → 递归渲染(.lock-reveal 淡入), * 否则 LockBlock 锁定卡片;文本段 → 再按内联 [lock:] 切段(已解锁容器 / 文本)。 * 文本段内联块索引用全文游标(nextIdx)衔接,保证与后端 parseLocks 的 index 一致。 * 2. locks 为空(含 previewMode 编辑预览):整段按内联 [lock:] 切段,作者视角同样显示已解锁容器 */ function buildNodes({ content, useMarkdown, locks, unlocked, lockContent, onUnlock, onGoComment, previewMode, refType, refId, blockCtx }) { if (content == null) return null; const str = String(content); const hasLocks = Array.isArray(locks) && locks.length > 0; if (hasLocks) { // 服务端已把未解锁块替换为 @@LOCK@@;split 交替产出 [文本, 索引, 文本, 索引…] const parts = str.split(/@@LOCK(\d+)@@/); const nodes = []; let nextIdx = 0; // 全文锁块索引游标:内联块与 @@LOCK 占位按文档顺序递增 for (let i = 0; i < parts.length; i += 1) { const p = parts[i]; if (i % 2 === 1) { const n = parseInt(p, 10); nextIdx = n + 1; // 占位符自带索引,同步游标 const meta = locks.find((l) => l.index === n) || {}; const type = meta.type || 'password'; const lockEntry = unlocked && unlocked.has(n) && lockContent && lockContent.get(n); if (lockEntry) { // lockContent 兼容纯字符串或 { content, token };token 优先取 locks 数组项(后端按块签发) const raw = typeof lockEntry === 'object' ? lockEntry.content : lockEntry; const entryToken = typeof lockEntry === 'object' && lockEntry.token ? lockEntry.token : ''; const token = (meta.token || entryToken) || ''; nodes.push(
); } else { nodes.push(); } } else if (p) { // 文本段:可能仍含内联 [lock:](已解锁块)→ 切段渲染已解锁容器 nodes.push(...renderSegments(p, useMarkdown, { locks, unlocked, lockContent, onUnlock, onGoComment, refType, refId, blockCtx, inlineStartIdx: nextIdx, })); nextIdx += countInlineLocks(p); } } return nodes; } // locks 为空:整段按内联 [lock:] 切段(previewMode 作者预览同样显示「已解锁内容」容器) return renderSegments(str, useMarkdown, { locks, unlocked, lockContent, onUnlock, onGoComment, refType, refId, blockCtx, inlineStartIdx: 0, }); } /** * Markdown 渲染器(前台统一入口): * props: * content / useMarkdown — 同原有 * locks — [{index, type}](新后端带 {index, type, token})详情接口返回的锁定块元信息 * unlocked — Set 已解锁索引(login/reply 由服务端判定后内容直接给原文; * password 类解锁后内容存 lockContent) * lockContent — Map password 块解锁拿到的 markdown(仅内存态) * onUnlock — async (index, password?) → 解锁;失败 throw * onGoComment — reply 块「去评论」滚动回调 * previewMode — 编辑器作者预览:锁定标签直接展开 * refType — 'blog' | 'forum'(可选):提供后锁块内 [image:]/[file:] 附件改走鉴权接口 * refId — 详情 id(可选,配合 refType) * blockCtx — {index, token}(可选):当前渲染上下文处于已解锁锁块内,附件用该块的 token 鉴权 */ export default function MarkdownRenderer({ content = '', useMarkdown = true, locks, unlocked, lockContent, onUnlock, onGoComment, previewMode = false, refType, refId, blockCtx, }) { const nodes = useMemo( () => buildNodes({ content, useMarkdown, locks, unlocked, lockContent, onUnlock, onGoComment, previewMode, refType, refId, blockCtx }), [content, useMarkdown, locks, unlocked, lockContent, onUnlock, onGoComment, previewMode, refType, refId, blockCtx] ); return
{nodes}
; }