فهرست منبع

♻️ 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
t0ng7u 7 ماه پیش
والد
کامیت
b5d4535db6
2فایلهای تغییر یافته به همراه282 افزوده شده و 260 حذف شده
  1. 131 0
      web/src/components/common/ui/ScrollableContainer.js
  2. 151 260
      web/src/pages/Detail/index.js

+ 131 - 0
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 <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; 

+ 151 - 260
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 }}
             >
-              <div className="card-content-container">
-                <div
-                  ref={apiScrollRef}
-                  className="p-2 max-h-96 overflow-y-auto card-content-scroll"
-                  onScroll={handleApiScroll}
-                >
-                  {apiInfoData.length > 0 ? (
-                    apiInfoData.map((api) => (
-                      <>
-                        <div key={api.id} className="flex p-2 hover:bg-white rounded-lg transition-colors cursor-pointer">
-                          <div className="flex-shrink-0 mr-3">
-                            <Avatar
-                              size="extra-small"
-                              color={api.color}
-                            >
-                              {api.route.substring(0, 2)}
-                            </Avatar>
-                          </div>
-                          <div className="flex-1">
-                            <div className="flex flex-wrap items-center justify-between mb-1 w-full gap-2">
-                              <span className="text-sm font-medium text-gray-900 !font-bold break-all">
-                                {api.route}
-                              </span>
-                              <div className="flex items-center gap-1 mt-1 lg:mt-0">
-                                <Tag
-                                  prefixIcon={<Gauge size={12} />}
-                                  size="small"
-                                  color="white"
-                                  shape='circle'
-                                  onClick={() => handleSpeedTest(api.url)}
-                                  className="cursor-pointer hover:opacity-80 text-xs"
-                                >
-                                  {t('测速')}
-                                </Tag>
-                                <Tag
-                                  prefixIcon={<ExternalLink size={12} />}
-                                  size="small"
-                                  color="white"
-                                  shape='circle'
-                                  onClick={() => window.open(api.url, '_blank', 'noopener,noreferrer')}
-                                  className="cursor-pointer hover:opacity-80 text-xs"
-                                >
-                                  {t('跳转')}
-                                </Tag>
-                              </div>
-                            </div>
-                            <div
-                              className="!text-semi-color-primary break-all cursor-pointer hover:underline mb-1"
-                              onClick={() => handleCopyUrl(api.url)}
-                            >
-                              {api.url}
-                            </div>
-                            <div className="text-gray-500">
-                              {api.description}
+              <ScrollableContainer maxHeight="24rem">
+                {apiInfoData.length > 0 ? (
+                  apiInfoData.map((api) => (
+                    <>
+                      <div key={api.id} className="flex p-2 hover:bg-white rounded-lg transition-colors cursor-pointer">
+                        <div className="flex-shrink-0 mr-3">
+                          <Avatar
+                            size="extra-small"
+                            color={api.color}
+                          >
+                            {api.route.substring(0, 2)}
+                          </Avatar>
+                        </div>
+                        <div className="flex-1">
+                          <div className="flex flex-wrap items-center justify-between mb-1 w-full gap-2">
+                            <span className="text-sm font-medium text-gray-900 !font-bold break-all">
+                              {api.route}
+                            </span>
+                            <div className="flex items-center gap-1 mt-1 lg:mt-0">
+                              <Tag
+                                prefixIcon={<Gauge size={12} />}
+                                size="small"
+                                color="white"
+                                shape='circle'
+                                onClick={() => handleSpeedTest(api.url)}
+                                className="cursor-pointer hover:opacity-80 text-xs"
+                              >
+                                {t('测速')}
+                              </Tag>
+                              <Tag
+                                prefixIcon={<ExternalLink size={12} />}
+                                size="small"
+                                color="white"
+                                shape='circle'
+                                onClick={() => window.open(api.url, '_blank', 'noopener,noreferrer')}
+                                className="cursor-pointer hover:opacity-80 text-xs"
+                              >
+                                {t('跳转')}
+                              </Tag>
                             </div>
                           </div>
+                          <div
+                            className="!text-semi-color-primary break-all cursor-pointer hover:underline mb-1"
+                            onClick={() => handleCopyUrl(api.url)}
+                          >
+                            {api.url}
+                          </div>
+                          <div className="text-gray-500">
+                            {api.description}
+                          </div>
                         </div>
-                        <Divider />
-                      </>
-                    ))
-                  ) : (
-                    <div className="flex justify-center items-center py-8">
-                      <Empty
-                        image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
-                        darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
-                        title={t('暂无API信息')}
-                        description={t('请联系管理员在系统设置中配置API信息')}
-                      />
-                    </div>
-                  )}
-                </div>
-                <div
-                  className="card-content-fade-indicator"
-                  style={{ opacity: showApiScrollHint ? 1 : 0 }}
-                />
-              </div>
+                      </div>
+                      <Divider />
+                    </>
+                  ))
+                ) : (
+                  <div className="flex justify-center items-center py-8">
+                    <Empty
+                      image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
+                      darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
+                      title={t('暂无API信息')}
+                      description={t('请联系管理员在系统设置中配置API信息')}
+                    />
+                  </div>
+                )}
+              </ScrollableContainer>
             </Card>
           )}
         </div>
