移除旧版 HTML 模板与兼容层,并完善私信、举报、媒体存储与 SEO。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 16:37:11 +08:00
parent 060b7707cb
commit 48db333272
121 changed files with 11147 additions and 3225 deletions

View File

@@ -40,11 +40,11 @@ export function validateAvatarOutput(file: File, maxMb: number): string | null {
return null;
}
/** 将裁剪区域渲染为 JPEG 文件 */
/** 将裁剪区域渲染为 WebP 文件(体积更小;不支持时回退 JPEG */
export async function getCroppedAvatarFile(
imageSrc: string,
pixelCrop: Area,
originalName = 'avatar.jpg',
originalName = 'avatar.webp',
): Promise<File> {
const image = await loadImage(imageSrc);
const canvas = document.createElement('canvas');
@@ -67,14 +67,25 @@ export async function getCroppedAvatarFile(
size,
);
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
b => (b ? resolve(b) : reject(new Error('裁剪失败'))),
'image/jpeg',
0.92,
);
});
const tryTypes: { mime: string; quality: number; ext: string }[] = [
{ mime: 'image/webp', quality: 0.86, ext: 'webp' },
{ mime: 'image/jpeg', quality: 0.92, ext: 'jpg' },
];
let blob: Blob | null = null;
let picked = tryTypes[1];
for (const t of tryTypes) {
blob = await new Promise<Blob | null>(resolve => {
canvas.toBlob(b => resolve(b), t.mime, t.quality);
});
if (blob && blob.type === t.mime) {
picked = t;
break;
}
blob = null;
}
if (!blob) throw new Error('裁剪失败');
const baseName = originalName.replace(/\.[^.]+$/, '') || 'avatar';
return new File([blob], `${baseName}.jpg`, { type: 'image/jpeg' });
return new File([blob], `${baseName}.${picked.ext}`, { type: picked.mime });
}

View File

