增加首页上拉刷新
This commit is contained in:
96
src/App.tsx
96
src/App.tsx
@@ -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 (
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
Reference in New Issue
Block a user