Files
jiang13-forum/frontend/src/utils/postContent.ts
freefire 9487c8ab02 支持 app.ini 配置与系统服务安装,并优化前端布局与无障碍体验。
引入类 Gitea 的 app.ini、Windows Service/systemd 控制;前端增加侧栏抽屉、回到顶部、标签输入与浮层 a11y。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 14:09:58 +08:00

101 lines
4.3 KiB
TypeScript

import DOMPurify from 'dompurify';
/** DOMPurify 配置:允许会员专属自定义标签 */
export const POST_CONTENT_PURIFY_CONFIG: DOMPurify.Config = {
ADD_TAGS: ['members-only'],
ADD_ATTR: ['data-locked', 'data-length'],
};
const VISIBLE_BADGE_HTML = `
<div class="post-members-only__badge">
<span class="post-members-only__badge-icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
</span>
<span>登录可见</span>
</div>`;
const LOCK_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>`;
/** 游客看到的锁定区块:模糊占位 + 登录引导 */
function buildLockedGateHtml(charLength: number): string {
const lineCount = charLength > 0
? Math.min(6, Math.max(3, Math.ceil(charLength / 42)))
: 4;
const lines = Array.from({ length: lineCount }, (_, i) => {
const mod = i % 3;
const widthClass = mod === 1 ? ' post-members-only__preview-line--medium'
: mod === 2 ? ' post-members-only__preview-line--short' : '';
return `<div class="post-members-only__preview-line${widthClass}"></div>`;
}).join('');
const lengthHint = charLength > 0
? `${charLength} 字的`
: '一段';
return `
<div class="post-members-only__locked-wrap">
<div class="post-members-only__badge post-members-only__badge--locked">
<span class="post-members-only__badge-icon" aria-hidden="true">${LOCK_ICON_SVG}</span>
<span>登录可见</span>
</div>
<div class="post-members-only__preview" aria-hidden="true">
${lines}
</div>
<div class="post-members-only__gate">
<div class="post-members-only__gate-icon" aria-hidden="true">${LOCK_ICON_SVG}</div>
<p class="post-members-only__gate-title">此处有${lengthHint}专属内容</p>
<p class="post-members-only__gate-desc">作者已将这部分内容设为仅登录用户可见,登录后即可阅读全文。</p>
<button type="button" class="post-members-only__gate-btn" data-members-login>登录查看</button>
<span class="post-members-only__gate-alt">还没有账号?<button type="button" class="post-members-only__gate-link" data-members-register>免费注册</button></span>
</div>
</div>`;
}
/** 判断 HTML 正文是否为空(忽略空段落等) */
export function isHtmlEmpty(html: string): boolean {
if (!html.trim()) return true;
const doc = new DOMParser().parseFromString(
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG),
'text/html',
);
return (doc.body.textContent ?? '').trim().length === 0;
}
/** 根据登录状态渲染帖子正文 HTML */
export function renderPostContentHtml(html: string, isLoggedIn: boolean): string {
if (!html.trim()) return '';
const doc = new DOMParser().parseFromString(
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG),
'text/html',
);
doc.querySelectorAll('members-only').forEach(el => {
const locked = el.getAttribute('data-locked') === 'true' || !isLoggedIn;
if (locked) {
const charLength = parseInt(el.getAttribute('data-length') || '0', 10) || 0;
el.setAttribute('data-locked', 'true');
el.className = 'post-members-only post-members-only--locked';
el.innerHTML = buildLockedGateHtml(charLength);
return;
}
const innerHtml = el.querySelector('.post-members-only__body')?.innerHTML
?? Array.from(el.childNodes)
.filter(n => !(n instanceof Element && n.classList.contains('post-members-only__badge')))
.map(n => (n instanceof Element ? n.outerHTML : n.textContent ?? ''))
.join('');
el.className = 'post-members-only post-members-only--visible';
el.innerHTML = `${VISIBLE_BADGE_HTML}<div class="post-members-only__body">${innerHtml}</div>`;
});
doc.querySelectorAll('img').forEach(img => {
if (!img.getAttribute('loading')) img.setAttribute('loading', 'lazy');
if (!img.getAttribute('decoding')) img.setAttribute('decoding', 'async');
});
return doc.body.innerHTML;
}