Просмотр исходного кода

🎨 feat(model-pricing): refactor layout and component structure (#1365)

* Re-architected model-pricing page into modular components:
  * PricingPage / PricingSidebar / PricingContent
  * Removed obsolete `ModelPricing*` components and column defs
* Introduced reusable `SelectableButtonGroup` in `common/ui`
  * Supports Row/Col grid (3 per row)
  * Optional collapsible mode with gradient mask & toggle
* Rebuilt filter panels with the new button-group:
  * Model categories, token groups, and quota types
  * Added dynamic `tagCount` badges to display item totals
* Extended `useModelPricingData` hook
  * Added `filterGroup` and `filterQuotaType` state and logic
* Updated PricingTable columns & sidebar reset logic to respect new states
* Ensured backward compatibility via re-export in `index.jsx`
* Polished styling, icons and i18n keys
t0ng7u 7 месяцев назад
Родитель
Сommit
a044070e1d

+ 147 - 0
web/src/components/common/ui/SelectableButtonGroup.jsx

@@ -0,0 +1,147 @@
+/*
+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, { useState, useRef } from 'react';
+import { Divider, Button, Tag, Row, Col, Collapsible } from '@douyinfe/semi-ui';
+import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
+
+/**
+ * 通用可选择按钮组组件
+ *
+ * @param {string} title 标题
+ * @param {Array<{value:any,label:string,icon?:React.ReactNode,tagCount?:number}>} items 按钮项
+ * @param {*} activeValue 当前激活的值
+ * @param {(value:any)=>void} onChange 选择改变回调
+ * @param {function} t i18n
+ * @param {object} style 额外样式
+ * @param {boolean} collapsible 是否支持折叠,默认true
+ * @param {number} collapseHeight 折叠时的高度,默认200
+ */
+const SelectableButtonGroup = ({
+  title,
+  items = [],
+  activeValue,
+  onChange,
+  t = (v) => v,
+  style = {},
+  collapsible = true,
+  collapseHeight = 200
+}) => {
+  const [isOpen, setIsOpen] = useState(false);
+  const perRow = 3;
+  const maxVisibleRows = Math.max(1, Math.floor(collapseHeight / 32)); // Approx row height 32
+  const needCollapse = collapsible && items.length > perRow * maxVisibleRows;
+
+  const contentRef = useRef(null);
+
+  const maskStyle = isOpen
+    ? {}
+    : {
+      WebkitMaskImage:
+        'linear-gradient(to bottom, black 0%, rgba(0, 0, 0, 1) 60%, rgba(0, 0, 0, 0.2) 80%, transparent 100%)',
+    };
+
+  const toggle = () => {
+    setIsOpen(!isOpen);
+  };
+
+  const linkStyle = {
+    position: 'absolute',
+    left: 0,
+    right: 0,
+    textAlign: 'center',
+    bottom: -10,
+    fontWeight: 400,
+    cursor: 'pointer',
+    fontSize: '12px',
+    color: 'var(--semi-color-text-2)',
+    display: 'flex',
+    alignItems: 'center',
+    justifyContent: 'center',
+    gap: 4,
+  };
+
+  const contentElement = (
+    <Row gutter={[8, 8]} style={{ lineHeight: '32px', ...style }} ref={contentRef}>
+      {items.map((item) => {
+        const isActive = activeValue === item.value;
+        return (
+          <Col span={8} key={item.value}>
+            <Button
+              onClick={() => onChange(item.value)}
+              theme={isActive ? 'solid' : 'outline'}
+              type={isActive ? 'primary' : 'tertiary'}
+              icon={item.icon}
+              style={{ width: '100%' }}
+            >
+              <span style={{ marginRight: item.tagCount !== undefined ? 4 : 0 }}>{item.label}</span>
+              {item.tagCount !== undefined && (
+                <Tag
+                  color='white'
+                  shape="circle"
+                  size="small"
+                >
+                  {item.tagCount}
+                </Tag>
+              )}
+            </Button>
+          </Col>
+        );
+      })}
+    </Row>
+  );
+
+  return (
+    <div className="mb-8">
+      {title && (
+        <Divider margin="12px" align="left">
+          {title}
+        </Divider>
+      )}
+      {needCollapse ? (
+        <div style={{ position: 'relative' }}>
+          <Collapsible isOpen={isOpen} collapseHeight={collapseHeight} style={{ ...maskStyle }}>
+            {contentElement}
+          </Collapsible>
+          {isOpen ? null : (
+            <div onClick={toggle} style={{ ...linkStyle }}>
+              <IconChevronDown size="small" />
+              <span>{t('展开更多')}</span>
+            </div>
+          )}
+          {isOpen && (
+            <div onClick={toggle} style={{
+              ...linkStyle,
+              position: 'static',
+              marginTop: 8,
+              bottom: 'auto'
+            }}>
+              <IconChevronUp size="small" />
+              <span>{t('收起')}</span>
+            </div>
+          )}
+        </div>
+      ) : (
+        contentElement
+      )}
+    </div>
+  );
+};
+
+export default SelectableButtonGroup; 

+ 1 - 1
web/src/components/layout/HeaderBar.js

@@ -467,7 +467,7 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
   };
 
   return (
-    <header className="text-semi-color-text-0 sticky top-0 z-50 transition-colors duration-300 bg-white/75 dark:bg-zinc-900/75 backdrop-blur-lg">
+    <header className="text-semi-color-text-0 sticky top-0 z-50 transition-colors duration-300 bg-white/75 dark:bg-zinc-900/75 backdrop-blur-lg" style={{ borderBottom: '1px solid var(--semi-color-border)' }}>
       <NoticeModal
         visible={noticeVisible}
         onClose={handleNoticeClose}

+ 1 - 1
web/src/components/layout/PageLayout.js

@@ -42,7 +42,7 @@ const PageLayout = () => {
   const { i18n } = useTranslation();
   const location = useLocation();
 
-  const shouldHideFooter = location.pathname.startsWith('/console');
+  const shouldHideFooter = location.pathname.startsWith('/console') || location.pathname === '/pricing';
 
   const shouldInnerPadding = location.pathname.includes('/console') &&
     !location.pathname.startsWith('/console/chat') &&

+ 0 - 87
web/src/components/table/model-pricing/ModelPricingFilters.jsx

@@ -1,87 +0,0 @@
-/*
-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, { useMemo } from 'react';
-import { Card, Input, Button, Space, Switch, Select } from '@douyinfe/semi-ui';
-import { IconSearch, IconCopy } from '@douyinfe/semi-icons';
-
-const ModelPricingFilters = ({
-  selectedRowKeys,
-  copyText,
-  showWithRecharge,
-  setShowWithRecharge,
-  currency,
-  setCurrency,
-  handleChange,
-  handleCompositionStart,
-  handleCompositionEnd,
-  t
-}) => {
-  const SearchAndActions = useMemo(() => (
-    <Card className="!rounded-xl mb-6" bordered={false}>
-      <div className="flex flex-wrap items-center gap-4">
-        <div className="flex-1 min-w-[200px]">
-          <Input
-            prefix={<IconSearch />}
-            placeholder={t('模糊搜索模型名称')}
-            onCompositionStart={handleCompositionStart}
-            onCompositionEnd={handleCompositionEnd}
-            onChange={handleChange}
-            showClear
-          />
-        </div>
-        <Button
-          theme='light'
-          type='primary'
-          icon={<IconCopy />}
-          onClick={() => copyText(selectedRowKeys)}
-          disabled={selectedRowKeys.length === 0}
-          className="!bg-blue-500 hover:!bg-blue-600 text-white"
-        >
-          {t('复制选中模型')}
-        </Button>
-
-        {/* 充值价格显示开关 */}
-        <Space align="center">
-          <span>{t('以充值价格显示')}</span>
-          <Switch
-            checked={showWithRecharge}
-            onChange={setShowWithRecharge}
-            size="small"
-          />
-          {showWithRecharge && (
-            <Select
-              value={currency}
-              onChange={setCurrency}
-              size="small"
-              style={{ width: 100 }}
-            >
-              <Select.Option value="USD">USD ($)</Select.Option>
-              <Select.Option value="CNY">CNY (¥)</Select.Option>
-            </Select>
-          )}
-        </Space>
-      </div>
-    </Card>
-  ), [selectedRowKeys, t, showWithRecharge, currency, handleCompositionStart, handleCompositionEnd, handleChange, copyText, setShowWithRecharge, setCurrency]);
-
-  return SearchAndActions;
-};
-
-export default ModelPricingFilters; 

