新增 OIDC/SSO、邮件验证码与 Gitea 项目同步,并强化 Feed 与管理后台。
作为 OIDC Provider 对接 Gitea;注册支持邮件验证码/验证码;侧栏同步公开仓库;Feed 分页、文章大纲、标签云与站点品牌设置。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
49
frontend/src/utils/authRedirect.ts
Normal file
49
frontend/src/utils/authRedirect.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/** 构造带回跳的登录路径 */
|
||||
export function loginPath(from?: string): string {
|
||||
const path = sanitizeReturnPath(from ?? currentPath());
|
||||
if (!path) return '/login';
|
||||
return `/login?from=${encodeURIComponent(path)}`;
|
||||
}
|
||||
|
||||
/** 构造带回跳的注册路径 */
|
||||
export function registerPath(from?: string): string {
|
||||
const path = sanitizeReturnPath(from ?? currentPath());
|
||||
if (!path) return '/register';
|
||||
return `/register?from=${encodeURIComponent(path)}`;
|
||||
}
|
||||
|
||||
/** 从查询参数解析登录/注册成功后的回跳地址 */
|
||||
export function resolveAuthRedirect(search: string | URLSearchParams, fallback = '/'): string {
|
||||
const params = typeof search === 'string' ? new URLSearchParams(search) : search;
|
||||
return sanitizeReturnPath(params.get('from') ?? '') || fallback;
|
||||
}
|
||||
|
||||
/** OAuth/OIDC 协议路径需整页跳转,不能走 React Router */
|
||||
export function isProtocolReturnPath(path: string): boolean {
|
||||
return path.startsWith('/oauth/') || path.startsWith('/.well-known/');
|
||||
}
|
||||
|
||||
/** 登录/注册成功后回跳(协议路径用 location 整页导航) */
|
||||
export function navigateAfterAuth(
|
||||
nav: (to: string, opts?: { replace?: boolean }) => void,
|
||||
redirectTo: string,
|
||||
): void {
|
||||
if (isProtocolReturnPath(redirectTo)) {
|
||||
window.location.assign(redirectTo);
|
||||
return;
|
||||
}
|
||||
nav(redirectTo, { replace: true });
|
||||
}
|
||||
|
||||
function currentPath(): string {
|
||||
if (typeof window === 'undefined') return '/';
|
||||
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
}
|
||||
|
||||
/** 仅允许站内相对路径,避免开放重定向 */
|
||||
function sanitizeReturnPath(raw: string): string {
|
||||
const path = raw.trim();
|
||||
if (!path.startsWith('/') || path.startsWith('//')) return '';
|
||||
if (path.startsWith('/login') || path.startsWith('/register')) return '';
|
||||
return path;
|
||||
}
|
||||
61
frontend/src/utils/composeDraft.ts
Normal file
61
frontend/src/utils/composeDraft.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
export interface ComposeDraft {
|
||||
title: string;
|
||||
tags: string;
|
||||
content: string;
|
||||
boardId: string;
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
const PREFIX = 'j13-compose-draft:';
|
||||
|
||||
function draftKey(editId: number | null): string {
|
||||
return editId == null ? `${PREFIX}new` : `${PREFIX}edit:${editId}`;
|
||||
}
|
||||
|
||||
/** 读取发帖/编辑草稿 */
|
||||
export function loadComposeDraft(editId: number | null): ComposeDraft | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(draftKey(editId));
|
||||
if (!raw) return null;
|
||||
const data = JSON.parse(raw) as ComposeDraft;
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
return {
|
||||
title: typeof data.title === 'string' ? data.title : '',
|
||||
tags: typeof data.tags === 'string' ? data.tags : '',
|
||||
content: typeof data.content === 'string' ? data.content : '',
|
||||
boardId: typeof data.boardId === 'string' ? data.boardId : '',
|
||||
savedAt: typeof data.savedAt === 'number' ? data.savedAt : 0,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 写入发帖/编辑草稿 */
|
||||
export function saveComposeDraft(editId: number | null, draft: Omit<ComposeDraft, 'savedAt'>): void {
|
||||
try {
|
||||
const payload: ComposeDraft = { ...draft, savedAt: Date.now() };
|
||||
localStorage.setItem(draftKey(editId), JSON.stringify(payload));
|
||||
} catch {
|
||||
// 配额不足等场景静默忽略
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除发帖/编辑草稿 */
|
||||
export function clearComposeDraft(editId: number | null): void {
|
||||
try {
|
||||
localStorage.removeItem(draftKey(editId));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/** 草稿是否相对 baseline 有实质内容 */
|
||||
export function draftHasContent(draft: ComposeDraft): boolean {
|
||||
return Boolean(
|
||||
draft.title.trim()
|
||||
|| draft.tags.trim()
|
||||
|| draft.content.trim()
|
||||
|| draft.boardId.trim(),
|
||||
);
|
||||
}
|
||||
74
frontend/src/utils/enhanceCodeBlocks.ts
Normal file
74
frontend/src/utils/enhanceCodeBlocks.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import hljs from 'highlight.js/lib/common';
|
||||
|
||||
/** 从 class / data-lang 中解析作者标注的语言标识 */
|
||||
function detectLang(...els: Element[]): string {
|
||||
for (const el of els) {
|
||||
const data = el.getAttribute('data-lang') || el.getAttribute('data-language');
|
||||
if (data?.trim()) return data.trim().toLowerCase();
|
||||
const cls = el.getAttribute('class') || '';
|
||||
const m = cls.match(/(?:language|lang)-([a-z0-9_+-]+)/i);
|
||||
if (m?.[1]) return m[1].toLowerCase();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/** 美化并高亮文档中的代码块(加语言标签与复制按钮) */
|
||||
export function enhanceCodeBlocks(root: ParentNode): void {
|
||||
root.querySelectorAll('pre').forEach(pre => {
|
||||
if (pre.closest('.md-codeblock')) return;
|
||||
const code = pre.querySelector('code') || pre;
|
||||
const raw = code.textContent || '';
|
||||
// 作者写了语言标签则以标注为准,绝不被自动识别覆盖
|
||||
const declaredLang = detectLang(code, pre);
|
||||
let label = declaredLang || 'code';
|
||||
|
||||
try {
|
||||
if (declaredLang && hljs.getLanguage(declaredLang)) {
|
||||
const result = hljs.highlight(raw, { language: declaredLang, ignoreIllegals: true });
|
||||
code.innerHTML = result.value;
|
||||
code.classList.add('hljs', `language-${declaredLang}`);
|
||||
} else if (declaredLang) {
|
||||
// 未收录语言(如 aardio):保留原文与标签,不做自动猜测
|
||||
code.classList.add('hljs', `language-${declaredLang}`);
|
||||
} else if (raw.length >= 24) {
|
||||
const result = hljs.highlightAuto(raw);
|
||||
code.innerHTML = result.value;
|
||||
code.classList.add('hljs');
|
||||
if (result.language) {
|
||||
label = result.language;
|
||||
code.classList.add(`language-${result.language}`);
|
||||
}
|
||||
} else {
|
||||
code.classList.add('hljs');
|
||||
}
|
||||
} catch {
|
||||
code.textContent = raw;
|
||||
code.classList.add('hljs');
|
||||
if (declaredLang) code.classList.add(`language-${declaredLang}`);
|
||||
}
|
||||
|
||||
const wrap = pre.ownerDocument.createElement('div');
|
||||
wrap.className = 'md-codeblock';
|
||||
wrap.setAttribute('data-lang', label);
|
||||
|
||||
const head = pre.ownerDocument.createElement('div');
|
||||
head.className = 'md-codeblock__head';
|
||||
head.innerHTML = `
|
||||
<span class="md-codeblock__lang">${escapeHtml(label)}</span>
|
||||
<button type="button" class="md-codeblock__copy" data-code-copy>复制</button>
|
||||
`;
|
||||
|
||||
pre.parentNode?.insertBefore(wrap, pre);
|
||||
wrap.appendChild(head);
|
||||
wrap.appendChild(pre);
|
||||
pre.classList.add('md-codeblock__pre');
|
||||
});
|
||||
}
|
||||
@@ -5,111 +5,33 @@ import type { FeedSort } from '../components/FeedSortBar';
|
||||
/** 导航到帖子列表时附带的状态,用于同 URL 重复点击时强制刷新 */
|
||||
export type FeedNavState = { refreshFeed?: boolean };
|
||||
|
||||
|
||||
|
||||
export type FeedCache = {
|
||||
|
||||
posts: PostItem[];
|
||||
|
||||
postTotal: number;
|
||||
|
||||
page: number;
|
||||
|
||||
hasMore: boolean;
|
||||
|
||||
scrollTop: number;
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
const PREFIX = 'j13-feed-cache:';
|
||||
|
||||
|
||||
/** 仅存内存:SPA 内返回可恢复,浏览器刷新自动清空 */
|
||||
const store = new Map<string, FeedCache>();
|
||||
|
||||
function cacheKey(boardId: number, keyword: string, sort: FeedSort) {
|
||||
|
||||
return `${PREFIX}${boardId}:${keyword}:${sort}`;
|
||||
|
||||
return `${boardId}:${keyword}:${sort}`;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** 读取帖子列表缓存,用于从详情页返回时恢复浏览位置 */
|
||||
|
||||
/** 读取帖子列表缓存(从详情页返回时恢复浏览位置) */
|
||||
export function getFeedCache(boardId: number, keyword: string, sort: FeedSort): FeedCache | null {
|
||||
|
||||
try {
|
||||
|
||||
const raw = sessionStorage.getItem(cacheKey(boardId, keyword, sort));
|
||||
|
||||
return raw ? (JSON.parse(raw) as FeedCache) : null;
|
||||
|
||||
} catch {
|
||||
|
||||
return null;
|
||||
|
||||
}
|
||||
|
||||
return store.get(cacheKey(boardId, keyword, sort)) ?? null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** 保存帖子列表缓存 */
|
||||
|
||||
export function setFeedCache(boardId: number, keyword: string, sort: FeedSort, data: FeedCache) {
|
||||
|
||||
try {
|
||||
|
||||
sessionStorage.setItem(cacheKey(boardId, keyword, sort), JSON.stringify(data));
|
||||
|
||||
} catch {
|
||||
|
||||
// sessionStorage 不可用时忽略
|
||||
|
||||
}
|
||||
|
||||
store.set(cacheKey(boardId, keyword, sort), data);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** 清除指定筛选条件下的列表缓存 */
|
||||
|
||||
export function clearFeedCache(boardId: number, keyword: string, sort: FeedSort) {
|
||||
|
||||
try {
|
||||
|
||||
sessionStorage.removeItem(cacheKey(boardId, keyword, sort));
|
||||
|
||||
} catch {
|
||||
|
||||
// ignore
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** 清除所有帖子列表缓存(置顶等操作后列表需全量刷新) */
|
||||
/** 清除所有帖子列表缓存 */
|
||||
export function clearAllFeedCache() {
|
||||
|
||||
try {
|
||||
|
||||
for (let i = sessionStorage.length - 1; i >= 0; i--) {
|
||||
|
||||
const key = sessionStorage.key(i);
|
||||
|
||||
if (key?.startsWith(PREFIX)) sessionStorage.removeItem(key);
|
||||
|
||||
}
|
||||
|
||||
} catch {
|
||||
|
||||
// ignore
|
||||
|
||||
}
|
||||
|
||||
store.clear();
|
||||
}
|
||||
|
||||
/** 主动刷新帖子列表时派发,用于同页内立即回到顶部 */
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { Board, ForumStats } from '../api/types';
|
||||
import type { Board, ForumStats, RecentComment, PostItem, TagCount } from '../api/types';
|
||||
|
||||
const BOARDS_KEY = 'j13-cache-boards';
|
||||
const STATS_KEY = 'j13-cache-stats';
|
||||
const HOT_KEY = 'j13-cache-hot';
|
||||
const RECENT_COMMENTS_KEY = 'j13-cache-recent-comments';
|
||||
const TAGS_KEY = 'j13-cache-tags';
|
||||
|
||||
function readJson<T>(key: string): T | null {
|
||||
try {
|
||||
@@ -30,6 +33,33 @@ export function getCachedStats(): ForumStats | null {
|
||||
return readJson<ForumStats>(STATS_KEY);
|
||||
}
|
||||
|
||||
/** 读取缓存的热门帖子,避免右栏/抽屉首屏高度跳动 */
|
||||
export function getCachedHot(): PostItem[] {
|
||||
const list = readJson<PostItem[]>(HOT_KEY);
|
||||
return Array.isArray(list) ? list : [];
|
||||
}
|
||||
|
||||
/** 读取缓存的最新评论,避免右栏/抽屉首屏高度跳动 */
|
||||
export function getCachedRecentComments(): RecentComment[] {
|
||||
const list = readJson<RecentComment[]>(RECENT_COMMENTS_KEY);
|
||||
return Array.isArray(list) ? list : [];
|
||||
}
|
||||
|
||||
/** 读取缓存的标签云 */
|
||||
export function getCachedTags(): TagCount[] {
|
||||
const list = readJson<TagCount[]>(TAGS_KEY);
|
||||
return Array.isArray(list) ? list : [];
|
||||
}
|
||||
|
||||
/** 右栏是否已有可展示的 session 缓存(含空列表) */
|
||||
export function hasCachedAside(): boolean {
|
||||
try {
|
||||
return sessionStorage.getItem(HOT_KEY) != null || sessionStorage.getItem(RECENT_COMMENTS_KEY) != null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setCachedBoards(boards: Board[]) {
|
||||
writeJson(BOARDS_KEY, boards);
|
||||
}
|
||||
@@ -37,3 +67,15 @@ export function setCachedBoards(boards: Board[]) {
|
||||
export function setCachedStats(stats: ForumStats) {
|
||||
writeJson(STATS_KEY, stats);
|
||||
}
|
||||
|
||||
export function setCachedHot(posts: PostItem[]) {
|
||||
writeJson(HOT_KEY, posts);
|
||||
}
|
||||
|
||||
export function setCachedRecentComments(list: RecentComment[]) {
|
||||
writeJson(RECENT_COMMENTS_KEY, list);
|
||||
}
|
||||
|
||||
export function setCachedTags(tags: TagCount[]) {
|
||||
writeJson(TAGS_KEY, tags);
|
||||
}
|
||||
|
||||
15
frontend/src/utils/openPost.ts
Normal file
15
frontend/src/utils/openPost.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
|
||||
/** 按站点配置打开帖子详情(当前页跳转或新标签) */
|
||||
export function openForumPost(
|
||||
nav: NavigateFunction,
|
||||
postId: number,
|
||||
openInNewTab: boolean,
|
||||
) {
|
||||
const path = `/post/${postId}`;
|
||||
if (openInNewTab) {
|
||||
window.open(path, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
nav(path);
|
||||
}
|
||||
@@ -1,52 +1,34 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
import type { Config } from 'dompurify';
|
||||
import { enhanceCodeBlocks } from './enhanceCodeBlocks';
|
||||
import { enhanceHeadingAnchors } from './postHeadings';
|
||||
|
||||
/** DOMPurify 配置:允许会员专属自定义标签 */
|
||||
export const POST_CONTENT_PURIFY_CONFIG: DOMPurify.Config = {
|
||||
/** DOMPurify 配置:允许会员专属自定义标签与链接 target */
|
||||
export const POST_CONTENT_PURIFY_CONFIG: Config = {
|
||||
ADD_TAGS: ['members-only'],
|
||||
ADD_ATTR: ['data-locked', 'data-length'],
|
||||
ADD_ATTR: ['data-locked', 'data-length', 'target', 'rel', 'data-code-copy', 'data-lang'],
|
||||
};
|
||||
|
||||
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="18" height="18" 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>`;
|
||||
|
||||
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} 字的`
|
||||
: '一段';
|
||||
? `约 ${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>
|
||||
<span class="post-members-only__gate-icon" aria-hidden="true">${LOCK_ICON_SVG}</span>
|
||||
<div class="post-members-only__gate-text">
|
||||
<p class="post-members-only__gate-title">登录后可见(${lengthHint})</p>
|
||||
<p class="post-members-only__gate-desc">作者将此段设为仅登录用户可读</p>
|
||||
</div>
|
||||
<div class="post-members-only__gate-actions">
|
||||
<button type="button" class="post-members-only__gate-btn" data-members-login>登录查看</button>
|
||||
<button type="button" class="post-members-only__gate-link" data-members-register>免费注册</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -55,18 +37,22 @@ function buildLockedGateHtml(charLength: number): string {
|
||||
export function isHtmlEmpty(html: string): boolean {
|
||||
if (!html.trim()) return true;
|
||||
const doc = new DOMParser().parseFromString(
|
||||
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG),
|
||||
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG) as string,
|
||||
'text/html',
|
||||
);
|
||||
return (doc.body.textContent ?? '').trim().length === 0;
|
||||
}
|
||||
|
||||
/** 根据登录状态渲染帖子正文 HTML */
|
||||
export function renderPostContentHtml(html: string, isLoggedIn: boolean): string {
|
||||
export function renderPostContentHtml(
|
||||
html: string,
|
||||
isLoggedIn: boolean,
|
||||
opts?: { openLinksInNewTab?: boolean },
|
||||
): string {
|
||||
if (!html.trim()) return '';
|
||||
|
||||
const doc = new DOMParser().parseFromString(
|
||||
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG),
|
||||
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG) as string,
|
||||
'text/html',
|
||||
);
|
||||
|
||||
@@ -87,8 +73,9 @@ export function renderPostContentHtml(html: string, isLoggedIn: boolean): string
|
||||
.map(n => (n instanceof Element ? n.outerHTML : n.textContent ?? ''))
|
||||
.join('');
|
||||
|
||||
// 已登录:降噪,不展示醒目 badge,仅保留结构容器
|
||||
el.className = 'post-members-only post-members-only--visible';
|
||||
el.innerHTML = `${VISIBLE_BADGE_HTML}<div class="post-members-only__body">${innerHtml}</div>`;
|
||||
el.innerHTML = `<div class="post-members-only__body">${innerHtml}</div>`;
|
||||
});
|
||||
|
||||
doc.querySelectorAll('img').forEach(img => {
|
||||
@@ -96,5 +83,20 @@ export function renderPostContentHtml(html: string, isLoggedIn: boolean): string
|
||||
if (!img.getAttribute('decoding')) img.setAttribute('decoding', 'async');
|
||||
});
|
||||
|
||||
if (opts?.openLinksInNewTab) {
|
||||
doc.querySelectorAll('a[href]').forEach(a => {
|
||||
const href = a.getAttribute('href') || '';
|
||||
if (!href || href.startsWith('#') || href.startsWith('javascript:')) return;
|
||||
a.setAttribute('target', '_blank');
|
||||
const rel = new Set((a.getAttribute('rel') || '').split(/\s+/).filter(Boolean));
|
||||
rel.add('noopener');
|
||||
rel.add('noreferrer');
|
||||
a.setAttribute('rel', Array.from(rel).join(' '));
|
||||
});
|
||||
}
|
||||
|
||||
enhanceHeadingAnchors(doc.body);
|
||||
enhanceCodeBlocks(doc.body);
|
||||
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
50
frontend/src/utils/postHeadings.ts
Normal file
50
frontend/src/utils/postHeadings.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/** 正文标题节点(用于文章目录树) */
|
||||
export interface PostHeading {
|
||||
id: string;
|
||||
level: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** 为标题补全锚点 id,并返回目录树数据 */
|
||||
export function enhanceHeadingAnchors(root: ParentNode): PostHeading[] {
|
||||
const headings: PostHeading[] = [];
|
||||
const used = new Map<string, number>();
|
||||
|
||||
root.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach((el, index) => {
|
||||
const text = (el.textContent || '').replace(/\s+/g, ' ').trim();
|
||||
if (!text) return;
|
||||
|
||||
const level = Number(el.tagName.slice(1)) || 2;
|
||||
let id = el.getAttribute('id')?.trim() || '';
|
||||
if (!id) {
|
||||
id = `heading-${index + 1}`;
|
||||
}
|
||||
const n = (used.get(id) || 0) + 1;
|
||||
used.set(id, n);
|
||||
if (n > 1) id = `${id}-${n}`;
|
||||
el.setAttribute('id', id);
|
||||
el.classList.add('post-heading-anchor');
|
||||
|
||||
headings.push({ id, level, text });
|
||||
});
|
||||
|
||||
return headings;
|
||||
}
|
||||
|
||||
/** 从已渲染 HTML 中读取目录(假定 id 已由 enhanceHeadingAnchors 写入) */
|
||||
export function extractHeadingsFromHtml(html: string): PostHeading[] {
|
||||
if (!html.trim()) return [];
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
const headings: PostHeading[] = [];
|
||||
doc.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach(el => {
|
||||
const id = el.getAttribute('id')?.trim();
|
||||
const text = (el.textContent || '').replace(/\s+/g, ' ').trim();
|
||||
if (!id || !text) return;
|
||||
headings.push({
|
||||
id,
|
||||
level: Number(el.tagName.slice(1)) || 2,
|
||||
text,
|
||||
});
|
||||
});
|
||||
return headings;
|
||||
}
|
||||
Reference in New Issue
Block a user