Przeglądaj źródła

♻️ refactor: restructure ModelPricing component into modular architecture

- Break down monolithic ModelPricing.js (685 lines) into focused components:
  * ModelPricingHeader.jsx - top status card with pricing information
  * ModelPricingTabs.jsx - model category navigation tabs
  * ModelPricingFilters.jsx - search and action controls
  * ModelPricingTable.jsx - data table with pricing details
  * ModelPricingColumnDefs.js - table column definitions and renderers

- Create custom hook useModelPricingData.js for centralized state management:
  * Consolidate all business logic and API calls
  * Manage pricing calculations and data transformations
  * Handle search, filtering, and UI interactions

- Follow project conventions matching other table components:
  * Adopt same file structure as channels/, users/, tokens/ modules
  * Maintain consistent naming patterns and component organization
  * Preserve all original functionality including responsive design

- Update import paths:
  * Remove obsolete ModelPricing.js file
  * Update Pricing page to use new ModelPricingPage component
  * Fix missing import references

Benefits:
- Improved maintainability with single-responsibility components
- Enhanced code reusability and testability
- Better team collaboration with modular structure
- Consistent codebase architecture across all table components
t0ng7u 7 miesięcy temu
rodzic
commit
0c5c5823bf

+ 0 - 684
web/src/components/table/ModelPricing.js