+ 0 - 67
web/src/components/table/model-pricing/ModelPricingTabs.jsx

@@ -1,67 +0,0 @@
-/*
-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 from 'react';
-import { Tabs, TabPane, Tag } from '@douyinfe/semi-ui';
-
-const ModelPricingTabs = ({
-  activeKey,
-  setActiveKey,
-  modelCategories,
-  categoryCounts,
-  availableCategories,
-  t
-}) => {
-  return (
-    <Tabs
-      activeKey={activeKey}
-      type="card"
-      collapsible
-      onChange={key => setActiveKey(key)}
-      className="mt-2"
-    >
-      {Object.entries(modelCategories)
-        .filter(([key]) => availableCategories.includes(key))
-        .map(([key, category]) => {
-          const modelCount = categoryCounts[key] || 0;
-
-          return (
-            <TabPane
-              tab={
-                <span className="flex items-center gap-2">
-                  {category.icon && <span className="w-4 h-4">{category.icon}</span>}
-                  {category.label}
-                  <Tag
-                    color={activeKey === key ? 'red' : 'grey'}
-                    shape='circle'
-                  >
-                    {modelCount}
-                  </Tag>
-                </span>
-              }
-              itemKey={key}
-              key={key}
-            />
-          );
-        })}
-    </Tabs>
-  );
-};
-
-export default ModelPricingTabs; 

+ 52 - 0
web/src/components/table/model-pricing/PricingContent.jsx

@@ -0,0 +1,52 @@
+/*
+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 from 'react';
+import PricingSearchBar from './PricingSearchBar.jsx';
+import PricingTable from './PricingTable.jsx';
+
+const PricingContent = (props) => {
+  return (
+    <>
+      {/* 固定的搜索和操作区域 */}
+      <div
+        style={{
+          padding: '16px 24px',
+          borderBottom: '1px solid var(--semi-color-border)',
+          backgroundColor: 'var(--semi-color-bg-0)',
+          flexShrink: 0
+        }}
+      >
+        <PricingSearchBar {...props} />
+      </div>
+
+      {/* 可滚动的内容区域 */}
+      <div
+        style={{
+          flex: 1,
+          overflow: 'auto'
+        }}
+      >
+        <PricingTable {...props} />
+      </div>
+    </>
+  );
+};
+
+export default PricingContent; 

