Jelajahi Sumber

✨ feat: Add skeleton loading animation to SelectableButtonGroup component (#1365)

Add comprehensive loading state support with skeleton animations for the SelectableButtonGroup component, improving user experience during data loading.

Key Changes:
- Add loading prop to SelectableButtonGroup with minimum 500ms display duration
- Implement skeleton buttons with proper Semi-UI Skeleton wrapper and active animation
- Use fixed skeleton count (6 items) to prevent visual jumping during load transitions
- Pass loading state through all pricing filter components hierarchy:
  - PricingSidebar and PricingFilterModal as container components
  - PricingDisplaySettings, PricingCategories, PricingGroups, PricingQuotaTypes as filter components

Technical Details:
- Reference CardTable.js implementation for consistent skeleton UI patterns
- Add useEffect hook for 500ms minimum loading duration control
- Support both checkbox and regular button skeleton modes
- Maintain responsive layout compatibility (mobile/desktop)
- Add proper JSDoc parameter documentation for loading prop

Fixes:
- Prevent skeleton count sudden changes that caused visual discontinuity
- Ensure proper skeleton animation with Semi-UI active parameter
- Maintain consistent loading experience across all filter components
t0ng7u 7 bulan lalu
induk
melakukan
3f96bd9509

+ 75 - 8
web/src/components/common/ui/SelectableButtonGroup.jsx

@@ -17,9 +17,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
 For commercial licensing, please contact support@quantumnous.com
 */
 
-import React, { useState, useRef } from 'react';
+import React, { useState, useRef, useEffect } from 'react';
 import { useIsMobile } from '../../../hooks/common/useIsMobile';
-import { Divider, Button, Tag, Row, Col, Collapsible, Checkbox } from '@douyinfe/semi-ui';
+import { Divider, Button, Tag, Row, Col, Collapsible, Checkbox, Skeleton } from '@douyinfe/semi-ui';
 import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
 
 /**
@@ -34,6 +34,7 @@ import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
  * @param {boolean} collapsible 是否支持折叠,默认true
  * @param {number} collapseHeight 折叠时的高度,默认200
  * @param {boolean} withCheckbox 是否启用前缀 Checkbox 来控制激活状态
+ * @param {boolean} loading 是否处于加载状态
  */
 const SelectableButtonGroup = ({
   title,
@@ -44,16 +45,36 @@ const SelectableButtonGroup = ({
   style = {},
   collapsible = true,
   collapseHeight = 200,
-  withCheckbox = false
+  withCheckbox = false,
+  loading = false
 }) => {
   const [isOpen, setIsOpen] = useState(false);
+  const [showSkeleton, setShowSkeleton] = useState(loading);
+  const [skeletonCount] = useState(6);
   const isMobile = useIsMobile();
   const perRow = 3;
   const maxVisibleRows = Math.max(1, Math.floor(collapseHeight / 32)); // Approx row height 32
   const needCollapse = collapsible && items.length > perRow * maxVisibleRows;
+  const loadingStartRef = useRef(Date.now());
 
   const contentRef = useRef(null);
 
+  useEffect(() => {
+    if (loading) {
+      loadingStartRef.current = Date.now();
+      setShowSkeleton(true);
+    } else {
+      const elapsed = Date.now() - loadingStartRef.current;
+      const remaining = Math.max(0, 500 - elapsed);
+      if (remaining === 0) {
+        setShowSkeleton(false);
+      } else {
+        const timer = setTimeout(() => setShowSkeleton(false), remaining);
+        return () => clearTimeout(timer);
+      }
+    }
+  }, [loading]);
+
   const maskStyle = isOpen
     ? {}
     : {
@@ -81,14 +102,57 @@ const SelectableButtonGroup = ({
     gap: 4,
   };
 
-  const contentElement = (
+  const renderSkeletonButtons = () => {
+
+    const placeholder = (
+      <Row gutter={[8, 8]} style={{ lineHeight: '32px', ...style }}>
+        {Array.from({ length: skeletonCount }).map((_, index) => (
+          <Col
+            {...(isMobile
+              ? { span: 12 }
+              : { xs: 24, sm: 24, md: 24, lg: 12, xl: 8 }
+            )}
+            key={index}
+          >
+            <div style={{
+              width: '100%',
+              height: '32px',
+              display: 'flex',
+              alignItems: 'center',
+              justifyContent: 'flex-start',
+              border: '1px solid var(--semi-color-border)',
+              borderRadius: 'var(--semi-border-radius-medium)',
+              padding: '0 12px',
+              gap: '8px'
+            }}>
+              {withCheckbox && (
+                <Skeleton.Title active style={{ width: 14, height: 14 }} />
+              )}
+              <Skeleton.Title
+                active
+                style={{
+                  width: `${60 + (index % 3) * 20}px`,
+                  height: 14
+                }}
+              />
+            </div>
+          </Col>
+        ))}
+      </Row>
+    );
+
+    return (
+      <Skeleton loading={true} active placeholder={placeholder}></Skeleton>
+    );
+  };
+
+  const contentElement = showSkeleton ? renderSkeletonButtons() : (
     <Row gutter={[8, 8]} style={{ lineHeight: '32px', ...style }} ref={contentRef}>
       {items.map((item) => {
         const isActive = Array.isArray(activeValue)
           ? activeValue.includes(item.value)
           : activeValue === item.value;
 
-        // 当启用前缀 Checkbox 时,按钮本身不可点击,仅 Checkbox 可控制状态切换
         if (withCheckbox) {
           return (
             <Col
@@ -129,7 +193,6 @@ const SelectableButtonGroup = ({
           );
         }
 
-        // 默认行为
         return (
           <Col
             {...(isMobile
@@ -166,10 +229,14 @@ const SelectableButtonGroup = ({
     <div className="mb-8">
       {title && (
         <Divider margin="12px" align="left">
-          {title}
+          {showSkeleton ? (
+            <Skeleton.Title active style={{ width: 80, height: 14 }} />
+          ) : (
+            title
+          )}
         </Divider>
       )}
-      {needCollapse ? (
+      {needCollapse && !showSkeleton ? (
         <div style={{ position: 'relative' }}>
           <Collapsible isOpen={isOpen} collapseHeight={collapseHeight} style={{ ...maskStyle }}>
             {contentElement}

+ 5 - 3
web/src/components/table/model-pricing/PricingSidebar.jsx

@@ -38,6 +38,7 @@ const PricingSidebar = ({
   setFilterGroup,
   filterQuotaType,
   setFilterQuotaType,
+  loading,
   t,
   ...categoryProps
 }) => {
@@ -77,14 +78,15 @@ const PricingSidebar = ({
         setCurrency={setCurrency}
         showRatio={showRatio}
         setShowRatio={setShowRatio}
+        loading={loading}
         t={t}
       />
 
-      <PricingCategories {...categoryProps} setActiveKey={setActiveKey} t={t} />
+      <PricingCategories {...categoryProps} setActiveKey={setActiveKey} loading={loading} t={t} />
 
-      <PricingGroups filterGroup={filterGroup} setFilterGroup={setFilterGroup} usableGroup={categoryProps.usableGroup} models={categoryProps.models} t={t} />
+      <PricingGroups filterGroup={filterGroup} setFilterGroup={setFilterGroup} usableGroup={categoryProps.usableGroup} models={categoryProps.models} loading={loading} t={t} />
 
-      <PricingQuotaTypes filterQuotaType={filterQuotaType} setFilterQuotaType={setFilterQuotaType} models={categoryProps.models} t={t} />
+      <PricingQuotaTypes filterQuotaType={filterQuotaType} setFilterQuotaType={setFilterQuotaType} models={categoryProps.models} loading={loading} t={t} />
     </div>
   );
 };

+ 2 - 1
web/src/components/table/model-pricing/filter/PricingCategories.jsx

@@ -20,7 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
 import React from 'react';
 import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup';
 
-const PricingCategories = ({ activeKey, setActiveKey, modelCategories, categoryCounts, availableCategories, t }) => {
+const PricingCategories = ({ activeKey, setActiveKey, modelCategories, categoryCounts, availableCategories, loading = false, t }) => {
   const items = Object.entries(modelCategories)
     .filter(([key]) => availableCategories.includes(key))
     .map(([key, category]) => ({
@@ -36,6 +36,7 @@ const PricingCategories = ({ activeKey, setActiveKey, modelCategories, categoryC
       items={items}
       activeValue={activeKey}
       onChange={setActiveKey}
+      loading={loading}
       t={t}
     />
   );

+ 3 - 0
web/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx

@@ -29,6 +29,7 @@ const PricingDisplaySettings = ({
   setCurrency,
   showRatio,
   setShowRatio,
+  loading = false,
   t
 }) => {
   const items = [
@@ -81,6 +82,7 @@ const PricingDisplaySettings = ({
         onChange={handleChange}
         withCheckbox
         collapsible={false}
+        loading={loading}
         t={t}
       />
 
@@ -91,6 +93,7 @@ const PricingDisplaySettings = ({
           activeValue={currency}
           onChange={setCurrency}
           collapsible={false}
+          loading={loading}
           t={t}
         />
       )}

+ 4 - 1
web/src/components/table/model-pricing/filter/PricingGroups.jsx

@@ -25,9 +25,11 @@ import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup';
  * @param {string} filterGroup 当前选中的分组,'all' 表示不过滤
  * @param {Function} setFilterGroup 设置选中分组
  * @param {Record<string, any>} usableGroup 后端返回的可用分组对象
+ * @param {Array} models 模型列表
+ * @param {boolean} loading 是否加载中
  * @param {Function} t i18n
  */
-const PricingGroups = ({ filterGroup, setFilterGroup, usableGroup = {}, models = [], t }) => {
+const PricingGroups = ({ filterGroup, setFilterGroup, usableGroup = {}, models = [], loading = false, t }) => {
   const groups = ['all', ...Object.keys(usableGroup)];
 
   const items = groups.map((g) => {
@@ -50,6 +52,7 @@ const PricingGroups = ({ filterGroup, setFilterGroup, usableGroup = {}, models =
       items={items}
       activeValue={filterGroup}
       onChange={setFilterGroup}
+      loading={loading}
       t={t}
     />
   );

+ 4 - 1
web/src/components/table/model-pricing/filter/PricingQuotaTypes.jsx

@@ -24,9 +24,11 @@ import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup';
  * 计费类型筛选组件
  * @param {string|'all'|0|1} filterQuotaType 当前值
  * @param {Function} setFilterQuotaType setter
+ * @param {Array} models 模型列表
+ * @param {boolean} loading 是否加载中
  * @param {Function} t i18n
  */
-const PricingQuotaTypes = ({ filterQuotaType, setFilterQuotaType, models = [], t }) => {
+const PricingQuotaTypes = ({ filterQuotaType, setFilterQuotaType, models = [], loading = false, t }) => {
   const qtyCount = (type) => models.filter(m => type === 'all' ? true : m.quota_type === type).length;
 
   const items = [
@@ -41,6 +43,7 @@ const PricingQuotaTypes = ({ filterQuotaType, setFilterQuotaType, models = [], t
       items={items}
       activeValue={filterQuotaType}
       onChange={setFilterQuotaType}
+      loading={loading}
       t={t}
     />
   );

+ 5 - 1
web/src/components/table/model-pricing/modal/PricingFilterModal.jsx

@@ -44,6 +44,7 @@ const PricingFilterModal = ({
     setFilterGroup,
     filterQuotaType,
     setFilterQuotaType,
+    loading,
     ...categoryProps
   } = sidebarProps;
 
@@ -105,16 +106,18 @@ const PricingFilterModal = ({
           setCurrency={setCurrency}
           showRatio={showRatio}
           setShowRatio={setShowRatio}
+          loading={loading}
           t={t}
         />
 
-        <PricingCategories {...categoryProps} setActiveKey={setActiveKey} t={t} />
+        <PricingCategories {...categoryProps} setActiveKey={setActiveKey} loading={loading} t={t} />
 
         <PricingGroups
           filterGroup={filterGroup}
           setFilterGroup={setFilterGroup}
           usableGroup={categoryProps.usableGroup}
           models={categoryProps.models}
+          loading={loading}
           t={t}
         />
 
@@ -122,6 +125,7 @@ const PricingFilterModal = ({
           filterQuotaType={filterQuotaType}
           setFilterQuotaType={setFilterQuotaType}
           models={categoryProps.models}
+          loading={loading}
           t={t}
         />
       </div>