移除旧版 HTML 模板与兼容层,并完善私信、举报、媒体存储与 SEO。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()}日`;
|
||||
}
|
||||
|
||||
/** 完整日期时间(用于帖子发布/修改时间展示) */
|
||||
|
||||
@@ -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;
|
||||
|
||||
52
frontend/src/utils/permalink.ts
Normal file
52
frontend/src/utils/permalink.ts
Normal 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;
|
||||
}
|
||||
22
frontend/src/utils/report.ts
Normal file
22
frontend/src/utils/report.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
18
frontend/src/utils/seoText.ts
Normal file
18
frontend/src/utils/seoText.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user