支持公开用户主页、帖子图缩略图与编辑器图组排版。
新增用户签名与活动统计、图片灯箱;正文按需生成缩略图;TipTap 支持多图分组与环绕排版,并注入站点标题避免刷新闪烁。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,6 +6,12 @@ export const AVATAR_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image
|
||||
/** 头像输出尺寸 */
|
||||
export const AVATAR_OUTPUT_SIZE = 512;
|
||||
|
||||
/**
|
||||
* 原图体积软上限:仅防止浏览器加载过大文件卡死。
|
||||
* 实际上传限额看裁剪后的文件(见 validateAvatarOutput)。
|
||||
*/
|
||||
export const AVATAR_SOURCE_MAX_MB = 20;
|
||||
|
||||
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
@@ -15,13 +21,21 @@ function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
});
|
||||
}
|
||||
|
||||
/** 校验头像文件,返回错误信息或 null */
|
||||
export function validateAvatarFile(file: File, maxMb: number): string | null {
|
||||
/** 选择/拖入原图时:只校验格式与可读性上限,不按上传限额卡死 */
|
||||
export function validateAvatarFile(file: File): string | null {
|
||||
if (!AVATAR_MIME_TYPES.includes(file.type)) {
|
||||
return '仅支持 JPG、PNG、GIF、WebP 格式';
|
||||
}
|
||||
if (file.size > AVATAR_SOURCE_MAX_MB * 1024 * 1024) {
|
||||
return `原图过大(超过 ${AVATAR_SOURCE_MAX_MB}MB),请换一张较小的图片`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 裁剪完成后:按实际上传体积校验 */
|
||||
export function validateAvatarOutput(file: File, maxMb: number): string | null {
|
||||
if (file.size > maxMb * 1024 * 1024) {
|
||||
return `头像不能超过 ${maxMb}MB`;
|
||||
return `裁剪后头像仍超过 ${maxMb}MB,请缩小裁剪区域或换图`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -50,16 +50,50 @@ function splitParagraphBreaks(html: string): string {
|
||||
|
||||
/** 为 Turndown 注册通用正文规则(不含 members-only) */
|
||||
function addTurndownContentRules(service: TurndownService): void {
|
||||
service.addRule('imageGroup', {
|
||||
filter: (node) =>
|
||||
node.nodeName === 'DIV' && (node as HTMLElement).hasAttribute('data-image-group'),
|
||||
replacement: (_content, node) => {
|
||||
const el = node as HTMLElement;
|
||||
const layout = el.getAttribute('data-layout') || 'cols-2';
|
||||
const imgs = [...el.querySelectorAll(':scope > img, :scope .image-group__grid > img')]
|
||||
.map(img => {
|
||||
const src = img.getAttribute('src') || '';
|
||||
const alt = img.getAttribute('alt') || '';
|
||||
return src ? `<img src="${src}" alt="${alt}">` : '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
if (!imgs) return '';
|
||||
return `\n\n<div data-image-group data-layout="${layout}" class="image-group image-group--${layout}">${imgs}</div>\n\n`;
|
||||
},
|
||||
});
|
||||
|
||||
service.addRule('image', {
|
||||
filter: 'img',
|
||||
replacement: (_content, node) => {
|
||||
const el = node as HTMLImageElement;
|
||||
// 已由图组规则处理的子图跳过
|
||||
if (el.closest('[data-image-group]')) return '';
|
||||
const alt = el.getAttribute('alt') ?? '';
|
||||
const src = el.getAttribute('src') ?? '';
|
||||
return src ? `` : '';
|
||||
if (!src) return '';
|
||||
const display = el.getAttribute('data-display');
|
||||
if (display && display !== 'default') {
|
||||
return `\n\n<img src="${src}" alt="${alt}" data-display="${display}" class="article-img article-img--${display}">\n\n`;
|
||||
}
|
||||
return ``;
|
||||
},
|
||||
});
|
||||
|
||||
// 清浮动段落:保留为 HTML,避免空行被 Markdown 折叠后绕排失效
|
||||
service.addRule('clearFloatParagraph', {
|
||||
filter: (node) =>
|
||||
node.nodeName === 'P' && (node as HTMLElement).hasAttribute('data-clear-float'),
|
||||
replacement: (content) =>
|
||||
`\n\n<p data-clear-float class="article-clear-float">${content}</p>\n\n`,
|
||||
});
|
||||
|
||||
service.addRule('underline', {
|
||||
filter: ['u'],
|
||||
replacement: (content) => `<u>${content}</u>`,
|
||||
|
||||
@@ -6,7 +6,13 @@ import { enhanceHeadingAnchors } from './postHeadings';
|
||||
/** DOMPurify 配置:允许会员专属自定义标签与链接 target */
|
||||
export const POST_CONTENT_PURIFY_CONFIG: Config = {
|
||||
ADD_TAGS: ['members-only'],
|
||||
ADD_ATTR: ['data-locked', 'data-length', 'target', 'rel', 'data-code-copy', 'data-lang'],
|
||||
ADD_ATTR: [
|
||||
'data-locked', 'data-length', 'target', 'rel',
|
||||
'data-code-copy', 'data-lang', 'data-full',
|
||||
'data-image-group', 'data-layout', 'data-display',
|
||||
'data-clear-float',
|
||||
'class',
|
||||
],
|
||||
};
|
||||
|
||||
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>`;
|
||||
@@ -81,8 +87,37 @@ export function renderPostContentHtml(
|
||||
doc.querySelectorAll('img').forEach(img => {
|
||||
if (!img.getAttribute('loading')) img.setAttribute('loading', 'lazy');
|
||||
if (!img.getAttribute('decoding')) img.setAttribute('decoding', 'async');
|
||||
|
||||
const rawSrc = img.getAttribute('src') || '';
|
||||
const full = img.getAttribute('data-full') || rawSrc;
|
||||
const thumb = toPostImageThumbSrc(full) || toPostImageThumbSrc(rawSrc);
|
||||
if (thumb && full) {
|
||||
// 正文加载缩略图,点击灯箱用原图
|
||||
if (!img.getAttribute('data-full')) img.setAttribute('data-full', full);
|
||||
if (rawSrc !== thumb) img.setAttribute('src', thumb);
|
||||
img.classList.add('post-content-img--zoomable');
|
||||
img.setAttribute('role', 'button');
|
||||
img.setAttribute('tabindex', '0');
|
||||
img.setAttribute('title', img.getAttribute('title') || '点击查看原图');
|
||||
}
|
||||
});
|
||||
|
||||
// 规范化图组 class,保证阅读态宫格样式生效
|
||||
doc.querySelectorAll('div[data-image-group]').forEach(el => {
|
||||
const layout = el.getAttribute('data-layout') || 'cols-2';
|
||||
el.classList.add('image-group', `image-group--${layout}`);
|
||||
});
|
||||
|
||||
// 单图展示形态 class
|
||||
doc.querySelectorAll('img[data-display]').forEach(img => {
|
||||
const display = img.getAttribute('data-display');
|
||||
if (!display || display === 'default') return;
|
||||
img.classList.add('article-img', `article-img--${display}`);
|
||||
});
|
||||
|
||||
// 绕排图后:连续 ≥2 个空段落,则其后首个有内容块清除浮动(写到图下)
|
||||
markClearFloatAfterBlankRuns(doc.body);
|
||||
|
||||
if (opts?.openLinksInNewTab) {
|
||||
doc.querySelectorAll('a[href]').forEach(a => {
|
||||
const href = a.getAttribute('href') || '';
|
||||
@@ -100,3 +135,110 @@ export function renderPostContentHtml(
|
||||
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
function isFloatDisplayImage(el: Element): boolean {
|
||||
if (el.tagName !== 'IMG') return false;
|
||||
const display = el.getAttribute('data-display') || '';
|
||||
return display === 'float-left' || display === 'float-right'
|
||||
|| el.classList.contains('article-img--float-left')
|
||||
|| el.classList.contains('article-img--float-right');
|
||||
}
|
||||
|
||||
/** 空段落 / 仅含 br、空白 */
|
||||
function isBlankParagraph(el: Element): boolean {
|
||||
if (el.tagName !== 'P') return false;
|
||||
const text = (el.textContent || '').replace(/\u00a0/g, ' ').trim();
|
||||
if (text.length > 0) return false;
|
||||
return !el.querySelector('img, video, iframe, table, pre, blockquote, members-only');
|
||||
}
|
||||
|
||||
/**
|
||||
* 浮动绕排后若作者连按多次回车再写字,给后续块加 clear,
|
||||
* 避免「明明写在图下却仍贴在图右侧」。
|
||||
* 阅读态与编辑器 DOM 均可调用。
|
||||
*/
|
||||
export function markClearFloatAfterBlankRuns(root: HTMLElement): void {
|
||||
const blocks = [...root.children];
|
||||
let seenFloat = false;
|
||||
let blankRun = 0;
|
||||
|
||||
for (const el of blocks) {
|
||||
// 已持久化的清浮动标记始终生效
|
||||
if (el.hasAttribute('data-clear-float')) {
|
||||
el.classList.add('article-clear-float');
|
||||
}
|
||||
|
||||
if (isFloatDisplayImage(el)) {
|
||||
seenFloat = true;
|
||||
blankRun = 0;
|
||||
continue;
|
||||
}
|
||||
// 通栏块本身会清浮动,重置状态
|
||||
if (
|
||||
el.tagName === 'IMG'
|
||||
|| el.classList.contains('image-group')
|
||||
|| /^H[1-6]$/.test(el.tagName)
|
||||
|| el.tagName === 'HR'
|
||||
|| el.tagName === 'PRE'
|
||||
|| el.tagName === 'TABLE'
|
||||
|| el.tagName === 'BLOCKQUOTE'
|
||||
) {
|
||||
seenFloat = isFloatDisplayImage(el);
|
||||
blankRun = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!seenFloat) {
|
||||
if (!el.hasAttribute('data-clear-float')) {
|
||||
el.classList.remove('article-clear-float');
|
||||
}
|
||||
blankRun = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isBlankParagraph(el)) {
|
||||
blankRun += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 双空行 或 已有 data-clear-float:保持写到图下
|
||||
if (blankRun >= 2 || el.hasAttribute('data-clear-float')) {
|
||||
el.classList.add('article-clear-float');
|
||||
if (!el.hasAttribute('data-clear-float')) {
|
||||
el.setAttribute('data-clear-float', '');
|
||||
}
|
||||
} else {
|
||||
el.classList.remove('article-clear-float');
|
||||
}
|
||||
blankRun = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将帖子上传图 URL 转为缩略图地址。
|
||||
* /uploads/posts/a.jpg → /media/thumb/posts/a.jpg
|
||||
*/
|
||||
export function toPostImageThumbSrc(src: string): string | null {
|
||||
const path = extractUploadPath(src);
|
||||
if (!path) return null;
|
||||
if (path.startsWith('/media/thumb/')) return path;
|
||||
if (!path.startsWith('/uploads/posts/')) return null;
|
||||
return `/media/thumb/${path.slice('/uploads/'.length)}`;
|
||||
}
|
||||
|
||||
/** 提取同源相对路径(忽略 query / hash) */
|
||||
function extractUploadPath(src: string): string | null {
|
||||
const raw = (src || '').trim();
|
||||
if (!raw || raw.startsWith('data:') || raw.startsWith('blob:')) return null;
|
||||
try {
|
||||
if (raw.startsWith('http://') || raw.startsWith('https://')) {
|
||||
const u = new URL(raw);
|
||||
if (typeof window !== 'undefined' && u.origin !== window.location.origin) return null;
|
||||
return u.pathname;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const path = raw.split('?')[0].split('#')[0];
|
||||
return path.startsWith('/') ? path : null;
|
||||
}
|
||||
|
||||
4
frontend/src/utils/userPath.ts
Normal file
4
frontend/src/utils/userPath.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/** 用户公开主页路径 */
|
||||
export function userPath(id: number | string): string {
|
||||
return `/user/${id}`;
|
||||
}
|
||||
Reference in New Issue
Block a user