@@ -22,7 +22,7 @@ export function isGuestComment(c: Comment): boolean {
return !c.user_id || c.user_id === 0;
}
/** 构建嵌套评论树( reply_to */
/** 构建嵌套评论树(优先 thread_parent_id回退 reply_to */
export function buildCommentTree(comments: Comment[]): CommentNode[] {
const map = new Map<number, CommentNode>();
const roots: CommentNode[] = [];
@@ -33,8 +33,9 @@ export function buildCommentTree(comments: Comment[]): CommentNode[] {
for (const c of comments) {
const node = map.get(c.id)!;
if (c.reply_to && map.has(c.reply_to)) {
map.get(c.reply_to)!.children.push(node);
const parentId = c.thread_parent_id ?? c.reply_to;
if (parentId && map.has(parentId)) {
map.get(parentId)!.children.push(node);
} else {
roots.push(node);
}

View File

@@ -15,34 +15,24 @@ export function highlightMentions(text: string, _onClick?: (name: string) => voi
.replace(/@([\w\u4e00-\u9fa5_-]+)/g, '<span class="mention">@$1</span>');
}
/** 相对时间:刚刚 / N分钟前 / N小时前 / N天前更早用具体日期 */
export function formatTime(iso: string) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const now = new Date();
const diff = (now.getTime() - d.getTime()) / 1000;
if (diff < 60) return '刚刚';
if (diff < 3600) return `${Math.floor(diff / 60)}分钟前`;
if (diff < 86400) return `${Math.floor(diff / 3600)}小时前`;
const diffSec = Math.max(0, (now.getTime() - d.getTime()) / 1000);
if (diffSec < 60) return '刚刚';
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}分钟前`;
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}小时前`;
const pad = (n: number) => String(n).padStart(2, '0');
const clock = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
if (
d.getFullYear() === yesterday.getFullYear()
&& d.getMonth() === yesterday.getMonth()
&& d.getDate() === yesterday.getDate()
) {
return `昨天 ${clock}`;
}
const diffDay = Math.floor(diffSec / 86400);
if (diffDay < 30) return `${diffDay}天前`;
if (d.getFullYear() === now.getFullYear()) {
return `${d.getMonth() + 1}${d.getDate()} ${clock}`;
return `${d.getMonth() + 1}${d.getDate()}`;
}
return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}${clock}`;
return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}`;
}
/** 完整日期时间(用于帖子发布/修改时间展示) */

View File

@@ -1,12 +1,19 @@
import type { NavigateFunction } from 'react-router-dom';
import { postPath, type PermalinkOpts } from './permalink';
export type OpenForumPostOpts = PermalinkOpts & {
/** 跳转到指定楼层(#floor-N */
floor?: number;
};
/** 按站点配置打开帖子详情(当前页跳转或新标签) */
export function openForumPost(
nav: NavigateFunction,
postId: number,
openInNewTab: boolean,
opts?: OpenForumPostOpts,
) {
const path = `/post/${postId}`;
const path = postPath(postId, opts) + (opts?.floor && opts.floor > 0 ? `#floor-${opts.floor}` : '');
if (openInNewTab) {
window.open(path, '_blank', 'noopener,noreferrer');
return;

View File

@@ -0,0 +1,52 @@
import { getCachedForumLimits } from '../hooks/useForumLimits';
export type PermalinkOpts = {
permalink_enabled?: boolean;
permalink_ext?: string;
};
const EXT_RE = /^[a-z0-9]{1,16}$/i;
/** 规范化伪静态后缀(无点) */
export function normalizePermalinkExt(raw?: string): string {
let ext = (raw ?? 'html').trim().replace(/^\./, '').toLowerCase();
if (!ext || !EXT_RE.test(ext)) return 'html';
return ext;
}
function suffix(opts?: PermalinkOpts): string {
const limits = opts ?? getCachedForumLimits();
if (!limits.permalink_enabled) return '';
return `.${normalizePermalinkExt(limits.permalink_ext)}`;
}
/** 帖子规范路径:/post/123 或 /post/123.html */
export function postPath(id: number | string, opts?: PermalinkOpts): string {
return `/post/${id}${suffix(opts)}`;
}
/** 用户规范路径 */
export function userPath(id: number | string, opts?: PermalinkOpts): string {
return `/user/${id}${suffix(opts)}`;
}
/** 从路由参数解析数字 ID兼容 123 / 123.html */
export function parsePermalinkID(raw: string | undefined): number {
if (!raw) return NaN;
const m = String(raw).match(/^(\d+)(?:\.[A-Za-z0-9]{1,16})?$/);
return m ? Number(m[1]) : NaN;
}
/** 客户端:若当前 URL 非规范伪静态路径则返回应跳转的目标 */
export function canonicalRedirectPath(
kind: 'post' | 'user',
id: number,
currentPathname: string,
opts?: PermalinkOpts,
): string | null {
if (!id || Number.isNaN(id)) return null;
const target = kind === 'post' ? postPath(id, opts) : userPath(id, opts);
const cur = currentPathname.replace(/\/$/, '') || '/';
const want = target.replace(/\/$/, '') || '/';
return cur === want ? null : target;
}

View File

@@ -0,0 +1,22 @@
import type { ReportReason, ReportStatus } from '../api/types';
export const REPORT_REASON_OPTIONS: { value: ReportReason; label: string }[] = [
{ value: 'spam', label: '垃圾广告' },
{ value: 'abuse', label: '人身攻击 / 辱骂' },
{ value: 'illegal', label: '违法违规' },
{ value: 'irrelevant', label: '内容无关 / 灌水' },
{ value: 'other', label: '其他' },
];
export function reportReasonLabel(reason: string) {
return REPORT_REASON_OPTIONS.find(o => o.value === reason)?.label ?? reason;
}
export function reportStatusLabel(status: ReportStatus | string) {
switch (status) {
case 'pending': return '待处理';
case 'resolved': return '已处理';
case 'dismissed': return '已忽略';
default: return status;
}
}

View File

@@ -0,0 +1,18 @@
/** 从 HTML 提取纯文本摘要(供页面 description / OG */
export function excerptFromHTML(html: string, max = 160): string {
if (!html) return '';
const doc = new DOMParser().parseFromString(html, 'text/html');
const text = (doc.body.textContent || '').replace(/\s+/g, ' ').trim();
if (text.length <= max) return text;
return `${text.slice(0, Math.max(0, max - 1))}`;
}
/** 正文中第一张图片 URL */
export function firstImageFromHTML(html: string): string {
if (!html) return '';
const doc = new DOMParser().parseFromString(html, 'text/html');
const img = doc.querySelector('img[src]');
const src = img?.getAttribute('src')?.trim() || '';
if (!src || src.startsWith('data:')) return '';
return src;
}

View File

@@ -1,4 +1,6 @@
/** 用户公开主页路径 */
export function userPath(id: number | string): string {
return `/user/${id}`;
import { userPath as permalinkUserPath, type PermalinkOpts } from './permalink';
/** 用户公开主页路径(遵循后台伪静态配置) */
export function userPath(id: number | string, opts?: PermalinkOpts): string {
return permalinkUserPath(id, opts);
}