@@ -1,684 +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, { useContext, useEffect, useRef, useMemo, useState } from 'react';
-import { API, copy, showError, showInfo, showSuccess, getModelCategories, renderModelTag, stringToColor } from '../../helpers';
-import { useTranslation } from 'react-i18next';
-
-import {
-  Input,
-  Layout,
-  Modal,
-  Space,
-  Table,
-  Tag,
-  Tooltip,
-  Popover,
-  ImagePreview,
-  Button,
-  Card,
-  Tabs,
-  TabPane,
-  Empty,
-  Switch,
-  Select
-} from '@douyinfe/semi-ui';
-import {
-  IllustrationNoResult,
-  IllustrationNoResultDark
-} from '@douyinfe/semi-illustrations';
-import {
-  IconVerify,
-  IconHelpCircle,
-  IconSearch,
-  IconCopy,
-  IconInfoCircle,
-  IconLayers
-} from '@douyinfe/semi-icons';
-import { UserContext } from '../../context/User/index.js';
-import { AlertCircle } from 'lucide-react';
-import { StatusContext } from '../../context/Status/index.js';
-
-const ModelPricing = () => {
-  const { t } = useTranslation();
-  const [filteredValue, setFilteredValue] = useState([]);
-  const compositionRef = useRef({ isComposition: false });
-  const [selectedRowKeys, setSelectedRowKeys] = useState([]);
-  const [modalImageUrl, setModalImageUrl] = useState('');
-  const [isModalOpenurl, setIsModalOpenurl] = useState(false);
-  const [selectedGroup, setSelectedGroup] = useState('default');
-  const [activeKey, setActiveKey] = useState('all');
-  const [pageSize, setPageSize] = useState(10);
-
-  const [currency, setCurrency] = useState('USD');
-  const [showWithRecharge, setShowWithRecharge] = useState(false);
-  const [tokenUnit, setTokenUnit] = useState('M');
-  const [statusState] = useContext(StatusContext);
-  // 充值汇率(price)与美元兑人民币汇率(usd_exchange_rate)
-  const priceRate = useMemo(() => statusState?.status?.price ?? 1, [statusState]);
-  const usdExchangeRate = useMemo(() => statusState?.status?.usd_exchange_rate ?? priceRate, [statusState, priceRate]);
-
-  const rowSelection = useMemo(
-    () => ({
-      onChange: (selectedRowKeys, selectedRows) => {
-        setSelectedRowKeys(selectedRowKeys);
-      },
-    }),
-    [],
-  );
-
-  const handleChange = (value) => {
-    if (compositionRef.current.isComposition) {
-      return;
-    }
-    const newFilteredValue = value ? [value] : [];
-    setFilteredValue(newFilteredValue);
-  };
-
-  const handleCompositionStart = () => {
-    compositionRef.current.isComposition = true;
-  };
-
-  const handleCompositionEnd = (event) => {
-    compositionRef.current.isComposition = false;
-    const value = event.target.value;
-    const newFilteredValue = value ? [value] : [];
-    setFilteredValue(newFilteredValue);
-  };
-
-  function renderQuotaType(type) {
-    switch (type) {
-      case 1:
-        return (
-          <Tag color='teal' shape='circle'>
-            {t('按次计费')}
-          </Tag>
-        );
-      case 0:
-        return (
-          <Tag color='violet' shape='circle'>
-            {t('按量计费')}
-          </Tag>
-        );
-      default:
-        return t('未知');
-    }
-  }
-
-  function renderAvailable(available) {
-    return available ? (
-      <Popover
-        content={
-          <div style={{ padding: 8 }}>{t('您的分组可以使用该模型')}</div>
-        }
-        position='top'
-        key={available}
-        className="bg-green-50"
-      >
-        <IconVerify style={{ color: 'rgb(22 163 74)' }} size='large' />
-      </Popover>
-    ) : null;
-  }
-
-  function renderSupportedEndpoints(endpoints) {
-    if (!endpoints || endpoints.length === 0) {
-      return null;
-    }
-    return (
-      <Space wrap>
-        {endpoints.map((endpoint, idx) => (
-          <Tag
-            key={endpoint}
-            color={stringToColor(endpoint)}
-            shape='circle'
-          >
-            {endpoint}
-          </Tag>
-        ))}
-      </Space>
-    );
-  }
-
-  const displayPrice = (usdPrice) => {
-    let priceInUSD = usdPrice;
-    if (showWithRecharge) {
-      priceInUSD = usdPrice * priceRate / usdExchangeRate;
-    }
-
-    if (currency === 'CNY') {
-      return `¥${(priceInUSD * usdExchangeRate).toFixed(3)}`;
-    }
-    return `$${priceInUSD.toFixed(3)}`;
-  };
-
-  const columns = [
-    {
-      title: t('可用性'),
-      dataIndex: 'available',
-      render: (text, record, index) => {
-        return renderAvailable(record.enable_groups.includes(selectedGroup));
-      },
-      sorter: (a, b) => {
-        const aAvailable = a.enable_groups.includes(selectedGroup);
-        const bAvailable = b.enable_groups.includes(selectedGroup);
-        return Number(aAvailable) - Number(bAvailable);
-      },
-      defaultSortOrder: 'descend',
-    },
-    {
-      title: t('可用端点类型'),
-      dataIndex: 'supported_endpoint_types',
-      render: (text, record, index) => {
-        return renderSupportedEndpoints(text);
-      },
-    },
-    {
-      title: t('模型名称'),
-      dataIndex: 'model_name',
-      render: (text, record, index) => {
-        return renderModelTag(text, {
-          onClick: () => {
-            copyText(text);
-          }
-        });
-      },
-      onFilter: (value, record) =>
-        record.model_name.toLowerCase().includes(value.toLowerCase()),
-      filteredValue,
-    },
-    {
-      title: t('计费类型'),
-      dataIndex: 'quota_type',
-      render: (text, record, index) => {
-        return renderQuotaType(parseInt(text));
-      },
-      sorter: (a, b) => a.quota_type - b.quota_type,
-    },
-    {
-      title: t('可用分组'),
-      dataIndex: 'enable_groups',
-      render: (text, record, index) => {
-        return (
-          <Space wrap>
-            {text.map((group) => {
-              if (usableGroup[group]) {
-                if (group === selectedGroup) {
-                  return (
-                    <Tag color='blue' shape='circle' prefixIcon={<IconVerify />}>
-                      {group}
-                    </Tag>
-                  );
-                } else {
-                  return (
-                    <Tag
-                      color='blue'
-                      shape='circle'
-                      onClick={() => {
-                        setSelectedGroup(group);
-                        showInfo(
-                          t('当前查看的分组为:{{group}},倍率为:{{ratio}}', {
-                            group: group,
-                            ratio: groupRatio[group],
-                          }),
-                        );
-                      }}
-                      className="cursor-pointer hover:opacity-80 transition-opacity"
-                    >
-                      {group}
-                    </Tag>
-                  );
-                }
-              }
-            })}
-          </Space>
-        );
-      },
-    },
-    {
-      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>
-        );
-        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;
-
-          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;
-      },
-    },
-  ];
-
-  const [models, setModels] = useState([]);
-  const [loading, setLoading] = useState(true);
-  const [userState] = useContext(UserContext);
-  const [groupRatio, setGroupRatio] = useState({});
-  const [usableGroup, setUsableGroup] = useState({});
-
-  const setModelsFormat = (models, groupRatio) => {
-    for (let i = 0; i < models.length; i++) {
-      models[i].key = models[i].model_name;
-      models[i].group_ratio = groupRatio[models[i].model_name];
-    }
-    models.sort((a, b) => {
-      return a.quota_type - b.quota_type;
-    });
-
-    models.sort((a, b) => {
-      if (a.model_name.startsWith('gpt') && !b.model_name.startsWith('gpt')) {
-        return -1;
-      } else if (
-        !a.model_name.startsWith('gpt') &&
-        b.model_name.startsWith('gpt')
-      ) {
-        return 1;
-      } else {
-        return a.model_name.localeCompare(b.model_name);
-      }
-    });
-
-    setModels(models);
-  };
-
-  const loadPricing = async () => {
-    setLoading(true);
-    let url = '/api/pricing';
-    const res = await API.get(url);
-    const { success, message, data, group_ratio, usable_group } = res.data;
-    if (success) {
-      setGroupRatio(group_ratio);
-      setUsableGroup(usable_group);
-      setSelectedGroup(userState.user ? userState.user.group : 'default');
-      setModelsFormat(data, group_ratio);
-    } else {
-      showError(message);
-    }
-    setLoading(false);
-  };
-
-  const refresh = async () => {
-    await loadPricing();
-  };
-
-  const copyText = async (text) => {
-    if (await copy(text)) {
-      showSuccess(t('已复制:') + text);
-    } else {
-      Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
-    }
-  };
-
-  useEffect(() => {
-    refresh().then();
-  }, []);
-
-  const modelCategories = getModelCategories(t);
-
-  const categoryCounts = useMemo(() => {
-    const counts = {};
-    if (models.length > 0) {
-      counts['all'] = models.length;
-
-      Object.entries(modelCategories).forEach(([key, category]) => {
-        if (key !== 'all') {
-          counts[key] = models.filter(model => category.filter(model)).length;
-        }
-      });
-    }
-    return counts;
-  }, [models, modelCategories]);
-
-  const availableCategories = useMemo(() => {
-    if (!models.length) return ['all'];
-
-    return Object.entries(modelCategories).filter(([key, category]) => {
-      if (key === 'all') return true;
-      return models.some(model => category.filter(model));
-    }).map(([key]) => key);
-  }, [models]);
-
-  const renderTabs = () => {
-    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>
-    );
-  };
-
-  const filteredModels = useMemo(() => {
-    let result = models;
-
-    if (activeKey !== 'all') {
-      result = result.filter(model => modelCategories[activeKey].filter(model));
-    }
-
-    if (filteredValue.length > 0) {
-      const searchTerm = filteredValue[0].toLowerCase();
-      result = result.filter(model =>
-        model.model_name.toLowerCase().includes(searchTerm)
-      );
-    }
-
-    return result;
-  }, [activeKey, models, filteredValue]);
-
-  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]);
-
-  const ModelTable = useMemo(() => (
-    <Card className="!rounded-xl overflow-hidden" bordered={false}>
-      <Table
-        columns={columns}
-        dataSource={filteredModels}
-        loading={loading}
-        rowSelection={rowSelection}
-        className="custom-table"
-        empty={
-          <Empty
-            image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
-            darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
-            description={t('搜索无结果')}
-            style={{ padding: 30 }}
-          />
-        }
-        pagination={{
-          defaultPageSize: 10,
-          pageSize: pageSize,
-          showSizeChanger: true,
-          pageSizeOptions: [10, 20, 50, 100],
-          onPageSizeChange: (size) => setPageSize(size),
-        }}
-      />
-    </Card>
-  ), [filteredModels, loading, columns, rowSelection, pageSize, t]);
-
-  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">
-                {/* 顶部状态卡片 */}
-                <Card
-                  className="!rounded-2xl !border-0 !shadow-md overflow-hidden mb-6"
-                  style={{
-                    background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 25%, #a855f7 50%, #c084fc 75%, #d8b4fe 100%)',
-                    position: 'relative'
-                  }}
-                  bodyStyle={{ padding: 0 }}
-                >
-                  <div className="relative p-6 sm:p-8" style={{ color: 'white' }}>
-                    <div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4 lg:gap-6">
-                      <div className="flex items-start">
-                        <div className="w-10 h-10 sm:w-12 sm:h-12 rounded-xl bg-white/10 flex items-center justify-center mr-3 sm:mr-4">
-                          <IconLayers size="extra-large" className="text-white" />
-                        </div>
-                        <div className="flex-1 min-w-0">
-                          <div className="text-base sm:text-lg font-semibold mb-1 sm:mb-2">
-                            {t('模型定价')}
-                          </div>
-                          <div className="text-sm text-white/80">
-                            {userState.user ? (
-                              <div className="flex items-center">
-                                <IconVerify className="mr-1.5 flex-shrink-0" size="small" />
-                                <span className="truncate">
-                                  {t('当前分组')}: {userState.user.group},{t('倍率')}: {groupRatio[userState.user.group]}
-                                </span>
-                              </div>
-                            ) : (
-                              <div className="flex items-center">
-                                <AlertCircle size={14} className="mr-1.5 flex-shrink-0" />
-                                <span className="truncate">
-                                  {t('未登录,使用默认分组倍率:')}{groupRatio['default']}
-                                </span>
-                              </div>
-                            )}
-                          </div>
-                        </div>
-                      </div>
-
-                      <div className="grid grid-cols-3 gap-2 sm:gap-3 mt-2 lg:mt-0">
-                        <div
-                          className="text-center px-2 py-2 sm:px-3 sm:py-2.5 bg-white/10 rounded-lg backdrop-blur-sm hover:bg-white/20 transition-colors duration-200"
-                          style={{ backdropFilter: 'blur(10px)' }}
-                        >
-                          <div className="text-xs text-white/70 mb-0.5">{t('分组倍率')}</div>
-                          <div className="text-sm sm:text-base font-semibold">{groupRatio[selectedGroup] || '1.0'}x</div>
-                        </div>
-                        <div
-                          className="text-center px-2 py-2 sm:px-3 sm:py-2.5 bg-white/10 rounded-lg backdrop-blur-sm hover:bg-white/20 transition-colors duration-200"
-                          style={{ backdropFilter: 'blur(10px)' }}
-                        >
-                          <div className="text-xs text-white/70 mb-0.5">{t('可用模型')}</div>
-                          <div className="text-sm sm:text-base font-semibold">
-                            {models.filter(m => m.enable_groups.includes(selectedGroup)).length}
-                          </div>
-                        </div>
-                        <div
-                          className="text-center px-2 py-2 sm:px-3 sm:py-2.5 bg-white/10 rounded-lg backdrop-blur-sm hover:bg-white/20 transition-colors duration-200"
-                          style={{ backdropFilter: 'blur(10px)' }}
-                        >
-                          <div className="text-xs text-white/70 mb-0.5">{t('计费类型')}</div>
-                          <div className="text-sm sm:text-base font-semibold">2</div>
-                        </div>
-                      </div>
-                    </div>
-
-                    {/* 计费说明 */}
-                    <div className="mt-4 sm:mt-5">
-                      <div className="flex items-start">
-                        <div
-                          className="w-full flex items-start space-x-2 px-3 py-2 sm:px-4 sm:py-2.5 rounded-lg text-xs sm:text-sm"
-                          style={{
-                            backgroundColor: 'rgba(255, 255, 255, 0.2)',
-                            color: 'white',
-                            backdropFilter: 'blur(10px)'
-                          }}
-                        >
-                          <IconInfoCircle className="flex-shrink-0 mt-0.5" size="small" />
-                          <span>
-                            {t('按量计费费用 = 分组倍率 × 模型倍率 × (提示token数 + 补全token数 × 补全倍率)/ 500000 (单位:美元)')}
-                          </span>
-                        </div>
-                      </div>
-                    </div>
-
-                    <div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-yellow-400 via-orange-400 to-red-400" style={{ opacity: 0.6 }}></div>
-                  </div>
-                </Card>
-
-                {/* 模型分类 Tabs */}
-                <div className="mb-6">
-                  {renderTabs()}
-
-                  {/* 搜索和表格区域 */}
-                  {SearchAndActions}
-                  {ModelTable}
-                </div>
-
-                {/* 倍率说明图预览 */}
-                <ImagePreview
-                  src={modalImageUrl}
-                  visible={isModalOpenurl}
-                  onVisibleChange={(visible) => setIsModalOpenurl(visible)}
-                />
-              </Card>
-            </div>
-          </div>
-        </Layout.Content>
-      </Layout>
-    </div>
-  );
-};
-
-export default ModelPricing;

