增加首页上拉刷新

This commit is contained in:
LeonspaceX
2025-11-19 19:11:06 +08:00
parent 4a98906fb9
commit e7cce50acb
2 changed files with 92 additions and 6 deletions

View File

@@ -1,4 +1,4 @@
// 你好。 // 你好感谢你愿意看源代码但是悄悄告诉你代码其实是AI写的所以质量很差喵。抱歉呜呜呜😭
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { FluentProvider, webLightTheme, webDarkTheme } from '@fluentui/react-components'; import { FluentProvider, webLightTheme, webDarkTheme } from '@fluentui/react-components';
@@ -8,7 +8,7 @@ import MainLayout from './layouts/MainLayout';
import './App.css'; import './App.css';
import { fetchArticles } from './api'; import { fetchArticles } from './api';
import CreatePost from './components/CreatePost'; import CreatePost from './components/CreatePost';
import { ToastContainer } from 'react-toastify'; import { ToastContainer, toast } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css'; import 'react-toastify/dist/ReactToastify.css';
import AboutPage from './components/AboutPage'; import AboutPage from './components/AboutPage';
import PostState from './components/PostState'; import PostState from './components/PostState';
@@ -28,7 +28,19 @@ function App() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const [homeRefreshTick, setHomeRefreshTick] = useState(0);
const [refreshing, setRefreshing] = useState(false);
const observer = useRef<IntersectionObserver>(null); const observer = useRef<IntersectionObserver>(null);
const containerRef = useRef<HTMLDivElement>(null);
const touchStartYRef = useRef<number | null>(null);
const pullDeltaRef = useRef<number>(0);
const lastRefreshAtRef = useRef<number>(0);
const [pullOffset, setPullOffset] = useState(0);
const [offsetAnimated, setOffsetAnimated] = useState(false);
const MAX_PULL = 80; // 最大下拉位移
const TRIGGER_PULL = 60; // 触发刷新的阈值
const DAMPING = 0.5; // 阻尼系数,减少位移幅度
const REFRESH_COOLDOWN_MS = 5000; // 刷新冷却时间
const lastArticleRef = useCallback((node: HTMLDivElement) => { const lastArticleRef = useCallback((node: HTMLDivElement) => {
if (loading) return; if (loading) return;
@@ -41,6 +53,61 @@ function App() {
if (node) observer.current.observe(node); if (node) observer.current.observe(node);
}, [loading, hasMore]); }, [loading, hasMore]);
const doRefresh = () => {
if (refreshing || loading) return;
const now = Date.now();
if (now - lastRefreshAtRef.current < REFRESH_COOLDOWN_MS) return;
lastRefreshAtRef.current = now;
setRefreshing(true);
setArticles([]);
setHasMore(true);
setPage(1);
setHomeRefreshTick((t) => t + 1);
if (containerRef.current) containerRef.current.scrollTop = 0;
};
const onTouchStart: React.TouchEventHandler<HTMLDivElement> = (e) => {
touchStartYRef.current = e.touches[0]?.clientY ?? null;
pullDeltaRef.current = 0;
setOffsetAnimated(false);
};
const onTouchMove: React.TouchEventHandler<HTMLDivElement> = (e) => {
const startY = touchStartYRef.current;
if (startY == null) return;
const currentY = e.touches[0]?.clientY ?? startY;
const rawDelta = currentY - startY;
pullDeltaRef.current = rawDelta;
const atTop = (containerRef.current?.scrollTop ?? 0) <= 0;
if (atTop && rawDelta > 0 && !loading && !refreshing) {
const offset = Math.min(MAX_PULL, rawDelta * DAMPING);
setPullOffset(offset);
} else {
setPullOffset(0);
}
};
const onTouchEnd: React.TouchEventHandler<HTMLDivElement> = () => {
const atTop = (containerRef.current?.scrollTop ?? 0) <= 0;
const shouldRefresh = atTop && pullOffset >= TRIGGER_PULL;
setOffsetAnimated(true);
setPullOffset(0);
if (shouldRefresh) {
doRefresh();
}
touchStartYRef.current = null;
pullDeltaRef.current = 0;
// 结束后移除动画标记,下一次拖动为无动画的跟随效果
setTimeout(() => setOffsetAnimated(false), 220);
};
const onWheel: React.WheelEventHandler<HTMLDivElement> = (e) => {
const atTop = (containerRef.current?.scrollTop ?? 0) <= 0;
if (atTop && e.deltaY < 0) {
doRefresh();
}
};
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
const signal = controller.signal; const signal = controller.signal;
@@ -61,6 +128,10 @@ function App() {
} }
} finally { } finally {
setLoading(false); setLoading(false);
if (refreshing) {
setRefreshing(false);
toast.success('刷新成功!');
}
} }
}; };
loadArticles(); loadArticles();
@@ -68,7 +139,7 @@ function App() {
return () => { return () => {
controller.abort(); controller.abort();
}; };
}, [page, hasMore]); }, [page, hasMore, homeRefreshTick]);
return ( return (
<FluentProvider theme={isDarkMode ? webDarkTheme : webLightTheme}> <FluentProvider theme={isDarkMode ? webDarkTheme : webLightTheme}>
@@ -78,8 +149,23 @@ function App() {
<Route <Route
index index
element={ element={
<div style={{ width: '100%', height: 'calc(100vh - 64px)', overflowY: 'auto', padding: '20px' }}> <div
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', minHeight: '100%' }}> style={{ width: '100%', height: 'calc(100vh - 64px)', overflowY: 'auto', padding: '20px' }}
ref={containerRef}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onWheel={onWheel}
>
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
minHeight: '100%',
transform: `translateY(${pullOffset}px)`,
transition: offsetAnimated ? 'transform 200ms ease' : 'none'
}}>
{/* 刷新提示改为 toast不显示顶部灰字 */}
{articles.map((article, index) => { {articles.map((article, index) => {
if (articles.length === index + 1 && hasMore) { if (articles.length === index + 1 && hasMore) {
return ( return (

View File

@@ -84,7 +84,7 @@ const InitPage: React.FC = () => {
<Input value={adminToken} onChange={(_, v) => setAdminToken(v?.value || '')} placeholder="请输入管理员令牌" /> <Input value={adminToken} onChange={(_, v) => setAdminToken(v?.value || '')} placeholder="请输入管理员令牌" />
</Field> </Field>
<Field label="上传目录"> <Field label="上传目录">
<Input value={uploadFolder} onChange={(_, v) => setUploadFolder(v?.value || '')} placeholder="例如:img" /> <Input value={uploadFolder} onChange={(_, v) => setUploadFolder(v?.value || '')} placeholder="建议使用img" />
</Field> </Field>
<Field label="允许扩展名 (逗号分隔)"> <Field label="允许扩展名 (逗号分隔)">
<Input value={allowedExtensions} onChange={(_, v) => setAllowedExtensions(v?.value || '')} placeholder="png,jpg,jpeg,gif,webp" /> <Input value={allowedExtensions} onChange={(_, v) => setAllowedExtensions(v?.value || '')} placeholder="png,jpg,jpeg,gif,webp" />