+ 2 - 2
web/src/components/table/model-pricing/ModelPricingHeader.jsx → web/src/components/table/model-pricing/PricingHeader.jsx

@@ -22,7 +22,7 @@ import { Card } from '@douyinfe/semi-ui';
 import { IconVerify, IconLayers, IconInfoCircle } from '@douyinfe/semi-icons';
 import { AlertCircle } from 'lucide-react';
 
-const ModelPricingHeader = ({
+const PricingHeader = ({
   userState,
   groupRatio,
   selectedGroup,
@@ -120,4 +120,4 @@ const ModelPricingHeader = ({
   );
 };
 
-export default ModelPricingHeader; 
+export default PricingHeader; 

+ 72 - 0
web/src/components/table/model-pricing/PricingPage.jsx

@@ -0,0 +1,72 @@
+/*
+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 from 'react';
+import { Layout, ImagePreview } from '@douyinfe/semi-ui';
+import PricingSidebar from './PricingSidebar.jsx';
+import PricingContent from './PricingContent.jsx';
+import { useModelPricingData } from '../../../hooks/model-pricing/useModelPricingData.js';
+
+const PricingPage = () => {
+  const pricingData = useModelPricingData();
+  const { Sider, Content } = Layout;
+
+  // 显示倍率状态
+  const [showRatio, setShowRatio] = React.useState(false);
+
+  return (
+    <div className="bg-white">
+      <Layout style={{ height: 'calc(100vh - 60px)', overflow: 'hidden', marginTop: '60px' }}>
+        {/* 左侧边栏 */}
+        <Sider
+          style={{
+            width: 460,
+            height: 'calc(100vh - 60px)',
+            backgroundColor: 'var(--semi-color-bg-0)',
+            borderRight: '1px solid var(--semi-color-border)',
+            overflow: 'auto'
+          }}
+        >
+          <PricingSidebar {...pricingData} showRatio={showRatio} setShowRatio={setShowRatio} />
+        </Sider>
+
+        {/* 右侧内容区 */}
+        <Content
+          style={{
+            height: 'calc(100vh - 60px)',
+            backgroundColor: 'var(--semi-color-bg-0)',
+            display: 'flex',
+            flexDirection: 'column'
+          }}
+        >
+          <PricingContent {...pricingData} showRatio={showRatio} />
+        </Content>
+      </Layout>
+
+      {/* 倍率说明图预览 */}
+      <ImagePreview
+        src={pricingData.modalImageUrl}
+        visible={pricingData.isModalOpenurl}
+        onVisibleChange={(visible) => pricingData.setIsModalOpenurl(visible)}
+      />
+    </div>
+  );
+};
+
+export default PricingPage; 

+ 63 - 0
web/src/components/table/model-pricing/PricingSearchBar.jsx

@@ -0,0 +1,63 @@
+/*
+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, { useMemo } from 'react';
+import { Input, Button } from '@douyinfe/semi-ui';
+import { IconSearch, IconCopy } from '@douyinfe/semi-icons';
+
+const PricingSearchBar = ({
+  selectedRowKeys,
+  copyText,
+  handleChange,
+  handleCompositionStart,
+  handleCompositionEnd,
+  t
+}) => {
+  const SearchAndActions = useMemo(() => (
+    <div className="flex items-center gap-4 w-full">
+      {/* 搜索框 */}
+      <div className="flex-1">
+        <Input
+          prefix={<IconSearch />}
+          placeholder={t('模糊搜索模型名称')}
+          onCompositionStart={handleCompositionStart}
+          onCompositionEnd={handleCompositionEnd}
+          onChange={handleChange}
+          showClear
+        />
+      </div>
+
+      {/* 操作按钮 */}
+      <Button
+        theme='light'
+        type='primary'
+        icon={<IconCopy />}
+        onClick={() => copyText(selectedRowKeys)}
+        disabled={selectedRowKeys.length === 0}
+        className="!bg-blue-500 hover:!bg-blue-600 text-white"
+      >
+        {t('复制选中模型')}
+      </Button>
+    </div>
+  ), [selectedRowKeys, t, handleCompositionStart, handleCompositionEnd, handleChange, copyText]);
+
+  return SearchAndActions;
+};
+
+export default PricingSearchBar; 

