PricingCardView.jsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. /*
  2. Copyright (C) 2025 QuantumNous
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU Affero General Public License as
  5. published by the Free Software Foundation, either version 3 of the
  6. License, or (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU Affero General Public License for more details.
  11. You should have received a copy of the GNU Affero General Public License
  12. along with this program. If not, see <https://www.gnu.org/licenses/>.
  13. For commercial licensing, please contact support@quantumnous.com
  14. */
  15. import React from 'react';
  16. import { Card, Tag, Tooltip, Checkbox, Empty, Pagination, Button, Avatar } from '@douyinfe/semi-ui';
  17. import { IconHelpCircle, IconCopy } from '@douyinfe/semi-icons';
  18. import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
  19. import { stringToColor, getModelCategories, calculateModelPrice, formatPriceInfo } from '../../../../../helpers';
  20. import PricingCardSkeleton from './PricingCardSkeleton';
  21. import { useMinimumLoadingTime } from '../../../../../hooks/common/useMinimumLoadingTime';
  22. const CARD_STYLES = {
  23. container: "w-12 h-12 rounded-2xl flex items-center justify-center relative shadow-sm",
  24. icon: "w-8 h-8 flex items-center justify-center",
  25. selected: "border-blue-500 bg-blue-50",
  26. default: "border-gray-200 hover:border-gray-300"
  27. };
  28. const PricingCardView = ({
  29. filteredModels,
  30. loading,
  31. rowSelection,
  32. pageSize,
  33. setPageSize,
  34. currentPage,
  35. setCurrentPage,
  36. selectedGroup,
  37. groupRatio,
  38. copyText,
  39. setModalImageUrl,
  40. setIsModalOpenurl,
  41. currency,
  42. tokenUnit,
  43. displayPrice,
  44. showRatio,
  45. t,
  46. selectedRowKeys = [],
  47. setSelectedRowKeys,
  48. activeKey,
  49. availableCategories,
  50. }) => {
  51. const showSkeleton = useMinimumLoadingTime(loading);
  52. const startIndex = (currentPage - 1) * pageSize;
  53. const endIndex = startIndex + pageSize;
  54. const paginatedModels = filteredModels.slice(startIndex, endIndex);
  55. const getModelKey = (model) => model.key ?? model.model_name ?? model.id;
  56. const handleCheckboxChange = (model, checked) => {
  57. if (!setSelectedRowKeys) return;
  58. const modelKey = getModelKey(model);
  59. const newKeys = checked
  60. ? Array.from(new Set([...selectedRowKeys, modelKey]))
  61. : selectedRowKeys.filter((key) => key !== modelKey);
  62. setSelectedRowKeys(newKeys);
  63. rowSelection?.onChange?.(newKeys, null);
  64. };
  65. // 获取模型图标
  66. const getModelIcon = (modelName) => {
  67. const categories = getModelCategories(t);
  68. let icon = null;
  69. // 遍历分类,找到匹配的模型图标
  70. for (const [key, category] of Object.entries(categories)) {
  71. if (key !== 'all' && category.filter({ model_name: modelName })) {
  72. icon = category.icon;
  73. break;
  74. }
  75. }
  76. // 如果找到了匹配的图标,返回包装后的图标
  77. if (icon) {
  78. return (
  79. <div className={CARD_STYLES.container}>
  80. <div className={CARD_STYLES.icon}>
  81. {React.cloneElement(icon, { size: 32 })}
  82. </div>
  83. </div>
  84. );
  85. }
  86. const avatarText = modelName.slice(0, 2).toUpperCase();
  87. return (
  88. <div className={CARD_STYLES.container}>
  89. <Avatar
  90. size="large"
  91. style={{
  92. width: 48,
  93. height: 48,
  94. borderRadius: 16,
  95. fontSize: 16,
  96. fontWeight: 'bold'
  97. }}
  98. >
  99. {avatarText}
  100. </Avatar>
  101. </div>
  102. );
  103. };
  104. // 获取模型描述
  105. const getModelDescription = (modelName) => {
  106. return t('高性能AI模型,适用于各种文本生成和理解任务。');
  107. };
  108. // 渲染价格信息
  109. const renderPriceInfo = (record) => {
  110. const priceData = calculateModelPrice({
  111. record,
  112. selectedGroup,
  113. groupRatio,
  114. tokenUnit,
  115. displayPrice,
  116. currency
  117. });
  118. return formatPriceInfo(priceData, t);
  119. };
  120. // 渲染标签
  121. const renderTags = (record) => {
  122. const tags = [];
  123. // 计费类型标签
  124. const billingType = record.quota_type === 1 ? 'teal' : 'violet';
  125. const billingText = record.quota_type === 1 ? t('按次计费') : t('按量计费');
  126. tags.push(
  127. <Tag shape='circle' key="billing" color={billingType} size='small'>
  128. {billingText}
  129. </Tag>
  130. );
  131. // 热门模型标签
  132. if (record.model_name.includes('gpt')) {
  133. tags.push(
  134. <Tag shape='circle' key="hot" color='red' size='small'>
  135. {t('热')}
  136. </Tag>
  137. );
  138. }
  139. // 端点类型标签
  140. if (record.supported_endpoint_types?.length > 0) {
  141. record.supported_endpoint_types.slice(0, 2).forEach((endpoint, index) => {
  142. tags.push(
  143. <Tag shape='circle' key={`endpoint-${index}`} color={stringToColor(endpoint)} size='small'>
  144. {endpoint}
  145. </Tag>
  146. );
  147. });
  148. }
  149. // 上下文长度标签
  150. const contextMatch = record.model_name.match(/(\d+)k/i);
  151. const contextSize = contextMatch ? contextMatch[1] + 'K' : '4K';
  152. tags.push(
  153. <Tag shape='circle' key="context" color='blue' size='small'>
  154. {contextSize}
  155. </Tag>
  156. );
  157. return tags;
  158. };
  159. // 显示骨架屏
  160. if (showSkeleton) {
  161. return (
  162. <PricingCardSkeleton
  163. rowSelection={!!rowSelection}
  164. showRatio={showRatio}
  165. />
  166. );
  167. }
  168. if (!filteredModels || filteredModels.length === 0) {
  169. return (
  170. <div className="flex justify-center items-center py-20">
  171. <Empty
  172. image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
  173. darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
  174. description={t('搜索无结果')}
  175. />
  176. </div>
  177. );
  178. }
  179. return (
  180. <div className="p-4">
  181. <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
  182. {paginatedModels.map((model, index) => {
  183. const modelKey = getModelKey(model);
  184. const isSelected = selectedRowKeys.includes(modelKey);
  185. return (
  186. <Card
  187. key={modelKey || index}
  188. className={`!rounded-2xl transition-all duration-200 hover:shadow-lg border ${isSelected ? CARD_STYLES.selected : CARD_STYLES.default
  189. }`}
  190. bodyStyle={{ padding: '24px' }}
  191. >
  192. {/* 头部:图标 + 模型名称 + 操作按钮 */}
  193. <div className="flex items-start justify-between mb-3">
  194. <div className="flex items-start space-x-3 flex-1 min-w-0">
  195. {getModelIcon(model.model_name)}
  196. <div className="flex-1 min-w-0">
  197. <h3 className="text-lg font-bold text-gray-900 truncate">
  198. {model.model_name}
  199. </h3>
  200. <div className="flex items-center gap-3 text-xs mt-1">
  201. {renderPriceInfo(model)}
  202. </div>
  203. </div>
  204. </div>
  205. <div className="flex items-center space-x-2 ml-3">
  206. {/* 复制按钮 */}
  207. <Button
  208. size="small"
  209. type="tertiary"
  210. icon={<IconCopy />}
  211. onClick={() => copyText(model.model_name)}
  212. />
  213. {/* 选择框 */}
  214. {rowSelection && (
  215. <Checkbox
  216. checked={isSelected}
  217. onChange={(e) => handleCheckboxChange(model, e.target.checked)}
  218. />
  219. )}
  220. </div>
  221. </div>
  222. {/* 模型描述 */}
  223. <div className="mb-4">
  224. <p
  225. className="text-xs line-clamp-2 leading-relaxed"
  226. style={{ color: 'var(--semi-color-text-2)' }}
  227. >
  228. {getModelDescription(model.model_name)}
  229. </p>
  230. </div>
  231. {/* 标签区域 */}
  232. <div className="flex flex-wrap gap-2">
  233. {renderTags(model)}
  234. </div>
  235. {/* 倍率信息(可选) */}
  236. {showRatio && (
  237. <div className="mt-4 pt-3 border-t border-gray-100">
  238. <div className="flex items-center space-x-1 mb-2">
  239. <span className="text-xs font-medium text-gray-700">{t('倍率信息')}</span>
  240. <Tooltip content={t('倍率是为了方便换算不同价格的模型')}>
  241. <IconHelpCircle
  242. className="text-blue-500 cursor-pointer"
  243. size="small"
  244. onClick={() => {
  245. setModalImageUrl('/ratio.png');
  246. setIsModalOpenurl(true);
  247. }}
  248. />
  249. </Tooltip>
  250. </div>
  251. <div className="grid grid-cols-3 gap-2 text-xs text-gray-600">
  252. <div>
  253. {t('模型')}: {model.quota_type === 0 ? model.model_ratio : t('无')}
  254. </div>
  255. <div>
  256. {t('补全')}: {model.quota_type === 0 ? parseFloat(model.completion_ratio.toFixed(3)) : t('无')}
  257. </div>
  258. <div>
  259. {t('分组')}: {groupRatio[selectedGroup]}
  260. </div>
  261. </div>
  262. </div>
  263. )}
  264. </Card>
  265. );
  266. })}
  267. </div>
  268. {/* 分页 */}
  269. {filteredModels.length > 0 && (
  270. <div className="flex justify-center mt-6 pt-4 border-t pricing-pagination-divider">
  271. <Pagination
  272. currentPage={currentPage}
  273. pageSize={pageSize}
  274. total={filteredModels.length}
  275. showSizeChanger={true}
  276. pageSizeOptions={[10, 20, 50, 100]}
  277. onPageChange={(page) => setCurrentPage(page)}
  278. onPageSizeChange={(size) => {
  279. setPageSize(size);
  280. setCurrentPage(1);
  281. }}
  282. />
  283. </div>
  284. )}
  285. </div>
  286. );
  287. };
  288. export default PricingCardView;