修复发帖长文布局与手机下拉刷新,发版后自动重载失效 chunk,并整理站务文案。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 18:18:35 +08:00
parent 61b47363e9
commit 2c06eaccb2
20 changed files with 811 additions and 360 deletions

View File

@@ -0,0 +1,60 @@
import { useEffect } from 'react';
import { isRouteErrorResponse, useRouteError } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { isChunkLoadError, reloadForStaleChunk } from '../utils/chunkLoad';
function errorMessage(error: unknown): string {
if (isRouteErrorResponse(error)) {
if (typeof error.data === 'string' && error.data) return error.data;
if (error.data && typeof error.data === 'object' && 'message' in error.data) {
const m = (error.data as { message?: unknown }).message;
if (typeof m === 'string' && m) return m;
}
return error.statusText || `错误 ${error.status}`;
}
if (error instanceof Error) return error.message;
return String(error ?? '未知错误');
}
/**
* React Router 路由级错误页。
* 发版后动态模块 404 时自动刷新;其它错误给出手动刷新入口。
*/
export default function AppRouteError() {
const error = useRouteError();
const chunkMiss = isChunkLoadError(error);
useEffect(() => {
if (chunkMiss) reloadForStaleChunk();
}, [chunkMiss]);
if (chunkMiss) {
return (
<div className="error-page-shell">
<div className="error-page">
<div className="error-page__code" aria-hidden></div>
<h1 className="error-page__title"></h1>
<p className="error-page__desc"></p>
</div>
</div>
);
}
return (
<div className="error-page-shell">
<div className="error-page">
<div className="error-page__code" aria-hidden>500</div>
<h1 className="error-page__title"></h1>
<p className="error-page__desc">{errorMessage(error)}</p>
<div className="error-page__actions">
<Button size="sm" onClick={() => window.location.reload()}>
</Button>
<Button size="sm" variant="outline" onClick={() => { window.location.href = '/'; }}>
</Button>
</div>
</div>
</div>
);
}

View File

@@ -2,7 +2,7 @@ import {
useRef, useEffect, useImperativeHandle, forwardRef, useCallback, useState, useMemo, type ReactNode,
} from 'react';
import { useEditor, EditorContent, type Editor } from '@tiptap/react';
import { TextSelection } from '@tiptap/pm/state';
import { TextSelection, NodeSelection } from '@tiptap/pm/state';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';
@@ -73,6 +73,35 @@ function isEditorEmpty(editor: Editor): boolean {
return editor.state.doc.textContent.trim().length === 0;
}
/**
* 将光标放到第一个文本块开头。
* 文档以分割线等原子节点开头时,默认选区会变成 NodeSelection出现整条高亮。
*/
function placeCaretInFirstTextblock(editor: Editor) {
const { state } = editor;
const { doc, selection } = state;
let pos: number | null = null;
doc.descendants((node, nodePos) => {
if (node.isTextblock) {
pos = nodePos + 1;
return false;
}
return true;
});
if (pos == null) return;
const needsMove =
selection instanceof NodeSelection
|| selection.from !== pos
|| selection.to !== pos;
if (!needsMove) return;
const tr = state.tr
.setSelection(TextSelection.create(doc, pos))
.setMeta('addToHistory', false);
editor.view.dispatch(tr);
}
/** 标题循环:正文 → H2 → H3 → … → H6 → 正文 */
function cycleHeading(editor: Editor) {
for (let level = 2; level <= 6; level += 1) {
@@ -181,6 +210,10 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
TabIndent,
],
content: sanitizeHtml(value) || '',
autofocus: false,
onCreate: ({ editor: ed }) => {
placeCaretInFirstTextblock(ed);
},
onUpdate: ({ editor: ed }) => {
const html = sanitizeHtml(ed.getHTML());
isInternalUpdate.current = true;
@@ -229,6 +262,8 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
if (next === lastValueRef.current) return;
lastValueRef.current = next;
editor.commands.setContent(next || '', { emitUpdate: false });
// 加载长文时避免首行分割线被 NodeSelection 选中
placeCaretInFirstTextblock(editor);
}, [value, editor, mode]);
// 全屏时锁定页面滚动Esc 退出
@@ -267,7 +302,9 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
markdownRef.current?.focus();
return;
}
editor?.commands.focus();
if (!editor) return;
placeCaretInFirstTextblock(editor);
editor.commands.focus();
},
}), [editor, value, mode, markdownSource]);
@@ -365,6 +402,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
onChange(html);
if (editor) {
editor.commands.setContent(html || '', { emitUpdate: false });
placeCaretInFirstTextblock(editor);
}
setMode('rich');
}, [editor, markdownSource, onChange]);