@@ -1482,50 +1420,40 @@ const Detail = (props) => {
                   }
                   bodyStyle={{ padding: 0 }}
                 >
-                  <div className="card-content-container">
-                    <div
-                      ref={announcementScrollRef}
-                      className="p-2 max-h-96 overflow-y-auto card-content-scroll"
-                      onScroll={() => handleCardScroll(announcementScrollRef, setShowAnnouncementScrollHint)}
-                    >
-                      {announcementData.length > 0 ? (
-                        <Timeline mode="alternate">
-                          {announcementData.map((item, idx) => (
-                            <Timeline.Item
-                              key={idx}
-                              type={item.type || 'default'}
-                              time={item.time}
-                            >
-                              <div>
+                  <ScrollableContainer maxHeight="24rem">
+                    {announcementData.length > 0 ? (
+                      <Timeline mode="alternate">
+                        {announcementData.map((item, idx) => (
+                          <Timeline.Item
+                            key={idx}
+                            type={item.type || 'default'}
+                            time={item.time}
+                          >
+                            <div>
+                              <div
+                                dangerouslySetInnerHTML={{ __html: marked.parse(item.content || '') }}
+                              />
+                              {item.extra && (
                                 <div
-                                  dangerouslySetInnerHTML={{ __html: marked.parse(item.content || '') }}
+                                  className="text-xs text-gray-500"
+                                  dangerouslySetInnerHTML={{ __html: marked.parse(item.extra) }}
                                 />
-                                {item.extra && (
-                                  <div
-                                    className="text-xs text-gray-500"
-                                    dangerouslySetInnerHTML={{ __html: marked.parse(item.extra) }}
-                                  />
-                                )}
-                              </div>
-                            </Timeline.Item>
-                          ))}
-                        </Timeline>
-                      ) : (
-                        <div className="flex justify-center items-center py-8">
-                          <Empty
-                            image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
-                            darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
-                            title={t('暂无系统公告')}
-                            description={t('请联系管理员在系统设置中配置公告信息')}
-                          />
-                        </div>
-                      )}
-                    </div>
-                    <div
-                      className="card-content-fade-indicator"
-                      style={{ opacity: showAnnouncementScrollHint ? 1 : 0 }}
-                    />
-                  </div>
+                              )}
+                            </div>
+                          </Timeline.Item>
+                        ))}
+                      </Timeline>
+                    ) : (
+                      <div className="flex justify-center items-center py-8">
+                        <Empty
+                          image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
+                          darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
+                          title={t('暂无系统公告')}
+                          description={t('请联系管理员在系统设置中配置公告信息')}
+                        />
+                      </div>
+                    )}
+                  </ScrollableContainer>
                 </Card>
               )}
 
@@ -1542,46 +1470,36 @@ const Detail = (props) => {
                   }
                   bodyStyle={{ padding: 0 }}
                 >
