修复发帖长文布局与手机下拉刷新,发版后自动重载失效 chunk,并整理站务文案。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
60
frontend/src/components/AppRouteError.tsx
Normal file
60
frontend/src/components/AppRouteError.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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">
|
||||
|
||||
205
frontend/src/components/PullToRefresh.tsx
Normal file
205
frontend/src/components/PullToRefresh.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user