+ 153 - 0
web/src/components/table/model-pricing/PricingSidebar.jsx

@@ -0,0 +1,153 @@
+/*
+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 from 'react';
+import { Divider, Button, Switch, Select, Tooltip } from '@douyinfe/semi-ui';
+import { IconHelpCircle } from '@douyinfe/semi-icons';
+import PricingCategories from './sidebar/PricingCategories.jsx';
+import PricingGroups from './sidebar/PricingGroups.jsx';
+import PricingQuotaTypes from './sidebar/PricingQuotaTypes.jsx';
+
+const PricingSidebar = ({
+  showWithRecharge,
+  setShowWithRecharge,
+  currency,
+  setCurrency,
+  handleChange,
+  setActiveKey,
+  showRatio,
+  setShowRatio,
+  filterGroup,
+  setFilterGroup,
+  filterQuotaType,
+  setFilterQuotaType,
+  t,
+  ...categoryProps
+}) => {
+
+  // 重置所有筛选条件
+  const handleResetFilters = () => {
+    // 重置搜索
+    if (handleChange) {
+      handleChange('');
+    }
+
+    // 重置模型分类到默认
+    if (setActiveKey && categoryProps.availableCategories?.length > 0) {
+      setActiveKey(categoryProps.availableCategories[0]);
+    }
+
+    // 重置充值价格显示
+    if (setShowWithRecharge) {
+      setShowWithRecharge(false);
+    }
+
+    // 重置货币
+    if (setCurrency) {
+      setCurrency('USD');
+    }
+
+    // 重置显示倍率
+    setShowRatio(false);
+
+    // 重置分组筛选
+    if (setFilterGroup) {
+      setFilterGroup('all');
+    }
+
+    // 重置计费类型筛选
+    if (setFilterQuotaType) {
+      setFilterQuotaType('all');
+    }
+  };
+
+  return (
+    <div className="p-4">
+      {/* 筛选标题和重置按钮 */}
+      <div className="flex items-center justify-between mb-6">
+        <div className="text-lg font-semibold text-gray-800">
+          {t('筛选')}
+        </div>
+        <Button
+          theme="outline"
+          onClick={handleResetFilters}
+          className="text-gray-500 hover:text-gray-700"
+        >
+          {t('重置')}
+        </Button>
+      </div>
+
+      {/* 显示设置 */}
+      <div className="mb-6">
+        <Divider margin='12px' align='left'>
+          {t('显示设置')}
+        </Divider>
+        <div className="px-2">
+          <div className="flex items-center justify-between mb-3">
+            <span className="text-sm text-gray-700">{t('以充值价格显示')}</span>
+            <Switch
+              checked={showWithRecharge}
+              onChange={setShowWithRecharge}
+              size="small"
+            />
+          </div>
+          {showWithRecharge && (
+            <div className="mt-2 mb-3">
+              <div className="text-xs text-gray-500 mb-1">{t('货币单位')}</div>
+              <Select
+                value={currency}
+                onChange={setCurrency}
+                size="small"
+                className="w-full"
+              >
+                <Select.Option value="USD">USD ($)</Select.Option>
+                <Select.Option value="CNY">CNY (¥)</Select.Option>
+              </Select>
+            </div>
+          )}
+          <div className="flex items-center justify-between">
+            <div className="flex items-center gap-1">
+              <span className="text-sm text-gray-700">{t('显示倍率')}</span>
+              <Tooltip content={t('倍率是用于系统计算不同模型的最终价格用的,如果您不理解倍率,请忽略')}>
+                <IconHelpCircle
+                  size="small"
+                  style={{ color: 'var(--semi-color-text-2)', cursor: 'help' }}
+                />
+              </Tooltip>
+            </div>
+            <Switch
+              checked={showRatio}
+              onChange={setShowRatio}
+              size="small"
+            />
+          </div>
+        </div>
+      </div>
+
+      {/* 模型分类 */}
+      <PricingCategories {...categoryProps} setActiveKey={setActiveKey} t={t} />
+
+      <PricingGroups filterGroup={filterGroup} setFilterGroup={setFilterGroup} usableGroup={categoryProps.usableGroup} models={categoryProps.models} t={t} />
+
+      <PricingQuotaTypes filterQuotaType={filterQuotaType} setFilterQuotaType={setFilterQuotaType} models={categoryProps.models} t={t} />
+    </div>
+  );
+};
+
+export default PricingSidebar; 