+ 261 - 0
web/src/components/table/model-pricing/ModelPricingColumnDefs.js

@@ -0,0 +1,261 @@
+/*
+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 { Tag, Space, Tooltip, Switch } from '@douyinfe/semi-ui';
+import { IconVerify, IconHelpCircle } from '@douyinfe/semi-icons';
+import { Popover } from '@douyinfe/semi-ui';
+import { renderModelTag, stringToColor } from '../../../helpers';
+
+function renderQuotaType(type, t) {
+  switch (type) {
+    case 1:
+      return (
+        <Tag color='teal' shape='circle'>
+          {t('按次计费')}
+        </Tag>
+      );
+    case 0:
+      return (
+        <Tag color='violet' shape='circle'>
+          {t('按量计费')}
+        </Tag>
+      );
+    default:
+      return t('未知');
+  }
+}
+
+function renderAvailable(available, t) {
+  return available ? (
+    <Popover
+      content={
+        <div style={{ padding: 8 }}>{t('您的分组可以使用该模型')}</div>
+      }
+      position='top'
+      key={available}
+      className="bg-green-50"
+    >
+      <IconVerify style={{ color: 'rgb(22 163 74)' }} size='large' />
+    </Popover>
+  ) : null;
+}
+
+function renderSupportedEndpoints(endpoints) {
+  if (!endpoints || endpoints.length === 0) {
+    return null;
+  }
+  return (
+    <Space wrap>
+      {endpoints.map((endpoint, idx) => (
+        <Tag
+          key={endpoint}
+          color={stringToColor(endpoint)}
+          shape='circle'
+        >
+          {endpoint}
+        </Tag>
+      ))}
+    </Space>
+  );
+}
+
+export const getModelPricingColumns = ({
+  t,
+  selectedGroup,
+  usableGroup,
+  groupRatio,
+  copyText,
+  setModalImageUrl,
+  setIsModalOpenurl,
+  currency,
+  showWithRecharge,
+  tokenUnit,
+  setTokenUnit,
+  displayPrice,
+  handleGroupClick,
+}) => {
+  return [
+    {
+      title: t('可用性'),
+      dataIndex: 'available',
+      render: (text, record, index) => {
+        return renderAvailable(record.enable_groups.includes(selectedGroup), t);
+      },
+      sorter: (a, b) => {
+        const aAvailable = a.enable_groups.includes(selectedGroup);
+        const bAvailable = b.enable_groups.includes(selectedGroup);
+        return Number(aAvailable) - Number(bAvailable);
+      },
+      defaultSortOrder: 'descend',
+    },
+    {
+      title: t('可用端点类型'),
+      dataIndex: 'supported_endpoint_types',
+      render: (text, record, index) => {
+        return renderSupportedEndpoints(text);
+      },
+    },
+    {
+      title: t('模型名称'),
+      dataIndex: 'model_name',
+      render: (text, record, index) => {
+        return renderModelTag(text, {
+          onClick: () => {
+            copyText(text);
+          }
+        });
+      },
+      onFilter: (value, record) =>
+        record.model_name.toLowerCase().includes(value.toLowerCase()),
+    },
+    {
+      title: t('计费类型'),
+      dataIndex: 'quota_type',
+      render: (text, record, index) => {
+        return renderQuotaType(parseInt(text), t);
+      },
+      sorter: (a, b) => a.quota_type - b.quota_type,
+    },
+    {
+      title: t('可用分组'),
+      dataIndex: 'enable_groups',
+      render: (text, record, index) => {
+        return (
+          <Space wrap>
+            {text.map((group) => {
+              if (usableGroup[group]) {
+                if (group === selectedGroup) {
+                  return (
+                    <Tag key={group} color='blue' shape='circle' prefixIcon={<IconVerify />}>
+                      {group}
+                    </Tag>
+                  );
+                } else {
+                  return (
+                    <Tag
+                      key={group}
+                      color='blue'
+                      shape='circle'
+                      onClick={() => handleGroupClick(group)}
+                      className="cursor-pointer hover:opacity-80 transition-opacity"
+                    >
+                      {group}
+                    </Tag>
+                  );
+                }
+              }
+            })}
+          </Space>
+        );
+      },
+    },
+    {
+      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>
+        );
+        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;
+
+          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;
+      },
+    },
+  ];
+}; 

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

@@ -0,0 +1,87 @@
+/*
+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; 

+ 123 - 0
web/src/components/table/model-pricing/ModelPricingHeader.jsx

@@ -0,0 +1,123 @@
+/*
+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 { Card } from '@douyinfe/semi-ui';
+import { IconVerify, IconLayers, IconInfoCircle } from '@douyinfe/semi-icons';
+import { AlertCircle } from 'lucide-react';
+
+const ModelPricingHeader = ({
+  userState,
+  groupRatio,
+  selectedGroup,
+  models,
+  t
+}) => {
+  return (
+    <Card
+      className="!rounded-2xl !border-0 !shadow-md overflow-hidden mb-6"
+      style={{
+        background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 25%, #a855f7 50%, #c084fc 75%, #d8b4fe 100%)',
+        position: 'relative'
+      }}
+      bodyStyle={{ padding: 0 }}
+    >
+      <div className="relative p-6 sm:p-8" style={{ color: 'white' }}>
+        <div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4 lg:gap-6">
+          <div className="flex items-start">
+            <div className="w-10 h-10 sm:w-12 sm:h-12 rounded-xl bg-white/10 flex items-center justify-center mr-3 sm:mr-4">
+              <IconLayers size="extra-large" className="text-white" />
+            </div>
+            <div className="flex-1 min-w-0">
+              <div className="text-base sm:text-lg font-semibold mb-1 sm:mb-2">
+                {t('模型定价')}
+              </div>
+              <div className="text-sm text-white/80">
+                {userState.user ? (
+                  <div className="flex items-center">
+                    <IconVerify className="mr-1.5 flex-shrink-0" size="small" />
+                    <span className="truncate">
+                      {t('当前分组')}: {userState.user.group},{t('倍率')}: {groupRatio[userState.user.group]}
+                    </span>
+                  </div>
+                ) : (
+                  <div className="flex items-center">
+                    <AlertCircle size={14} className="mr-1.5 flex-shrink-0" />
+                    <span className="truncate">
+                      {t('未登录,使用默认分组倍率:')}{groupRatio['default']}
+                    </span>
+                  </div>
+                )}
+              </div>
+            </div>
+          </div>
+
+          <div className="grid grid-cols-3 gap-2 sm:gap-3 mt-2 lg:mt-0">
+            <div
+              className="text-center px-2 py-2 sm:px-3 sm:py-2.5 bg-white/10 rounded-lg backdrop-blur-sm hover:bg-white/20 transition-colors duration-200"
+              style={{ backdropFilter: 'blur(10px)' }}
+            >
+              <div className="text-xs text-white/70 mb-0.5">{t('分组倍率')}</div>
+              <div className="text-sm sm:text-base font-semibold">{groupRatio[selectedGroup] || '1.0'}x</div>
+            </div>
+            <div
+              className="text-center px-2 py-2 sm:px-3 sm:py-2.5 bg-white/10 rounded-lg backdrop-blur-sm hover:bg-white/20 transition-colors duration-200"
+              style={{ backdropFilter: 'blur(10px)' }}
+            >
+              <div className="text-xs text-white/70 mb-0.5">{t('可用模型')}</div>
+              <div className="text-sm sm:text-base font-semibold">
+                {models.filter(m => m.enable_groups.includes(selectedGroup)).length}
+              </div>
+            </div>
+            <div
+              className="text-center px-2 py-2 sm:px-3 sm:py-2.5 bg-white/10 rounded-lg backdrop-blur-sm hover:bg-white/20 transition-colors duration-200"
+              style={{ backdropFilter: 'blur(10px)' }}
+            >
+              <div className="text-xs text-white/70 mb-0.5">{t('计费类型')}</div>
+              <div className="text-sm sm:text-base font-semibold">2</div>
+            </div>
+          </div>
+        </div>
+
+        {/* 计费说明 */}
+        <div className="mt-4 sm:mt-5">
+          <div className="flex items-start">
+            <div
+              className="w-full flex items-start space-x-2 px-3 py-2 sm:px-4 sm:py-2.5 rounded-lg text-xs sm:text-sm"
+              style={{
+                backgroundColor: 'rgba(255, 255, 255, 0.2)',
+                color: 'white',
+                backdropFilter: 'blur(10px)'
+              }}
+            >
+              <IconInfoCircle className="flex-shrink-0 mt-0.5" size="small" />
+              <span>
+                {t('按量计费费用 = 分组倍率 × 模型倍率 × (提示token数 + 补全token数 × 补全倍率)/ 500000 (单位:美元)')}
+              </span>
+            </div>
+          </div>
+        </div>
+
+        <div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-yellow-400 via-orange-400 to-red-400" style={{ opacity: 0.6 }}></div>
+      </div>
+    </Card>
+  );
+};
+
+export default ModelPricingHeader; 

