From b5d4535db6b1c0da2f09f1c88c601d7c87f0b0ff Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sun, 20 Jul 2025 12:51:18 +0800 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20Extract=20scro?= =?UTF-8?q?ll=20effect=20logic=20into=20reusable=20ScrollableContainer=20c?= =?UTF-8?q?omponent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create new ScrollableContainer component in @/components/common/ui - Provides automatic scroll detection and fade indicator - Supports customizable height, styling, and event callbacks - Includes comprehensive PropTypes for type safety - Optimized with useCallback for better performance - Refactor Detail page to use ScrollableContainer - Remove manual scroll detection functions (checkApiScrollable, checkCardScrollable) - Remove scroll event handlers (handleApiScroll, handleCardScroll) - Remove scroll-related refs and state variables - Replace all card scroll containers with ScrollableContainer component * API info card * System announcements card * FAQ card * Uptime monitoring card (both single and multi-tab scenarios) - Benefits: - Improved code reusability and maintainability - Reduced code duplication across components - Consistent scroll behavior throughout the application - Easier to maintain and extend scroll functionality Breaking changes: None Migration: Existing scroll behavior is preserved with no user-facing changes --- .../common/ui/ScrollableContainer.js | 131 ++++++ web/src/pages/Detail/index.js | 411 +++++++----------- 2 files changed, 282 insertions(+), 260 deletions(-) create mode 100644 web/src/components/common/ui/ScrollableContainer.js diff --git a/web/src/components/common/ui/ScrollableContainer.js b/web/src/components/common/ui/ScrollableContainer.js new file mode 100644 index 00000000..f8c65b1f --- /dev/null +++ b/web/src/components/common/ui/ScrollableContainer.js @@ -0,0 +1,131 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useRef, useState, useEffect, useCallback } from 'react'; +import PropTypes from 'prop-types'; + +/** + * ScrollableContainer 可滚动容器组件 + * + * 提供自动检测滚动状态和显示渐变指示器的功能 + * 当内容超出容器高度且未滚动到底部时,会显示底部渐变指示器 + */ +const ScrollableContainer = ({ + children, + maxHeight = '24rem', + className = '', + contentClassName = 'p-2', + fadeIndicatorClassName = '', + checkInterval = 100, + scrollThreshold = 5, + onScroll, + onScrollStateChange, + ...props +}) => { + const scrollRef = useRef(null); + const [showScrollHint, setShowScrollHint] = useState(false); + + // 检查是否可滚动且未滚动到底部 + const checkScrollable = useCallback(() => { + if (scrollRef.current) { + const element = scrollRef.current; + const isScrollable = element.scrollHeight > element.clientHeight; + const isAtBottom = element.scrollTop + element.clientHeight >= element.scrollHeight - scrollThreshold; + const shouldShowHint = isScrollable && !isAtBottom; + + setShowScrollHint(shouldShowHint); + + // 通知父组件滚动状态变化 + if (onScrollStateChange) { + onScrollStateChange({ + isScrollable, + isAtBottom, + showScrollHint: shouldShowHint, + scrollTop: element.scrollTop, + scrollHeight: element.scrollHeight, + clientHeight: element.clientHeight + }); + } + } + }, [scrollThreshold, onScrollStateChange]); + + // 处理滚动事件 + const handleScroll = useCallback((e) => { + checkScrollable(); + if (onScroll) { + onScroll(e); + } + }, [checkScrollable, onScroll]); + + // 初始检查和内容变化时检查 + useEffect(() => { + const timer = setTimeout(() => { + checkScrollable(); + }, checkInterval); + return () => clearTimeout(timer); + }, [children, checkScrollable, checkInterval]); + + // 暴露检查方法给父组件 + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.checkScrollable = checkScrollable; + } + }, [checkScrollable]); + + return ( +
+
+ {children} +
+
+
+ ); +}; + +ScrollableContainer.propTypes = { + // 子组件内容 + children: PropTypes.node.isRequired, + + // 样式相关 + maxHeight: PropTypes.string, + className: PropTypes.string, + contentClassName: PropTypes.string, + fadeIndicatorClassName: PropTypes.string, + + // 行为配置 + checkInterval: PropTypes.number, + scrollThreshold: PropTypes.number, + + // 事件回调 + onScroll: PropTypes.func, + onScrollStateChange: PropTypes.func, +}; + +export default ScrollableContainer; \ No newline at end of file diff --git a/web/src/pages/Detail/index.js b/web/src/pages/Detail/index.js index 76625424..0a725209 100644 --- a/web/src/pages/Detail/index.js +++ b/web/src/pages/Detail/index.js @@ -40,6 +40,7 @@ import { Divider, Skeleton } from '@douyinfe/semi-ui'; +import ScrollableContainer from '../../components/common/ui/ScrollableContainer'; import { IconRefresh, IconSearch, @@ -91,7 +92,6 @@ const Detail = (props) => { // ========== Hooks - Refs ========== const formRef = useRef(); const initialized = useRef(false); - const apiScrollRef = useRef(null); // ========== Constants & Shared Configurations ========== const CHART_CONFIG = { mode: 'desktop-browser' }; @@ -224,7 +224,6 @@ const Detail = (props) => { const [modelColors, setModelColors] = useState({}); const [activeChartTab, setActiveChartTab] = useState('1'); - const [showApiScrollHint, setShowApiScrollHint] = useState(false); const [searchModalVisible, setSearchModalVisible] = useState(false); const [trendData, setTrendData] = useState({ @@ -238,16 +237,7 @@ const Detail = (props) => { tpm: [] }); - // ========== Additional Refs for new cards ========== - const announcementScrollRef = useRef(null); - const faqScrollRef = useRef(null); - const uptimeScrollRef = useRef(null); - const uptimeTabScrollRefs = useRef({}); - // ========== Additional State for scroll hints ========== - const [showAnnouncementScrollHint, setShowAnnouncementScrollHint] = useState(false); - const [showFaqScrollHint, setShowFaqScrollHint] = useState(false); - const [showUptimeScrollHint, setShowUptimeScrollHint] = useState(false); // ========== Uptime data ========== const [uptimeData, setUptimeData] = useState([]); @@ -728,51 +718,9 @@ const Detail = (props) => { setSearchModalVisible(false); }, []); - // ========== Regular Functions ========== - const checkApiScrollable = () => { - if (apiScrollRef.current) { - const element = apiScrollRef.current; - const isScrollable = element.scrollHeight > element.clientHeight; - const isAtBottom = element.scrollTop + element.clientHeight >= element.scrollHeight - 5; - setShowApiScrollHint(isScrollable && !isAtBottom); - } - }; - const handleApiScroll = () => { - checkApiScrollable(); - }; - const checkCardScrollable = (ref, setHintFunction) => { - if (ref.current) { - const element = ref.current; - const isScrollable = element.scrollHeight > element.clientHeight; - const isAtBottom = element.scrollTop + element.clientHeight >= element.scrollHeight - 5; - setHintFunction(isScrollable && !isAtBottom); - } - }; - const handleCardScroll = (ref, setHintFunction) => { - checkCardScrollable(ref, setHintFunction); - }; - - // ========== Effects for scroll detection ========== - useEffect(() => { - const timer = setTimeout(() => { - checkApiScrollable(); - checkCardScrollable(announcementScrollRef, setShowAnnouncementScrollHint); - checkCardScrollable(faqScrollRef, setShowFaqScrollHint); - - if (uptimeData.length === 1) { - checkCardScrollable(uptimeScrollRef, setShowUptimeScrollHint); - } else if (uptimeData.length > 1 && activeUptimeTab) { - const activeTabRef = uptimeTabScrollRefs.current[activeUptimeTab]; - if (activeTabRef) { - checkCardScrollable(activeTabRef, setShowUptimeScrollHint); - } - } - }, 100); - return () => clearTimeout(timer); - }, [uptimeData, activeUptimeTab]); useEffect(() => { const timer = setTimeout(() => { @@ -1360,82 +1308,72 @@ const Detail = (props) => { } bodyStyle={{ padding: 0 }} > -
-
- {apiInfoData.length > 0 ? ( - apiInfoData.map((api) => ( - <> -
-
- - {api.route.substring(0, 2)} - + + {apiInfoData.length > 0 ? ( + apiInfoData.map((api) => ( + <> +
+
+ + {api.route.substring(0, 2)} + +
+
+
+ + {api.route} + +
+ } + size="small" + color="white" + shape='circle' + onClick={() => handleSpeedTest(api.url)} + className="cursor-pointer hover:opacity-80 text-xs" + > + {t('测速')} + + } + size="small" + color="white" + shape='circle' + onClick={() => window.open(api.url, '_blank', 'noopener,noreferrer')} + className="cursor-pointer hover:opacity-80 text-xs" + > + {t('跳转')} + +
-
-
- - {api.route} - -
- } - size="small" - color="white" - shape='circle' - onClick={() => handleSpeedTest(api.url)} - className="cursor-pointer hover:opacity-80 text-xs" - > - {t('测速')} - - } - size="small" - color="white" - shape='circle' - onClick={() => window.open(api.url, '_blank', 'noopener,noreferrer')} - className="cursor-pointer hover:opacity-80 text-xs" - > - {t('跳转')} - -
-
-
handleCopyUrl(api.url)} - > - {api.url} -
-
- {api.description} -
+
handleCopyUrl(api.url)} + > + {api.url} +
+
+ {api.description}
- - - )) - ) : ( -
- } - darkModeImage={} - title={t('暂无API信息')} - description={t('请联系管理员在系统设置中配置API信息')} - /> -
- )} -
-
-
+
+ + + )) + ) : ( +
+ } + darkModeImage={} + title={t('暂无API信息')} + description={t('请联系管理员在系统设置中配置API信息')} + /> +
+ )} +
)}
@@ -1482,50 +1420,40 @@ const Detail = (props) => { } bodyStyle={{ padding: 0 }} > -
-
handleCardScroll(announcementScrollRef, setShowAnnouncementScrollHint)} - > - {announcementData.length > 0 ? ( - - {announcementData.map((item, idx) => ( - -
+ + {announcementData.length > 0 ? ( + + {announcementData.map((item, idx) => ( + +
+
+ {item.extra && (
- {item.extra && ( -
- )} -
- - ))} - - ) : ( -
- } - darkModeImage={} - title={t('暂无系统公告')} - description={t('请联系管理员在系统设置中配置公告信息')} - /> -
- )} -
-
-
+ )} +
+ + ))} + + ) : ( +
+ } + darkModeImage={} + title={t('暂无系统公告')} + description={t('请联系管理员在系统设置中配置公告信息')} + /> +
+ )} + )} @@ -1542,46 +1470,36 @@ const Detail = (props) => { } bodyStyle={{ padding: 0 }} > -
-
handleCardScroll(faqScrollRef, setShowFaqScrollHint)} - > - {faqData.length > 0 ? ( - } - collapseIcon={} - > - {faqData.map((item, index) => ( - -
- - ))} - - ) : ( -
- } - darkModeImage={} - title={t('暂无常见问答')} - description={t('请联系管理员在系统设置中配置常见问答')} - /> -
- )} -
-
-
+ + {faqData.length > 0 ? ( + } + collapseIcon={} + > + {faqData.map((item, index) => ( + +
+ + ))} + + ) : ( +
+ } + darkModeImage={} + title={t('暂无常见问答')} + description={t('请联系管理员在系统设置中配置常见问答')} + /> +
+ )} + )} @@ -1614,19 +1532,9 @@ const Detail = (props) => { {uptimeData.length > 0 ? ( uptimeData.length === 1 ? ( -
-
handleCardScroll(uptimeScrollRef, setShowUptimeScrollHint)} - > - {renderMonitorList(uptimeData[0].monitors)} -
-
-
+ + {renderMonitorList(uptimeData[0].monitors)} + ) : ( { onChange={setActiveUptimeTab} size="small" > - {uptimeData.map((group, groupIdx) => { - if (!uptimeTabScrollRefs.current[group.categoryName]) { - uptimeTabScrollRefs.current[group.categoryName] = React.createRef(); - } - const tabScrollRef = uptimeTabScrollRefs.current[group.categoryName]; - - return ( - - - {group.categoryName} - - {group.monitors ? group.monitors.length : 0} - - - } - itemKey={group.categoryName} - key={groupIdx} - > -
-
handleCardScroll(tabScrollRef, setShowUptimeScrollHint)} + {uptimeData.map((group, groupIdx) => ( + + + {group.categoryName} + - {renderMonitorList(group.monitors)} -
-
-
- - ); - })} + {group.monitors ? group.monitors.length : 0} + + + } + itemKey={group.categoryName} + key={groupIdx} + > + + {renderMonitorList(group.monitors)} + + + ))} ) ) : (