♻️ refactor: Extract scroll effect logic into reusable ScrollableContainer component

- 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
This commit is contained in:
t0ng7u
2025-07-20 12:51:18 +08:00
parent 4d7562fd79
commit b5d4535db6
2 changed files with 282 additions and 260 deletions

View File

@@ -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 <https://www.gnu.org/licenses/>.
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 (
<div
className={`card-content-container ${className}`}
{...props}
>
<div
ref={scrollRef}
className={`overflow-y-auto card-content-scroll ${contentClassName}`}
style={{ maxHeight }}
onScroll={handleScroll}
>
{children}
</div>
<div
className={`card-content-fade-indicator ${fadeIndicatorClassName}`}
style={{ opacity: showScrollHint ? 1 : 0 }}
/>
</div>
);
};
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;

View File

@@ -40,6 +40,7 @@ import {
Divider, Divider,
Skeleton Skeleton
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import ScrollableContainer from '../../components/common/ui/ScrollableContainer';
import { import {
IconRefresh, IconRefresh,
IconSearch, IconSearch,
@@ -91,7 +92,6 @@ const Detail = (props) => {
// ========== Hooks - Refs ========== // ========== Hooks - Refs ==========
const formRef = useRef(); const formRef = useRef();
const initialized = useRef(false); const initialized = useRef(false);
const apiScrollRef = useRef(null);
// ========== Constants & Shared Configurations ========== // ========== Constants & Shared Configurations ==========
const CHART_CONFIG = { mode: 'desktop-browser' }; const CHART_CONFIG = { mode: 'desktop-browser' };
@@ -224,7 +224,6 @@ const Detail = (props) => {
const [modelColors, setModelColors] = useState({}); const [modelColors, setModelColors] = useState({});
const [activeChartTab, setActiveChartTab] = useState('1'); const [activeChartTab, setActiveChartTab] = useState('1');
const [showApiScrollHint, setShowApiScrollHint] = useState(false);
const [searchModalVisible, setSearchModalVisible] = useState(false); const [searchModalVisible, setSearchModalVisible] = useState(false);
const [trendData, setTrendData] = useState({ const [trendData, setTrendData] = useState({
@@ -238,16 +237,7 @@ const Detail = (props) => {
tpm: [] 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 ========== // ========== Uptime data ==========
const [uptimeData, setUptimeData] = useState([]); const [uptimeData, setUptimeData] = useState([]);
@@ -728,51 +718,9 @@ const Detail = (props) => {
setSearchModalVisible(false); 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(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
@@ -1360,12 +1308,7 @@ const Detail = (props) => {
} }
bodyStyle={{ padding: 0 }} bodyStyle={{ padding: 0 }}
> >
<div className="card-content-container"> <ScrollableContainer maxHeight="24rem">
<div
ref={apiScrollRef}
className="p-2 max-h-96 overflow-y-auto card-content-scroll"
onScroll={handleApiScroll}
>
{apiInfoData.length > 0 ? ( {apiInfoData.length > 0 ? (
apiInfoData.map((api) => ( apiInfoData.map((api) => (
<> <>
@@ -1430,12 +1373,7 @@ const Detail = (props) => {
/> />
</div> </div>
)} )}
</div> </ScrollableContainer>
<div
className="card-content-fade-indicator"
style={{ opacity: showApiScrollHint ? 1 : 0 }}
/>
</div>
</Card> </Card>
)} )}
</div> </div>
@@ -1482,12 +1420,7 @@ const Detail = (props) => {
} }
bodyStyle={{ padding: 0 }} bodyStyle={{ padding: 0 }}
> >
<div className="card-content-container"> <ScrollableContainer maxHeight="24rem">
<div
ref={announcementScrollRef}
className="p-2 max-h-96 overflow-y-auto card-content-scroll"
onScroll={() => handleCardScroll(announcementScrollRef, setShowAnnouncementScrollHint)}
>
{announcementData.length > 0 ? ( {announcementData.length > 0 ? (
<Timeline mode="alternate"> <Timeline mode="alternate">
{announcementData.map((item, idx) => ( {announcementData.map((item, idx) => (
@@ -1520,12 +1453,7 @@ const Detail = (props) => {
/> />
</div> </div>
)} )}
</div> </ScrollableContainer>
<div
className="card-content-fade-indicator"
style={{ opacity: showAnnouncementScrollHint ? 1 : 0 }}
/>
</div>
</Card> </Card>
)} )}
@@ -1542,12 +1470,7 @@ const Detail = (props) => {
} }
bodyStyle={{ padding: 0 }} bodyStyle={{ padding: 0 }}
> >
<div className="card-content-container"> <ScrollableContainer maxHeight="24rem">
<div
ref={faqScrollRef}
className="p-2 max-h-96 overflow-y-auto card-content-scroll"
onScroll={() => handleCardScroll(faqScrollRef, setShowFaqScrollHint)}
>
{faqData.length > 0 ? ( {faqData.length > 0 ? (
<Collapse <Collapse
accordion accordion
@@ -1576,12 +1499,7 @@ const Detail = (props) => {
/> />
</div> </div>
)} )}
</div> </ScrollableContainer>
<div
className="card-content-fade-indicator"
style={{ opacity: showFaqScrollHint ? 1 : 0 }}
/>
</div>
</Card> </Card>
)} )}
@@ -1614,19 +1532,9 @@ const Detail = (props) => {
<Spin spinning={uptimeLoading}> <Spin spinning={uptimeLoading}>
{uptimeData.length > 0 ? ( {uptimeData.length > 0 ? (
uptimeData.length === 1 ? ( uptimeData.length === 1 ? (
<div className="card-content-container"> <ScrollableContainer maxHeight="24rem">
<div
ref={uptimeScrollRef}
className="p-2 max-h-[24rem] overflow-y-auto card-content-scroll"
onScroll={() => handleCardScroll(uptimeScrollRef, setShowUptimeScrollHint)}
>
{renderMonitorList(uptimeData[0].monitors)} {renderMonitorList(uptimeData[0].monitors)}
</div> </ScrollableContainer>
<div
className="card-content-fade-indicator"
style={{ opacity: showUptimeScrollHint ? 1 : 0 }}
/>
</div>
) : ( ) : (
<Tabs <Tabs
type="card" type="card"
@@ -1635,13 +1543,7 @@ const Detail = (props) => {
onChange={setActiveUptimeTab} onChange={setActiveUptimeTab}
size="small" size="small"
> >
{uptimeData.map((group, groupIdx) => { {uptimeData.map((group, groupIdx) => (
if (!uptimeTabScrollRefs.current[group.categoryName]) {
uptimeTabScrollRefs.current[group.categoryName] = React.createRef();
}
const tabScrollRef = uptimeTabScrollRefs.current[group.categoryName];
return (
<TabPane <TabPane
tab={ tab={
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
@@ -1659,22 +1561,11 @@ const Detail = (props) => {
itemKey={group.categoryName} itemKey={group.categoryName}
key={groupIdx} key={groupIdx}
> >
<div className="card-content-container"> <ScrollableContainer maxHeight="21.5rem">
<div
ref={tabScrollRef}
className="p-2 max-h-[21.5rem] overflow-y-auto card-content-scroll"
onScroll={() => handleCardScroll(tabScrollRef, setShowUptimeScrollHint)}
>
{renderMonitorList(group.monitors)} {renderMonitorList(group.monitors)}
</div> </ScrollableContainer>
<div
className="card-content-fade-indicator"
style={{ opacity: activeUptimeTab === group.categoryName ? showUptimeScrollHint ? 1 : 0 : 0 }}
/>
</div>
</TabPane> </TabPane>
); ))}
})}
</Tabs> </Tabs>
) )
) : ( ) : (