+ 8 - 4
web/src/components/table/model-pricing/ModelPricingTable.jsx → web/src/components/table/model-pricing/PricingTable.jsx

@@ -23,9 +23,9 @@ import {
   IllustrationNoResult,
   IllustrationNoResultDark
 } from '@douyinfe/semi-illustrations';
-import { getModelPricingColumns } from './ModelPricingColumnDefs.js';
+import { getPricingTableColumns } from './PricingTableColumns.js';
 
-const ModelPricingTable = ({
+const PricingTable = ({
   filteredModels,
   loading,
   rowSelection,
@@ -44,10 +44,12 @@ const ModelPricingTable = ({
   displayPrice,
   filteredValue,
   handleGroupClick,
+  showRatio,
   t
 }) => {
+
   const columns = useMemo(() => {
-    return getModelPricingColumns({
+    return getPricingTableColumns({
       t,
       selectedGroup,
       usableGroup,
@@ -61,6 +63,7 @@ const ModelPricingTable = ({
       setTokenUnit,
       displayPrice,
       handleGroupClick,
+      showRatio,
     });
   }, [
     t,
@@ -76,6 +79,7 @@ const ModelPricingTable = ({
     setTokenUnit,
     displayPrice,
     handleGroupClick,
+    showRatio,
   ]);
 
   // 更新列定义中的 filteredValue
@@ -121,4 +125,4 @@ const ModelPricingTable = ({
   return ModelTable;
 };
 
-export default ModelPricingTable; 
+export default PricingTable; 

+ 96 - 82
web/src/components/table/model-pricing/ModelPricingColumnDefs.js → web/src/components/table/model-pricing/PricingTableColumns.js

@@ -76,7 +76,7 @@ function renderSupportedEndpoints(endpoints) {
   );
 }
 
-export const getModelPricingColumns = ({
+export const getPricingTableColumns = ({
   t,
   selectedGroup,
   usableGroup,
@@ -90,8 +90,9 @@ export const getModelPricingColumns = ({
   setTokenUnit,
   displayPrice,
   handleGroupClick,
+  showRatio,
 }) => {
-  return [
+  const baseColumns = [
     {
       title: t('可用性'),
       dataIndex: 'available',
@@ -166,96 +167,109 @@ export const getModelPricingColumns = ({
         );
       },
     },
-    {
-      title: () => (
-        <div className="flex items-center space-x-1">
-          <span>{t('倍率')}</span>
-          <Tooltip content={t('倍率是为了方便换算不同价格的模型')}>
-            <IconHelpCircle
-              className="text-blue-500 cursor-pointer"
-              onClick={() => {
-                setModalImageUrl('/ratio.png');
-                setIsModalOpenurl(true);
-              }}
-            />
-          </Tooltip>
+  ];
+
+  // 倍率列 - 只有在showRatio为true时才包含
+  const ratioColumn = {
+    title: () => (
+      <div className="flex items-center space-x-1">
+        <span>{t('倍率')}</span>
+        <Tooltip content={t('倍率是为了方便换算不同价格的模型')}>
+          <IconHelpCircle
+            className="text-blue-500 cursor-pointer"
+            onClick={() => {
+              setModalImageUrl('/ratio.png');
+              setIsModalOpenurl(true);
+            }}
+          />
+        </Tooltip>
+      </div>
+    ),
+    dataIndex: 'model_ratio',
+    render: (text, record, index) => {
+      let content = text;
+      let completionRatio = parseFloat(record.completion_ratio.toFixed(3));
+      content = (
+        <div className="space-y-1">
+          <div className="text-gray-700">
+            {t('模型倍率')}:{record.quota_type === 0 ? text : t('无')}
+          </div>
+          <div className="text-gray-700">
+            {t('补全倍率')}:
+            {record.quota_type === 0 ? completionRatio : t('无')}
+          </div>
+          <div className="text-gray-700">
+            {t('分组倍率')}:{groupRatio[selectedGroup]}
+          </div>
         </div>
-      ),
-      dataIndex: 'model_ratio',
-      render: (text, record, index) => {
-        let content = text;
-        let completionRatio = parseFloat(record.completion_ratio.toFixed(3));
+      );
+      return content;
+    },
+  };
+
+  // 价格列
+  const priceColumn = {
+    title: (
+      <div className="flex items-center space-x-2">
+        <span>{t('模型价格')}</span>
+        {/* 计费单位切换 */}
+        <Switch
+          checked={tokenUnit === 'K'}
+          onChange={(checked) => setTokenUnit(checked ? 'K' : 'M')}
+          checkedText="K"
+          uncheckedText="M"
+        />
+      </div>
+    ),
+    dataIndex: 'model_price',
+    render: (text, record, index) => {
+      let content = text;
+      if (record.quota_type === 0) {
+        let inputRatioPriceUSD = record.model_ratio * 2 * groupRatio[selectedGroup];
+        let completionRatioPriceUSD =
+          record.model_ratio * record.completion_ratio * 2 * groupRatio[selectedGroup];
+
+        const unitDivisor = tokenUnit === 'K' ? 1000 : 1;
+        const unitLabel = tokenUnit === 'K' ? 'K' : 'M';
+
+        let displayInput = displayPrice(inputRatioPriceUSD);
+        let displayCompletion = displayPrice(completionRatioPriceUSD);
+
+        const divisor = unitDivisor;
+        const numInput = parseFloat(displayInput.replace(/[^0-9.]/g, '')) / divisor;
+        const numCompletion = parseFloat(displayCompletion.replace(/[^0-9.]/g, '')) / divisor;
+
+        displayInput = `${currency === 'CNY' ? '¥' : '$'}${numInput.toFixed(3)}`;
+        displayCompletion = `${currency === 'CNY' ? '¥' : '$'}${numCompletion.toFixed(3)}`;
         content = (
           <div className="space-y-1">
             <div className="text-gray-700">
-              {t('模型倍率')}:{record.quota_type === 0 ? text : t('无')}
-            </div>
-            <div className="text-gray-700">
-              {t('补全倍率')}:
-              {record.quota_type === 0 ? completionRatio : t('无')}
+              {t('提示')} {displayInput} / 1{unitLabel} tokens
             </div>
             <div className="text-gray-700">
-              {t('分组倍率')}:{groupRatio[selectedGroup]}
+              {t('补全')} {displayCompletion} / 1{unitLabel} tokens
             </div>
           </div>
         );
-        return content;
-      },
+      } else {
+        let priceUSD = parseFloat(text) * groupRatio[selectedGroup];
+        let displayVal = displayPrice(priceUSD);
+        content = (
+          <div className="text-gray-700">
+            {t('模型价格')}:{displayVal}
+          </div>
+        );
+      }
+      return content;
     },
-    {
-      title: (
-        <div className="flex items-center space-x-2">
-          <span>{t('模型价格')}</span>
-          {/* 计费单位切换 */}
-          <Switch
-            checked={tokenUnit === 'K'}
-            onChange={(checked) => setTokenUnit(checked ? 'K' : 'M')}
-            checkedText="K"
-            uncheckedText="M"
-          />
-        </div>
-      ),
-      dataIndex: 'model_price',
-      render: (text, record, index) => {
-        let content = text;
-        if (record.quota_type === 0) {
-          let inputRatioPriceUSD = record.model_ratio * 2 * groupRatio[selectedGroup];
-          let completionRatioPriceUSD =
-            record.model_ratio * record.completion_ratio * 2 * groupRatio[selectedGroup];
-
-          const unitDivisor = tokenUnit === 'K' ? 1000 : 1;
-          const unitLabel = tokenUnit === 'K' ? 'K' : 'M';
+  };
 
-          let displayInput = displayPrice(inputRatioPriceUSD);
-          let displayCompletion = displayPrice(completionRatioPriceUSD);
-
-          const divisor = unitDivisor;
-          const numInput = parseFloat(displayInput.replace(/[^0-9.]/g, '')) / divisor;
-          const numCompletion = parseFloat(displayCompletion.replace(/[^0-9.]/g, '')) / divisor;
+  // 根据showRatio决定是否包含倍率列
+  const columns = [...baseColumns];
+  if (showRatio) {
+    columns.push(ratioColumn);
+  }
+  columns.push(priceColumn);
 
-          displayInput = `${currency === 'CNY' ? '¥' : '$'}${numInput.toFixed(3)}`;
-          displayCompletion = `${currency === 'CNY' ? '¥' : '$'}${numCompletion.toFixed(3)}`;
-          content = (
-            <div className="space-y-1">
-              <div className="text-gray-700">
-                {t('提示')} {displayInput} / 1{unitLabel} tokens
-              </div>
-              <div className="text-gray-700">
-                {t('补全')} {displayCompletion} / 1{unitLabel} tokens
-              </div>
-            </div>
-          );
-        } else {
-          let priceUSD = parseFloat(text) * groupRatio[selectedGroup];
-          let displayVal = displayPrice(priceUSD);
-          content = (
-            <div className="text-gray-700">
-              {t('模型价格')}:{displayVal}
-            </div>
-          );
-        }
-        return content;
-      },
-    },
-  ];
+  return columns;
 }; 

+ 2 - 47
web/src/components/table/model-pricing/index.jsx

@@ -17,50 +17,5 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
 For commercial licensing, please contact support@quantumnous.com
 */
 
-import React from 'react';
-import { Layout, Card, ImagePreview } from '@douyinfe/semi-ui';
-import ModelPricingTabs from './ModelPricingTabs.jsx';
-import ModelPricingFilters from './ModelPricingFilters.jsx';
-import ModelPricingTable from './ModelPricingTable.jsx';
-import ModelPricingHeader from './ModelPricingHeader.jsx';
-import { useModelPricingData } from '../../../hooks/model-pricing/useModelPricingData.js';
-
-const ModelPricingPage = () => {
-  const modelPricingData = useModelPricingData();
-
-  return (
-    <div className="bg-gray-50">
-      <Layout>
-        <Layout.Content>
-          <div className="flex justify-center">
-            <div className="w-full">
-              {/* 主卡片容器 */}
-              <Card bordered={false} className="!rounded-2xl shadow-lg border-0">
-                {/* 顶部状态卡片 */}
-                <ModelPricingHeader {...modelPricingData} />
-
-                {/* 模型分类 Tabs */}
-                <div className="mb-6">
-                  <ModelPricingTabs {...modelPricingData} />
-
-                  {/* 搜索和表格区域 */}
-                  <ModelPricingFilters {...modelPricingData} />
-                  <ModelPricingTable {...modelPricingData} />
-                </div>
-
-                {/* 倍率说明图预览 */}
-                <ImagePreview
-                  src={modelPricingData.modalImageUrl}
-                  visible={modelPricingData.isModalOpenurl}
-                  onVisibleChange={(visible) => modelPricingData.setIsModalOpenurl(visible)}
-                />
-              </Card>
-            </div>
-          </div>
-        </Layout.Content>
-      </Layout>
-    </div>
-  );
-};
-
-export default ModelPricingPage; 
+// 为了向后兼容,这里重新导出新的 PricingPage 组件
+export { default } from './PricingPage.jsx'; 

+ 44 - 0
web/src/components/table/model-pricing/sidebar/PricingCategories.jsx

@@ -0,0 +1,44 @@
+/*
+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 from 'react';
+import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup.jsx';
+
+const PricingCategories = ({ activeKey, setActiveKey, modelCategories, categoryCounts, availableCategories, t }) => {
+  const items = Object.entries(modelCategories)
+    .filter(([key]) => availableCategories.includes(key))
+    .map(([key, category]) => ({
+      value: key,
+      label: category.label,
+      icon: category.icon,
+      tagCount: categoryCounts[key] || 0,
+    }));
+
+  return (
+    <SelectableButtonGroup
+      title={t('模型分类')}
+      items={items}
+      activeValue={activeKey}
+      onChange={setActiveKey}
+      t={t}
+    />
+  );
+};
+
+export default PricingCategories; 

+ 58 - 0
web/src/components/table/model-pricing/sidebar/PricingGroups.jsx

@@ -0,0 +1,58 @@
+/*
+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 from 'react';
+import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup.jsx';
+
+/**
+ * 分组筛选组件
+ * @param {string} filterGroup 当前选中的分组,'all' 表示不过滤
+ * @param {Function} setFilterGroup 设置选中分组
+ * @param {Record<string, any>} usableGroup 后端返回的可用分组对象
+ * @param {Function} t i18n
+ */
+const PricingGroups = ({ filterGroup, setFilterGroup, usableGroup = {}, models = [], t }) => {
+  const groups = ['all', ...Object.keys(usableGroup)];
+
+  const items = groups.map((g) => {
+    let count = 0;
+    if (g === 'all') {
+      count = models.length;
+    } else {
+      count = models.filter(m => m.enable_groups && m.enable_groups.includes(g)).length;
+    }
+    return {
+      value: g,
+      label: g === 'all' ? t('全部分组') : g,
+      tagCount: count,
+    };
+  });
+
+  return (
+    <SelectableButtonGroup
+      title={t('可用令牌分组')}
+      items={items}
+      activeValue={filterGroup}
+      onChange={setFilterGroup}
+      t={t}
+    />
+  );
+};
+
+export default PricingGroups; 

+ 49 - 0
web/src/components/table/model-pricing/sidebar/PricingQuotaTypes.jsx

@@ -0,0 +1,49 @@
+/*
+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 from 'react';
+import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup.jsx';
+
+/**
+ * 计费类型筛选组件
+ * @param {string|'all'|0|1} filterQuotaType 当前值
+ * @param {Function} setFilterQuotaType setter
+ * @param {Function} t i18n
+ */
+const PricingQuotaTypes = ({ filterQuotaType, setFilterQuotaType, models = [], t }) => {
+  const qtyCount = (type) => models.filter(m => type === 'all' ? true : m.quota_type === type).length;
+
+  const items = [
+    { value: 'all', label: t('全部类型'), tagCount: qtyCount('all') },
+    { value: 0, label: t('按量计费'), tagCount: qtyCount(0) },
+    { value: 1, label: t('按次计费'), tagCount: qtyCount(1) },
+  ];
+
+  return (
+    <SelectableButtonGroup
+      title={t('计费类型')}
+      items={items}
+      activeValue={filterQuotaType}
+      onChange={setFilterQuotaType}
+      t={t}
+    />
+  );
+};
+
+export default PricingQuotaTypes; 

+ 23 - 1
web/src/hooks/model-pricing/useModelPricingData.js

@@ -32,6 +32,10 @@ export const useModelPricingData = () => {
   const [modalImageUrl, setModalImageUrl] = useState('');
   const [isModalOpenurl, setIsModalOpenurl] = useState(false);
   const [selectedGroup, setSelectedGroup] = useState('default');
+  // 用于 Table 的可用分组筛选,“all” 表示不过滤
+  const [filterGroup, setFilterGroup] = useState('all');
+  // 计费类型筛选: 'all' | 0 | 1
+  const [filterQuotaType, setFilterQuotaType] = useState('all');
   const [activeKey, setActiveKey] = useState('all');
   const [pageSize, setPageSize] = useState(10);
   const [currency, setCurrency] = useState('USD');
@@ -75,10 +79,22 @@ export const useModelPricingData = () => {
   const filteredModels = useMemo(() => {
     let result = models;
 
+    // 分类筛选
     if (activeKey !== 'all') {
       result = result.filter(model => modelCategories[activeKey].filter(model));
     }
 
+    // 分组筛选
+    if (filterGroup !== 'all') {
+      result = result.filter(model => model.enable_groups.includes(filterGroup));
+    }
+
+    // 计费类型筛选
+    if (filterQuotaType !== 'all') {
+      result = result.filter(model => model.quota_type === filterQuotaType);
+    }
+
+    // 搜索筛选
     if (filteredValue.length > 0) {
       const searchTerm = filteredValue[0].toLowerCase();
       result = result.filter(model =>
@@ -87,7 +103,7 @@ export const useModelPricingData = () => {
     }
 
     return result;
-  }, [activeKey, models, filteredValue]);
+  }, [activeKey, models, filteredValue, filterGroup, filterQuotaType]);
 
   const rowSelection = useMemo(
     () => ({
@@ -184,6 +200,8 @@ export const useModelPricingData = () => {
 
   const handleGroupClick = (group) => {
     setSelectedGroup(group);
+    // 同时将分组过滤设置为该分组
+    setFilterGroup(group);
     showInfo(
       t('当前查看的分组为:{{group}},倍率为:{{ratio}}', {
         group: group,
@@ -208,6 +226,10 @@ export const useModelPricingData = () => {
     setIsModalOpenurl,
     selectedGroup,
     setSelectedGroup,
+    filterGroup,
+    setFilterGroup,
+    filterQuotaType,
+    setFilterQuotaType,
     activeKey,
     setActiveKey,
     pageSize,

+ 2 - 2
web/src/pages/Pricing/index.js

@@ -21,9 +21,9 @@ import React from 'react';
 import ModelPricingPage from '../../components/table/model-pricing';
 
 const Pricing = () => (
-  <div className="mt-[60px] px-2">
+  <>
     <ModelPricingPage />
-  </div>
+  </>
 );
 
 export default Pricing;