+ 124 - 0
web/src/components/table/model-pricing/ModelPricingTable.jsx

@@ -0,0 +1,124 @@
+/*
+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, Table, Empty } from '@douyinfe/semi-ui';
+import {
+  IllustrationNoResult,
+  IllustrationNoResultDark
+} from '@douyinfe/semi-illustrations';
+import { getModelPricingColumns } from './ModelPricingColumnDefs.js';
+
+const ModelPricingTable = ({
+  filteredModels,
+  loading,
+  rowSelection,
+  pageSize,
+  setPageSize,
+  selectedGroup,
+  usableGroup,
+  groupRatio,
+  copyText,
+  setModalImageUrl,
+  setIsModalOpenurl,
+  currency,
+  showWithRecharge,
+  tokenUnit,
+  setTokenUnit,
+  displayPrice,
+  filteredValue,
+  handleGroupClick,
+  t
+}) => {
+  const columns = useMemo(() => {
+    return getModelPricingColumns({
+      t,
+      selectedGroup,
+      usableGroup,
+      groupRatio,
+      copyText,
+      setModalImageUrl,
+      setIsModalOpenurl,
+      currency,
+      showWithRecharge,
+      tokenUnit,
+      setTokenUnit,
+      displayPrice,
+      handleGroupClick,
+    });
+  }, [
+    t,
+    selectedGroup,
+    usableGroup,
+    groupRatio,
+    copyText,
+    setModalImageUrl,
+    setIsModalOpenurl,
+    currency,
+    showWithRecharge,
+    tokenUnit,
+    setTokenUnit,
+    displayPrice,
+    handleGroupClick,
+  ]);
+
+  // 更新列定义中的 filteredValue
+  const tableColumns = useMemo(() => {
+    return columns.map(column => {
+      if (column.dataIndex === 'model_name') {
+        return {
+          ...column,
+          filteredValue
+        };
+      }
+      return column;
+    });
+  }, [columns, filteredValue]);
+
+  const ModelTable = useMemo(() => (
+    <Card className="!rounded-xl overflow-hidden" bordered={false}>
+      <Table
+        columns={tableColumns}
+        dataSource={filteredModels}
+        loading={loading}
+        rowSelection={rowSelection}
+        className="custom-table"
+        empty={
+          <Empty
+            image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
+            darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
+            description={t('搜索无结果')}
+            style={{ padding: 30 }}
+          />
+        }
+        pagination={{
+          defaultPageSize: 10,
+          pageSize: pageSize,
+          showSizeChanger: true,
+          pageSizeOptions: [10, 20, 50, 100],
+          onPageSizeChange: (size) => setPageSize(size),
+        }}
+      />
+    </Card>
+  ), [filteredModels, loading, tableColumns, rowSelection, pageSize, setPageSize, t]);
+
+  return ModelTable;
+};
+
+export default ModelPricingTable; 

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

@@ -0,0 +1,67 @@
+/*
+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; 

+ 66 - 0
web/src/components/table/model-pricing/index.jsx

@@ -0,0 +1,66 @@
+/*
+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, 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; 

+ 254 - 0
web/src/hooks/model-pricing/useModelPricingData.js

@@ -0,0 +1,254 @@
+/*
+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 { useState, useEffect, useContext, useRef, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { API, copy, showError, showInfo, showSuccess, getModelCategories } from '../../helpers';
+import { Modal } from '@douyinfe/semi-ui';
+import { UserContext } from '../../context/User/index.js';
+import { StatusContext } from '../../context/Status/index.js';
+
+export const useModelPricingData = () => {
+  const { t } = useTranslation();
+  const [filteredValue, setFilteredValue] = useState([]);
+  const compositionRef = useRef({ isComposition: false });
+  const [selectedRowKeys, setSelectedRowKeys] = useState([]);
+  const [modalImageUrl, setModalImageUrl] = useState('');
+  const [isModalOpenurl, setIsModalOpenurl] = useState(false);
+  const [selectedGroup, setSelectedGroup] = useState('default');
+  const [activeKey, setActiveKey] = useState('all');
+  const [pageSize, setPageSize] = useState(10);
+  const [currency, setCurrency] = useState('USD');
+  const [showWithRecharge, setShowWithRecharge] = useState(false);
+  const [tokenUnit, setTokenUnit] = useState('M');
+  const [models, setModels] = useState([]);
+  const [loading, setLoading] = useState(true);
+  const [groupRatio, setGroupRatio] = useState({});
+  const [usableGroup, setUsableGroup] = useState({});
+
+  const [statusState] = useContext(StatusContext);
+  const [userState] = useContext(UserContext);
+
+  // 充值汇率(price)与美元兑人民币汇率(usd_exchange_rate)
+  const priceRate = useMemo(() => statusState?.status?.price ?? 1, [statusState]);
+  const usdExchangeRate = useMemo(() => statusState?.status?.usd_exchange_rate ?? priceRate, [statusState, priceRate]);
+
+  const modelCategories = getModelCategories(t);
+
+  const categoryCounts = useMemo(() => {
+    const counts = {};
+    if (models.length > 0) {
+      counts['all'] = models.length;
+      Object.entries(modelCategories).forEach(([key, category]) => {
+        if (key !== 'all') {
+          counts[key] = models.filter(model => category.filter(model)).length;
+        }
+      });
+    }
+    return counts;
+  }, [models, modelCategories]);
+
+  const availableCategories = useMemo(() => {
+    if (!models.length) return ['all'];
+    return Object.entries(modelCategories).filter(([key, category]) => {
+      if (key === 'all') return true;
+      return models.some(model => category.filter(model));
+    }).map(([key]) => key);
+  }, [models]);
+
+  const filteredModels = useMemo(() => {
+    let result = models;
+
+    if (activeKey !== 'all') {
+      result = result.filter(model => modelCategories[activeKey].filter(model));
+    }
+
+    if (filteredValue.length > 0) {
+      const searchTerm = filteredValue[0].toLowerCase();
+      result = result.filter(model =>
+        model.model_name.toLowerCase().includes(searchTerm)
+      );
+    }
+
+    return result;
+  }, [activeKey, models, filteredValue]);
+
+  const rowSelection = useMemo(
+    () => ({
+      onChange: (selectedRowKeys, selectedRows) => {
+        setSelectedRowKeys(selectedRowKeys);
+      },
+    }),
+    [],
+  );
+
+  const displayPrice = (usdPrice) => {
+    let priceInUSD = usdPrice;
+    if (showWithRecharge) {
+      priceInUSD = usdPrice * priceRate / usdExchangeRate;
+    }
+
+    if (currency === 'CNY') {
+      return `¥${(priceInUSD * usdExchangeRate).toFixed(3)}`;
+    }
+    return `$${priceInUSD.toFixed(3)}`;
+  };
+
+  const setModelsFormat = (models, groupRatio) => {
+    for (let i = 0; i < models.length; i++) {
+      models[i].key = models[i].model_name;
+      models[i].group_ratio = groupRatio[models[i].model_name];
+    }
+    models.sort((a, b) => {
+      return a.quota_type - b.quota_type;
+    });
+
+    models.sort((a, b) => {
+      if (a.model_name.startsWith('gpt') && !b.model_name.startsWith('gpt')) {
+        return -1;
+      } else if (
+        !a.model_name.startsWith('gpt') &&
+        b.model_name.startsWith('gpt')
+      ) {
+        return 1;
+      } else {
+        return a.model_name.localeCompare(b.model_name);
+      }
+    });
+
+    setModels(models);
+  };
+
+  const loadPricing = async () => {
+    setLoading(true);
+    let url = '/api/pricing';
+    const res = await API.get(url);
+    const { success, message, data, group_ratio, usable_group } = res.data;
+    if (success) {
+      setGroupRatio(group_ratio);
+      setUsableGroup(usable_group);
+      setSelectedGroup(userState.user ? userState.user.group : 'default');
+      setModelsFormat(data, group_ratio);
+    } else {
+      showError(message);
+    }
+    setLoading(false);
+  };
+
+  const refresh = async () => {
+    await loadPricing();
+  };
+
+  const copyText = async (text) => {
+    if (await copy(text)) {
+      showSuccess(t('已复制:') + text);
+    } else {
+      Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
+    }
+  };
+
+  const handleChange = (value) => {
+    if (compositionRef.current.isComposition) {
+      return;
+    }
+    const newFilteredValue = value ? [value] : [];
+    setFilteredValue(newFilteredValue);
+  };
+
+  const handleCompositionStart = () => {
+    compositionRef.current.isComposition = true;
+  };
+
+  const handleCompositionEnd = (event) => {
+    compositionRef.current.isComposition = false;
+    const value = event.target.value;
+    const newFilteredValue = value ? [value] : [];
+    setFilteredValue(newFilteredValue);
+  };
+
+  const handleGroupClick = (group) => {
+    setSelectedGroup(group);
+    showInfo(
+      t('当前查看的分组为:{{group}},倍率为:{{ratio}}', {
+        group: group,
+        ratio: groupRatio[group],
+      }),
+    );
+  };
+
+  useEffect(() => {
+    refresh().then();
+  }, []);
+
+  return {
+    // 状态
+    filteredValue,
+    setFilteredValue,
+    selectedRowKeys,
+    setSelectedRowKeys,
+    modalImageUrl,
+    setModalImageUrl,
+    isModalOpenurl,
+    setIsModalOpenurl,
+    selectedGroup,
+    setSelectedGroup,
+    activeKey,
+    setActiveKey,
+    pageSize,
+    setPageSize,
+    currency,
+    setCurrency,
+    showWithRecharge,
+    setShowWithRecharge,
+    tokenUnit,
+    setTokenUnit,
+    models,
+    loading,
+    groupRatio,
+    usableGroup,
+
+    // 计算属性
+    priceRate,
+    usdExchangeRate,
+    modelCategories,
+    categoryCounts,
+    availableCategories,
+    filteredModels,
+    rowSelection,
+
+    // 用户和状态
+    userState,
+    statusState,
+
+    // 方法
+    displayPrice,
+    refresh,
+    copyText,
+    handleChange,
+    handleCompositionStart,
+    handleCompositionEnd,
+    handleGroupClick,
+
+    // 引用
+    compositionRef,
+
+    // 国际化
+    t,
+  };
+}; 

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

@@ -18,11 +18,11 @@ For commercial licensing, please contact support@quantumnous.com
 */
 */
 
 
 import React from 'react';
 import React from 'react';
-import ModelPricing from '../../components/table/ModelPricing.js';
+import ModelPricingPage from '../../components/table/model-pricing';
 
 
 const Pricing = () => (
 const Pricing = () => (
   <div className="mt-[60px] px-2">
   <div className="mt-[60px] px-2">
-    <ModelPricing />
+    <ModelPricingPage />
   </div>
   </div>
 );
 );