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 (
+
+ );
+};
+
+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)}
+
+
+ ))}
)
) : (