修复发帖长文布局与手机下拉刷新,发版后自动重载失效 chunk,并整理站务文案。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { Suspense } from 'react';
|
||||
import {
|
||||
createBrowserRouter,
|
||||
createRoutesFromElements,
|
||||
@@ -12,33 +12,36 @@ import { ThemeProvider } from './hooks/useTheme';
|
||||
import MainLayout from './layouts/MainLayout';
|
||||
import AdminLayout from './layouts/AdminLayout';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import AppRouteError from './components/AppRouteError';
|
||||
import PageLoader from './components/PageLoader';
|
||||
import AuthPageFallback from './components/AuthPageFallback';
|
||||
import { Toaster } from './components/ui/sonner';
|
||||
import PullToRefresh from './components/PullToRefresh';
|
||||
import { lazyWithRetry } from './utils/lazyWithRetry';
|
||||
|
||||
const HomePage = lazy(() => import('./pages/HomePage'));
|
||||
const PostDetailPage = lazy(() => import('./pages/PostDetailPage'));
|
||||
const LoginPage = lazy(() => import('./pages/LoginPage'));
|
||||
const RegisterPage = lazy(() => import('./pages/RegisterPage'));
|
||||
const ComposePage = lazy(() => import('./pages/ComposePage'));
|
||||
const BoardsManagePage = lazy(() => import('./pages/BoardsManagePage'));
|
||||
const ProfilePage = lazy(() => import('./pages/ProfilePage'));
|
||||
const UserProfilePage = lazy(() => import('./pages/UserProfilePage'));
|
||||
const FavoritesPage = lazy(() => import('./pages/FavoritesPage'));
|
||||
const MessagesPage = lazy(() => import('./pages/MessagesPage'));
|
||||
const ProjectsPage = lazy(() => import('./pages/ProjectsPage'));
|
||||
const AdminDashboardPage = lazy(() => import('./pages/admin/AdminDashboardPage'));
|
||||
const AdminPostsPage = lazy(() => import('./pages/admin/AdminPostsPage'));
|
||||
const AdminCommentsPage = lazy(() => import('./pages/admin/AdminCommentsPage'));
|
||||
const AdminReportsPage = lazy(() => import('./pages/admin/AdminReportsPage'));
|
||||
const AdminUsersPage = lazy(() => import('./pages/admin/AdminUsersPage'));
|
||||
const AdminMediaPage = lazy(() => import('./pages/admin/AdminMediaPage'));
|
||||
const AdminSettingsPage = lazy(() => import('./pages/admin/AdminSettingsPage'));
|
||||
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'));
|
||||
const HomePage = lazyWithRetry(() => import('./pages/HomePage'));
|
||||
const PostDetailPage = lazyWithRetry(() => import('./pages/PostDetailPage'));
|
||||
const LoginPage = lazyWithRetry(() => import('./pages/LoginPage'));
|
||||
const RegisterPage = lazyWithRetry(() => import('./pages/RegisterPage'));
|
||||
const ComposePage = lazyWithRetry(() => import('./pages/ComposePage'));
|
||||
const BoardsManagePage = lazyWithRetry(() => import('./pages/BoardsManagePage'));
|
||||
const ProfilePage = lazyWithRetry(() => import('./pages/ProfilePage'));
|
||||
const UserProfilePage = lazyWithRetry(() => import('./pages/UserProfilePage'));
|
||||
const FavoritesPage = lazyWithRetry(() => import('./pages/FavoritesPage'));
|
||||
const MessagesPage = lazyWithRetry(() => import('./pages/MessagesPage'));
|
||||
const ProjectsPage = lazyWithRetry(() => import('./pages/ProjectsPage'));
|
||||
const AdminDashboardPage = lazyWithRetry(() => import('./pages/admin/AdminDashboardPage'));
|
||||
const AdminPostsPage = lazyWithRetry(() => import('./pages/admin/AdminPostsPage'));
|
||||
const AdminCommentsPage = lazyWithRetry(() => import('./pages/admin/AdminCommentsPage'));
|
||||
const AdminReportsPage = lazyWithRetry(() => import('./pages/admin/AdminReportsPage'));
|
||||
const AdminUsersPage = lazyWithRetry(() => import('./pages/admin/AdminUsersPage'));
|
||||
const AdminMediaPage = lazyWithRetry(() => import('./pages/admin/AdminMediaPage'));
|
||||
const AdminSettingsPage = lazyWithRetry(() => import('./pages/admin/AdminSettingsPage'));
|
||||
const NotFoundPage = lazyWithRetry(() => import('./pages/NotFoundPage'));
|
||||
|
||||
const router = createBrowserRouter(
|
||||
createRoutesFromElements(
|
||||
<>
|
||||
<Route errorElement={<AppRouteError />}>
|
||||
<Route path="/login" element={<Suspense fallback={<AuthPageFallback />}><LoginPage /></Suspense>} />
|
||||
<Route path="/register" element={<Suspense fallback={<AuthPageFallback />}><RegisterPage /></Suspense>} />
|
||||
<Route path="/boards" element={<Navigate to="/admin/boards" replace />} />
|
||||
@@ -68,7 +71,7 @@ const router = createBrowserRouter(
|
||||
<Route path="*" element={<Suspense fallback={<PageLoader />}><NotFoundPage /></Suspense>} />
|
||||
</Route>
|
||||
<Route path="*" element={<Suspense fallback={<PageLoader fullScreen />}><NotFoundPage standalone /></Suspense>} />
|
||||
</>,
|
||||
</Route>,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -78,6 +81,7 @@ export default function App() {
|
||||
<AuthProvider>
|
||||
<ErrorBoundary>
|
||||
<RouterProvider router={router} />
|
||||
<PullToRefresh />
|
||||
<Toaster />
|
||||
</ErrorBoundary>
|
||||
</AuthProvider>
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -303,6 +303,7 @@ export default function ComposePage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="compose-shell-body">
|
||||
<section className="compose-context" aria-label="发布设置">
|
||||
<div className="compose-context-row">
|
||||
<span className="compose-context-label">板块</span>
|
||||
@@ -347,6 +348,7 @@ export default function ComposePage() {
|
||||
placeholder="开始写作。按回车分段,选中文字后用工具栏设置格式。"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UnsavedChangesDialog
|
||||
|
||||
@@ -1470,6 +1470,57 @@ img.site-brand-logo-img {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
/* 手机端自定义下拉刷新指示器(原生 PTR 依赖 document 滚动) */
|
||||
.ptr-indicator {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
transition: transform 0.12s ease-out;
|
||||
}
|
||||
|
||||
.ptr-indicator__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--j13-border);
|
||||
background: var(--j13-bg-surface);
|
||||
color: var(--color-text-2);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
box-shadow: var(--j13-shadow-card);
|
||||
}
|
||||
|
||||
.ptr-indicator--ready .ptr-indicator__chip {
|
||||
color: var(--j13-green);
|
||||
border-color: color-mix(in srgb, var(--j13-green) 35%, var(--j13-border));
|
||||
background: var(--j13-green-bg);
|
||||
}
|
||||
|
||||
.ptr-indicator__arrow {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.ptr-indicator__spin {
|
||||
animation: ptr-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ptr-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.ptr-indicator { display: none; }
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.back-to-top-stack {
|
||||
right: 14px;
|
||||
@@ -5809,10 +5860,11 @@ button.profile-stat:hover strong {
|
||||
.compose-page {
|
||||
/* 与 .compose-header 实际高度对齐,供编辑器工具栏 sticky 偏移 */
|
||||
--compose-header-sticky-h: 57px;
|
||||
flex: 1;
|
||||
/* 随正文增高,避免白底被视口高度裁切、粘性顶栏失效 */
|
||||
flex: 1 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: min(100%, 100vh - var(--j13-header-h));
|
||||
min-height: 100%;
|
||||
overflow: visible;
|
||||
background: var(--j13-bg-workspace);
|
||||
}
|
||||
@@ -5880,15 +5932,16 @@ button.profile-stat:hover strong {
|
||||
}
|
||||
|
||||
.compose-canvas {
|
||||
flex: 1;
|
||||
flex: 1 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: min(100%, 100vh - var(--j13-header-h));
|
||||
/* 富文本:接近阅读宽度;源码双栏时拉宽 */
|
||||
max-width: 1120px;
|
||||
min-height: 100%;
|
||||
/* 富文本:接近正文阅读宽度,避免宽行/表格撑出右侧;源码双栏另加宽 */
|
||||
max-width: calc(var(--j13-article-read-w) + 64px);
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 16px 20px 24px;
|
||||
box-sizing: border-box;
|
||||
transition: max-width 0.2s ease;
|
||||
}
|
||||
|
||||
@@ -5897,17 +5950,33 @@ button.profile-stat:hover strong {
|
||||
}
|
||||
|
||||
.compose-shell {
|
||||
flex: 1;
|
||||
flex: 1 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
/* 短文填满可视区;长文随内容增高(勿用 min-height:0,否则白底被裁切) */
|
||||
min-height: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
background: var(--j13-bg-surface);
|
||||
border: 1px solid var(--j13-border-light);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--j13-shadow-card);
|
||||
/* 顶栏 sticky 需 visible;横向溢出由 .compose-shell-body 承接 */
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* 正文区单独限宽,避免表格/长词撑出白底 */
|
||||
.compose-shell-body {
|
||||
flex: 1 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
border-radius: 0 0 12px 12px;
|
||||
}
|
||||
|
||||
.compose-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
@@ -5921,6 +5990,8 @@ button.profile-stat:hover strong {
|
||||
background: var(--j13-bg-surface);
|
||||
border-bottom: 1px solid var(--j13-border-light);
|
||||
border-radius: 12px 12px 0 0;
|
||||
/* 长文下滚时顶栏与正文分层更清晰 */
|
||||
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.03);
|
||||
}
|
||||
|
||||
.compose-header-left {
|
||||
@@ -6199,11 +6270,12 @@ button.profile-stat:hover strong {
|
||||
}
|
||||
|
||||
.compose-document {
|
||||
flex: 1;
|
||||
flex: 1 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 0 0 0;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.compose-title {
|
||||
@@ -6238,9 +6310,11 @@ button.profile-stat:hover strong {
|
||||
|
||||
/* 文章编辑器 */
|
||||
.article-editor {
|
||||
flex: 1;
|
||||
flex: 1 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
border-top: 1px solid var(--j13-border-light);
|
||||
}
|
||||
|
||||
@@ -6407,20 +6481,25 @@ button.profile-stat:hover strong {
|
||||
}
|
||||
|
||||
.article-editor-body {
|
||||
flex: 1;
|
||||
flex: 1 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
/* 编辑面板 */
|
||||
.article-editor-pane {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex: 1 0 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
min-height: 320px;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
/* 普通模式随正文增高,由外层 .main-content--compose 滚动 */
|
||||
overflow: visible;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -6457,9 +6536,11 @@ button.profile-stat:hover strong {
|
||||
}
|
||||
|
||||
.article-editor-scroll {
|
||||
flex: 1;
|
||||
flex: 1 0 auto;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@@ -6476,11 +6557,32 @@ button.profile-stat:hover strong {
|
||||
.article-editor-content .tiptap,
|
||||
.article-prosemirror {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
min-height: 360px;
|
||||
padding: 16px 24px 32px;
|
||||
outline: none;
|
||||
font-size: 15.5px;
|
||||
line-height: 1.7;
|
||||
/* 长中英混排/表格单元格:强制在容器内换行,不撑破白底 */
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* 编辑器内表格:允许单元格换行,过宽时在编辑区内横滑 */
|
||||
.article-prosemirror.post-detail-content table,
|
||||
.article-prosemirror table {
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.article-prosemirror.post-detail-content th,
|
||||
.article-prosemirror.post-detail-content td,
|
||||
.article-prosemirror th,
|
||||
.article-prosemirror td {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* 源码双栏:充分利用加宽画布 */
|
||||
@@ -6902,6 +7004,13 @@ button.profile-stat:hover strong {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.article-editor--fullscreen .article-editor-pane,
|
||||
.article-editor--fullscreen .article-editor-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* 全屏富文本:编辑区居中,宽度对齐帖子正文阅读区 */
|
||||
.article-editor--fullscreen.article-editor--rich .article-editor-body {
|
||||
overflow-y: auto;
|
||||
|
||||
34
frontend/src/utils/chunkLoad.ts
Normal file
34
frontend/src/utils/chunkLoad.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/** sessionStorage:短时内只自动刷新一次,避免死循环 */
|
||||
const RELOAD_AT_KEY = 'j13:chunk-reload-at';
|
||||
const RELOAD_COOLDOWN_MS = 15_000;
|
||||
|
||||
/** 判断是否为「发版后旧 JS chunk 失效」类错误 */
|
||||
export function isChunkLoadError(error: unknown): boolean {
|
||||
const msg = error instanceof Error
|
||||
? `${error.name} ${error.message}`
|
||||
: String(error ?? '');
|
||||
return /Failed to fetch dynamically imported module/i.test(msg)
|
||||
|| /error loading dynamically imported module/i.test(msg)
|
||||
|| /Importing a module script failed/i.test(msg)
|
||||
|| /Loading chunk [\w-]+ failed/i.test(msg)
|
||||
|| /ChunkLoadError/i.test(msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发版后旧页面仍引用已删除的 hashed chunk 时,整页刷新以拉取新 index.html。
|
||||
* @returns 是否已触发刷新(调用方应暂停渲染)
|
||||
*/
|
||||
export function reloadForStaleChunk(): boolean {
|
||||
try {
|
||||
const prev = Number(sessionStorage.getItem(RELOAD_AT_KEY) || 0);
|
||||
const now = Date.now();
|
||||
if (now - prev < RELOAD_COOLDOWN_MS) {
|
||||
return false;
|
||||
}
|
||||
sessionStorage.setItem(RELOAD_AT_KEY, String(now));
|
||||
} catch {
|
||||
// sessionStorage 不可用时仍尝试刷新一次
|
||||
}
|
||||
window.location.reload();
|
||||
return true;
|
||||
}
|
||||
25
frontend/src/utils/lazyWithRetry.ts
Normal file
25
frontend/src/utils/lazyWithRetry.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { lazy, type ComponentType, type LazyExoticComponent } from 'react';
|
||||
import { isChunkLoadError, reloadForStaleChunk } from './chunkLoad';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type AnyComponent = ComponentType<any>;
|
||||
|
||||
/**
|
||||
* 带发版容错的 React.lazy:动态 import 失败时自动整页刷新一次。
|
||||
* (部署后 hashed chunk 更名,旧标签页点到懒加载路由时常见)
|
||||
*/
|
||||
export function lazyWithRetry<T extends AnyComponent>(
|
||||
factory: () => Promise<{ default: T }>,
|
||||
): LazyExoticComponent<T> {
|
||||
return lazy(async () => {
|
||||
try {
|
||||
return await factory();
|
||||
} catch (err) {
|
||||
if (isChunkLoadError(err) && reloadForStaleChunk()) {
|
||||
// 刷新进行中,挂起 Promise,避免再抛到错误页闪一下
|
||||
return new Promise<{ default: T }>(() => {});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user