-                  <div className="card-content-container">
-                    <div
-                      ref={faqScrollRef}
-                      className="p-2 max-h-96 overflow-y-auto card-content-scroll"
-                      onScroll={() => handleCardScroll(faqScrollRef, setShowFaqScrollHint)}
-                    >
-                      {faqData.length > 0 ? (
-                        <Collapse
-                          accordion
-                          expandIcon={<IconPlus />}
-                          collapseIcon={<IconMinus />}
-                        >
-                          {faqData.map((item, index) => (
-                            <Collapse.Panel
-                              key={index}
-                              header={item.question}
-                              itemKey={index.toString()}
-                            >
-                              <div
-                                dangerouslySetInnerHTML={{ __html: marked.parse(item.answer || '') }}
-                              />
-                            </Collapse.Panel>
-                          ))}
-                        </Collapse>
-                      ) : (
-                        <div className="flex justify-center items-center py-8">
-                          <Empty
-                            image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
-                            darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
-                            title={t('暂无常见问答')}
-                            description={t('请联系管理员在系统设置中配置常见问答')}
-                          />
-                        </div>
-                      )}
-                    </div>
-                    <div
-                      className="card-content-fade-indicator"
-                      style={{ opacity: showFaqScrollHint ? 1 : 0 }}
-                    />
-                  </div>
+                  <ScrollableContainer maxHeight="24rem">
+                    {faqData.length > 0 ? (
+                      <Collapse
+                        accordion
+                        expandIcon={<IconPlus />}
+                        collapseIcon={<IconMinus />}
+                      >
+                        {faqData.map((item, index) => (
+                          <Collapse.Panel
+                            key={index}
+                            header={item.question}
+                            itemKey={index.toString()}
+                          >
+                            <div
+                              dangerouslySetInnerHTML={{ __html: marked.parse(item.answer || '') }}
+                            />
+                          </Collapse.Panel>
+                        ))}
+                      </Collapse>
+                    ) : (
+                      <div className="flex justify-center items-center py-8">
+                        <Empty
+                          image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
+                          darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
+                          title={t('暂无常见问答')}
+                          description={t('请联系管理员在系统设置中配置常见问答')}
+                        />
+                      </div>
+                    )}
+                  </ScrollableContainer>
                 </Card>
               )}
 
@@ -1614,19 +1532,9 @@ const Detail = (props) => {
                     <Spin spinning={uptimeLoading}>
                       {uptimeData.length > 0 ? (
                         uptimeData.length === 1 ? (
-                          <div className="card-content-container">
-                            <div
-                              ref={uptimeScrollRef}
-                              className="p-2 max-h-[24rem] overflow-y-auto card-content-scroll"
-                              onScroll={() => handleCardScroll(uptimeScrollRef, setShowUptimeScrollHint)}
-                            >
-                              {renderMonitorList(uptimeData[0].monitors)}
-                            </div>
-                            <div
-                              className="card-content-fade-indicator"
-                              style={{ opacity: showUptimeScrollHint ? 1 : 0 }}
-                            />
-                          </div>
+                          <ScrollableContainer maxHeight="24rem">
+                            {renderMonitorList(uptimeData[0].monitors)}
+                          </ScrollableContainer>
                         ) : (
                           <Tabs
                             type="card"
@@ -1635,46 +1543,29 @@ const Detail = (props) => {
                             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 (
-                                <TabPane
-                                  tab={
-                                    <span className="flex items-center gap-2">
-                                      <Gauge size={14} />
-                                      {group.categoryName}
-                                      <Tag
-                                        color={activeUptimeTab === group.categoryName ? 'red' : 'grey'}
-                                        size='small'
-                                        shape='circle'
-                                      >
-                                        {group.monitors ? group.monitors.length : 0}
-                                      </Tag>
-                                    </span>
-                                  }
-                                  itemKey={group.categoryName}
-                                  key={groupIdx}
-                                >
-                                  <div className="card-content-container">
-                                    <div
-                                      ref={tabScrollRef}
-                                      className="p-2 max-h-[21.5rem] overflow-y-auto card-content-scroll"
-                                      onScroll={() => handleCardScroll(tabScrollRef, setShowUptimeScrollHint)}
+                            {uptimeData.map((group, groupIdx) => (
+                              <TabPane
+                                tab={
+                                  <span className="flex items-center gap-2">
+                                    <Gauge size={14} />
+                                    {group.categoryName}
+                                    <Tag
+                                      color={activeUptimeTab === group.categoryName ? 'red' : 'grey'}
+                                      size='small'
+                                      shape='circle'
                                     >
-                                      {renderMonitorList(group.monitors)}
-                                    </div>
-                                    <div
-                                      className="card-content-fade-indicator"
-                                      style={{ opacity: activeUptimeTab === group.categoryName ? showUptimeScrollHint ? 1 : 0 : 0 }}
-                                    />
-                                  </div>
-                                </TabPane>
-                              );
-                            })}
+                                      {group.monitors ? group.monitors.length : 0}
+                                    </Tag>
+                                  </span>
+                                }
+                                itemKey={group.categoryName}
+                                key={groupIdx}
+                              >
+                                <ScrollableContainer maxHeight="21.5rem">
+                                  {renderMonitorList(group.monitors)}
+                                </ScrollableContainer>
+                              </TabPane>
+                            ))}
                           </Tabs>
                         )
                       ) : (