升级帖子编辑与 Feed 体验:TipTap 富文本、修订历史、排序与编辑时限。
将 Markdown 编辑器替换为 TipTap WYSIWYG,新增帖子修订记录与 diff 展示;首页支持最新/回复排序与本地缓存;后台可配置编辑时限与锁定帖子;侧边栏整合板块导航并优化 Feed 布局。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -17,10 +17,46 @@ export function highlightMentions(text: string, _onClick?: (name: string) => voi
|
||||
|
||||
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)}小时前`;
|
||||
return `${d.getMonth() + 1}-${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
if (d.getFullYear() === now.getFullYear()) {
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日 ${clock}`;
|
||||
}
|
||||
|
||||
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日 ${clock}`;
|
||||
}
|
||||
|
||||
/** 完整日期时间(用于帖子发布/修改时间展示) */
|
||||
export function formatDateTime(iso: string) {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日 ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** 判断两个 ISO 时间是否相差超过 1 分钟 */
|
||||
export function isTimeDiffSignificant(a: string, b: string) {
|
||||
const da = new Date(a).getTime();
|
||||
const db = new Date(b).getTime();
|
||||
if (Number.isNaN(da) || Number.isNaN(db)) return false;
|
||||
return Math.abs(da - db) > 60_000;
|
||||
}
|
||||
|
||||
112
frontend/src/utils/feedCache.ts
Normal file
112
frontend/src/utils/feedCache.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { PostItem } from '../api/types';
|
||||
|
||||
import type { FeedSort } from '../components/FeedSortBar';
|
||||
|
||||
|
||||
|
||||
export type FeedCache = {
|
||||
|
||||
posts: PostItem[];
|
||||
|
||||
postTotal: number;
|
||||
|
||||
page: number;
|
||||
|
||||
hasMore: boolean;
|
||||
|
||||
scrollTop: number;
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
const PREFIX = 'j13-feed-cache:';
|
||||
|
||||
|
||||
|
||||
function cacheKey(boardId: number, keyword: string, sort: FeedSort) {
|
||||
|
||||
return `${PREFIX}${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;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** 保存帖子列表缓存 */
|
||||
|
||||
export function setFeedCache(boardId: number, keyword: string, sort: FeedSort, data: FeedCache) {
|
||||
|
||||
try {
|
||||
|
||||
sessionStorage.setItem(cacheKey(boardId, keyword, sort), JSON.stringify(data));
|
||||
|
||||
} catch {
|
||||
|
||||
// sessionStorage 不可用时忽略
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** 清除指定筛选条件下的列表缓存 */
|
||||
|
||||
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
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -51,6 +51,16 @@ function buildLockedGateHtml(charLength: number): string {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** 判断 HTML 正文是否为空(忽略空段落等) */
|
||||
export function isHtmlEmpty(html: string): boolean {
|
||||
if (!html.trim()) return true;
|
||||
const doc = new DOMParser().parseFromString(
|
||||
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG),
|
||||
'text/html',
|
||||
);
|
||||
return (doc.body.textContent ?? '').trim().length === 0;
|
||||
}
|
||||
|
||||
/** 根据登录状态渲染帖子正文 HTML */
|
||||
export function renderPostContentHtml(html: string, isLoggedIn: boolean): string {
|
||||
if (!html.trim()) return '';
|
||||
|
||||
77
frontend/src/utils/revisionDiff.ts
Normal file
77
frontend/src/utils/revisionDiff.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { diffLines, diffWords } from 'diff';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { POST_CONTENT_PURIFY_CONFIG } from './postContent';
|
||||
|
||||
export interface PostSnapshot {
|
||||
title: string;
|
||||
content: string;
|
||||
tags: string;
|
||||
}
|
||||
|
||||
export interface RevisionChangeSummary {
|
||||
titleChanged: boolean;
|
||||
tagsChanged: boolean;
|
||||
contentChanged: boolean;
|
||||
hasChanges: boolean;
|
||||
}
|
||||
|
||||
export interface DiffPart {
|
||||
value: string;
|
||||
added?: boolean;
|
||||
removed?: boolean;
|
||||
}
|
||||
|
||||
/** 将 HTML 正文转为适合 diff 的纯文本(保留段落换行) */
|
||||
export function htmlToDiffText(html: string): string {
|
||||
if (!html.trim()) return '';
|
||||
const doc = new DOMParser().parseFromString(
|
||||
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG),
|
||||
'text/html',
|
||||
);
|
||||
doc.querySelectorAll('br').forEach(br => br.replaceWith('\n'));
|
||||
const blocks = doc.querySelectorAll('p, div, li, h1, h2, h3, h4, h5, h6, blockquote, pre, members-only');
|
||||
blocks.forEach(el => {
|
||||
el.prepend(doc.createTextNode('\n'));
|
||||
el.append(doc.createTextNode('\n'));
|
||||
});
|
||||
return (doc.body.textContent ?? '')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function summarizeChange(before: PostSnapshot, after: PostSnapshot): RevisionChangeSummary {
|
||||
const titleChanged = before.title.trim() !== after.title.trim();
|
||||
const tagsChanged = normalizeTags(before.tags) !== normalizeTags(after.tags);
|
||||
const contentChanged = htmlToDiffText(before.content) !== htmlToDiffText(after.content);
|
||||
return {
|
||||
titleChanged,
|
||||
tagsChanged,
|
||||
contentChanged,
|
||||
hasChanges: titleChanged || tagsChanged || contentChanged,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTags(tags: string) {
|
||||
return tags.split(/[,,]/).map(t => t.trim()).filter(Boolean).join(',');
|
||||
}
|
||||
|
||||
export function diffTextLines(before: string, after: string): DiffPart[] {
|
||||
return diffLines(before || '', after || '');
|
||||
}
|
||||
|
||||
export function diffTextWords(before: string, after: string): DiffPart[] {
|
||||
return diffWords(before || '', after || '');
|
||||
}
|
||||
|
||||
/** 统计 diff 片段中的增删行数 */
|
||||
export function countLineChanges(parts: DiffPart[]) {
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
for (const p of parts) {
|
||||
const lines = p.value.split('\n').filter(l => l.length > 0);
|
||||
if (p.added) added += lines.length;
|
||||
else if (p.removed) removed += lines.length;
|
||||
}
|
||||
return { added, removed };
|
||||
}
|
||||
Reference in New Issue
Block a user