View File

@@ -1,15 +1,19 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import { Button } from '@/components/ui/button';
import { isChunkLoadError, reloadForStaleChunk } from '../utils/chunkLoad';
interface Props { children: ReactNode }
interface State { error: Error | null }
interface State { error: Error | null; reloading: boolean }
/** 捕获渲染异常,避免整页白屏 */
/** 捕获渲染异常;发版 chunk 失效时自动刷新 */
export default class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
state: State = { error: null, reloading: false };
static getDerivedStateFromError(error: Error) {
return { error };
if (isChunkLoadError(error) && reloadForStaleChunk()) {
return { error, reloading: true };
}
return { error, reloading: false };
}
componentDidCatch(error: Error, info: ErrorInfo) {
@@ -17,6 +21,18 @@ export default class ErrorBoundary extends Component<Props, State> {
}
render() {
if (this.state.reloading) {
return (
<div className="error-page-shell">
<div className="error-page">
<div className="error-page__code" aria-hidden></div>
<h1 className="error-page__title"></h1>
<p className="error-page__desc"></p>
</div>
</div>
);
}
if (this.state.error) {
return (
<div className="error-page-shell">

View File

@@ -0,0 +1,205 @@
import { useEffect, useRef, useState } from 'react';
import { Loader2, ArrowDown } from 'lucide-react';
/** 触发刷新的下拉距离px */
const REFRESH_THRESHOLD = 68;
/** 指示器最大位移 */
const PULL_MAX = 108;
/** 判定为「下拉」意图的最小位移,避免误触滚动 */
const ARM_DELTA = 10;
/**
* 定位当前真正滚动的容器。
* SPA 使用内部滚动body overflow:hidden原生下拉刷新不可用需挂到此容器。
*/
function pickScrollEl(): HTMLElement | null {
const list = document.querySelector<HTMLElement>('.post-list-scroll');
if (list) return list;
const page = document.querySelector<HTMLElement>('.page-wrap:not(.page-wrap--feed)');
if (page) return page;
const compose = document.querySelector<HTMLElement>('.main-content--compose');
if (compose) return compose;
const admin = document.querySelector<HTMLElement>('.admin-main');
if (admin) return admin;
const auth = document.querySelector<HTMLElement>('.auth-page');
if (auth) return auth;
return null;
}
function isTouchDevice(): boolean {
return window.matchMedia('(hover: none) and (pointer: coarse)').matches
|| navigator.maxTouchPoints > 0;
}
/**
* 手机端下拉刷新:在内部滚动容器顶部下拉后整页重载。
* (浏览器原生 PTR 依赖 document 滚动,与本站 app-shell 布局不兼容。)
*
* 挂在 Router 外,故用 MutationObserver 在路由切换后重绑滚动容器。
*/
export default function PullToRefresh() {
const [pull, setPull] = useState(0);
const [refreshing, setRefreshing] = useState(false);
const pullRef = useRef(0);
const refreshingRef = useRef(false);
const startYRef = useRef(0);
const trackingRef = useRef(false);
const pullingRef = useRef(false);
const scrollElRef = useRef<HTMLElement | null>(null);
useEffect(() => {
pullRef.current = pull;
}, [pull]);
useEffect(() => {
refreshingRef.current = refreshing;
}, [refreshing]);
useEffect(() => {
if (!isTouchDevice()) return;
let cancelled = false;
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
let bound: HTMLElement | null = null;
const resetGesture = () => {
trackingRef.current = false;
pullingRef.current = false;
startYRef.current = 0;
if (!refreshingRef.current) setPull(0);
};
const onTouchStart = (e: TouchEvent) => {
if (refreshingRef.current || e.touches.length !== 1) return;
const el = scrollElRef.current;
if (!el || el.scrollTop > 1) return;
if (document.querySelector(
'.sidebar-drawer-root, .aside-drawer-root, .image-lightbox, [aria-modal="true"]',
)) {
return;
}
trackingRef.current = true;
pullingRef.current = false;
startYRef.current = e.touches[0].clientY;
};
const onTouchMove = (e: TouchEvent) => {
if (!trackingRef.current || refreshingRef.current || e.touches.length !== 1) return;
const el = scrollElRef.current;
if (!el) return;
if (el.scrollTop > 1) {
resetGesture();
return;
}
const dy = e.touches[0].clientY - startYRef.current;
if (dy < ARM_DELTA) {
if (pullingRef.current && dy <= 0) resetGesture();
return;
}
pullingRef.current = true;
setPull(Math.min(PULL_MAX, dy * 0.55));
if (e.cancelable) e.preventDefault();
};
const onTouchEnd = () => {
if (!trackingRef.current) return;
const shouldRefresh = pullingRef.current && pullRef.current >= REFRESH_THRESHOLD;
trackingRef.current = false;
pullingRef.current = false;
if (shouldRefresh) {
setRefreshing(true);
setPull(REFRESH_THRESHOLD * 0.7);
window.setTimeout(() => {
window.location.reload();
}, 180);
return;
}
setPull(0);
};
const unbind = () => {
if (!bound) return;
bound.removeEventListener('touchstart', onTouchStart);
bound.removeEventListener('touchmove', onTouchMove);
bound.removeEventListener('touchend', onTouchEnd);
bound.removeEventListener('touchcancel', onTouchEnd);
bound = null;
};
const bind = (el: HTMLElement) => {
if (bound === el) return;
unbind();
bound = el;
scrollElRef.current = el;
el.addEventListener('touchstart', onTouchStart, { passive: true });
el.addEventListener('touchmove', onTouchMove, { passive: false });
el.addEventListener('touchend', onTouchEnd, { passive: true });
el.addEventListener('touchcancel', onTouchEnd, { passive: true });
};
const tryBind = () => {
if (cancelled) return;
const next = pickScrollEl();
if (next) bind(next);
};
const scheduleBind = () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(tryBind, 40);
};
tryBind();
const root = document.getElementById('root') ?? document.body;
const mo = new MutationObserver(scheduleBind);
mo.observe(root, { childList: true, subtree: true });
window.addEventListener('popstate', scheduleBind);
return () => {
cancelled = true;
clearTimeout(debounceTimer);
mo.disconnect();
window.removeEventListener('popstate', scheduleBind);
unbind();
scrollElRef.current = null;
trackingRef.current = false;
pullingRef.current = false;
};
}, []);
if (!refreshing && pull <= 0) return null;
const ready = pull >= REFRESH_THRESHOLD || refreshing;
const offset = Math.max(pull, refreshing ? 48 : 0);
return (
<div
className={`ptr-indicator${ready ? ' ptr-indicator--ready' : ''}${refreshing ? ' ptr-indicator--refreshing' : ''}`}
style={{ transform: `translateY(${offset}px)` }}
aria-hidden
>
<div className="ptr-indicator__chip">
{refreshing ? (
<Loader2 size={18} className="ptr-indicator__spin" aria-hidden />
) : (
<ArrowDown
size={18}
className="ptr-indicator__arrow"
style={{ transform: ready ? 'rotate(180deg)' : undefined }}
aria-hidden
/>
)}
<span>{refreshing ? '刷新中…' : ready ? '松开刷新' : '下拉刷新'}</span>
</div>
</div>
);
}