新增 OIDC/SSO、邮件验证码与 Gitea 项目同步,并强化 Feed 与管理后台。

作为 OIDC Provider 对接 Gitea;注册支持邮件验证码/验证码;侧栏同步公开仓库;Feed 分页、文章大纲、标签云与站点品牌设置。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-31 16:58:22 +08:00
parent 9487c8ab02
commit 822eef96be
83 changed files with 8578 additions and 1222 deletions

View 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;
}

View 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(),
);
}

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/** 美化并高亮文档中的代码块(加语言标签与复制按钮) */
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');
});
}

View File

@@ -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();
}
/** 主动刷新帖子列表时派发,用于同页内立即回到顶部 */

View File

@@ -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);
}

View 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);
}

View File

@@ -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;
}

View 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;
}