PricingCardView.jsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  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, calculateModelPrice, formatPriceInfo, getLobeHubIcon } from '../../../../../helpers';
  20. import PricingCardSkeleton from './PricingCardSkeleton';
  21. import { useMinimumLoadingTime } from '../../../../../hooks/common/useMinimumLoadingTime';
  22. import { renderLimitedItems } from '../../../../common/ui/RenderUtils';
  23. import { useIsMobile } from '../../../../../hooks/common/useIsMobile';
  24. const CARD_STYLES = {
  25. container: "w-12 h-12 rounded-2xl flex items-center justify-center relative shadow-md",
  26. icon: "w-8 h-8 flex items-center justify-center",
  27. selected: "border-blue-500 bg-blue-50",
  28. default: "border-gray-200 hover:border-gray-300"
  29. };
  30. const PricingCardView = ({
  31. filteredModels,
  32. loading,
  33. rowSelection,
  34. pageSize,
  35. setPageSize,
  36. currentPage,
  37. setCurrentPage,
  38. selectedGroup,
  39. groupRatio,
  40. copyText,
  41. setModalImageUrl,
  42. setIsModalOpenurl,
  43. currency,
  44. tokenUnit,
  45. displayPrice,
  46. showRatio,
  47. t,
  48. selectedRowKeys = [],
  49. setSelectedRowKeys,
  50. openModelDetail,
  51. }) => {
  52. const showSkeleton = useMinimumLoadingTime(loading);
  53. const startIndex = (currentPage - 1) * pageSize;
  54. const paginatedModels = filteredModels.slice(startIndex, startIndex + pageSize);
  55. const getModelKey = (model) => model.key ?? model.model_name ?? model.id;
  56. const isMobile = useIsMobile();
  57. const handleCheckboxChange = (model, checked) => {
  58. if (!setSelectedRowKeys) return;
  59. const modelKey = getModelKey(model);
  60. const newKeys = checked
  61. ? Array.from(new Set([...selectedRowKeys, modelKey]))
  62. : selectedRowKeys.filter((key) => key !== modelKey);
  63. setSelectedRowKeys(newKeys);
  64. rowSelection?.onChange?.(newKeys, null);
  65. };
  66. // 获取模型图标
  67. const getModelIcon = (model) => {
  68. if (!model || !model.model_name) {
  69. return (
  70. <div className={CARD_STYLES.container}>
  71. <Avatar size='large'>?</Avatar>
  72. </div>
  73. );
  74. }
  75. // 1) 优先使用模型自定义图标
  76. if (model.icon) {
  77. return (
  78. <div className={CARD_STYLES.container}>
  79. <div className={CARD_STYLES.icon}>
  80. {getLobeHubIcon(model.icon, 32)}
  81. </div>
  82. </div>
  83. );
  84. }
  85. // 2) 退化为供应商图标
  86. if (model.vendor_icon) {
  87. return (
  88. <div className={CARD_STYLES.container}>
  89. <div className={CARD_STYLES.icon}>
  90. {getLobeHubIcon(model.vendor_icon, 32)}
  91. </div>
  92. </div>
  93. );
  94. }
  95. // 如果没有供应商图标,使用模型名称生成头像
  96. const avatarText = model.model_name.slice(0, 2).toUpperCase();
  97. return (
  98. <div className={CARD_STYLES.container}>
  99. <Avatar
  100. size="large"
  101. style={{
  102. width: 48,
  103. height: 48,
  104. borderRadius: 16,
  105. fontSize: 16,
  106. fontWeight: 'bold'
  107. }}
  108. >
  109. {avatarText}
  110. </Avatar>
  111. </div>
  112. );
  113. };
  114. // 获取模型描述
  115. const getModelDescription = (record) => {
  116. return record.description || '';
  117. };
  118. // 渲染标签
  119. const renderTags = (record) => {
  120. // 计费类型标签(左边)
  121. let billingTag = (
  122. <Tag key="billing" shape='circle' color='white' size='small'>
  123. -
  124. </Tag>
  125. );
  126. if (record.quota_type === 1) {
  127. billingTag = (
  128. <Tag key="billing" shape='circle' color='teal' size='small'>
  129. {t('按次计费')}
  130. </Tag>
  131. );
  132. } else if (record.quota_type === 0) {
  133. billingTag = (
  134. <Tag key="billing" shape='circle' color='violet' size='small'>
  135. {t('按量计费')}
  136. </Tag>
  137. );
  138. }
  139. // 自定义标签(右边)
  140. const customTags = [];
  141. if (record.tags) {
  142. const tagArr = record.tags.split(',').filter(Boolean);
  143. tagArr.forEach((tg, idx) => {
  144. customTags.push(
  145. <Tag key={`custom-${idx}`} shape='circle' color={stringToColor(tg)} size='small'>
  146. {tg}
  147. </Tag>
  148. );
  149. });
  150. }
  151. return (
  152. <div className="flex items-center justify-between">
  153. <div className="flex items-center gap-2">
  154. {billingTag}
  155. </div>
  156. <div className="flex items-center gap-1">
  157. {customTags.length > 0 && renderLimitedItems({
  158. items: customTags.map((tag, idx) => ({ key: `custom-${idx}`, element: tag })),
  159. renderItem: (item, idx) => item.element,
  160. maxDisplay: 3
  161. })}
  162. </div>
  163. </div>
  164. );
  165. };
  166. // 显示骨架屏
  167. if (showSkeleton) {
  168. return (
  169. <PricingCardSkeleton
  170. rowSelection={!!rowSelection}
  171. showRatio={showRatio}
  172. />
  173. );
  174. }
  175. if (!filteredModels || filteredModels.length === 0) {
  176. return (
  177. <div className="flex justify-center items-center py-20">
  178. <Empty
  179. image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
  180. darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
  181. description={t('搜索无结果')}
  182. />
  183. </div>
  184. );
  185. }
  186. return (
  187. <div className="px-4">
  188. <div className="grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 gap-4">
  189. {paginatedModels.map((model, index) => {
  190. const modelKey = getModelKey(model);
  191. const isSelected = selectedRowKeys.includes(modelKey);
  192. const priceData = calculateModelPrice({
  193. record: model,
  194. selectedGroup,
  195. groupRatio,
  196. tokenUnit,
  197. displayPrice,
  198. currency,
  199. });
  200. return (
  201. <Card
  202. key={modelKey || index}
  203. className={`!rounded-2xl transition-all duration-200 hover:shadow-lg border cursor-pointer ${isSelected ? CARD_STYLES.selected : CARD_STYLES.default}`}
  204. bodyStyle={{ height: '100%' }}
  205. onClick={() => openModelDetail && openModelDetail(model)}
  206. >
  207. <div className="flex flex-col h-full">
  208. {/* 头部:图标 + 模型名称 + 操作按钮 */}
  209. <div className="flex items-start justify-between mb-3">
  210. <div className="flex items-start space-x-3 flex-1 min-w-0">
  211. {getModelIcon(model)}
  212. <div className="flex-1 min-w-0">
  213. <h3 className="text-lg font-bold text-gray-900 truncate">
  214. {model.model_name}
  215. </h3>
  216. <div className="flex items-center gap-3 text-xs mt-1">
  217. {formatPriceInfo(priceData, t)}
  218. </div>
  219. </div>
  220. </div>
  221. <div className="flex items-center space-x-2 ml-3">
  222. {/* 复制按钮 */}
  223. <Button
  224. size="small"
  225. theme="outline"
  226. type="tertiary"
  227. icon={<IconCopy />}
  228. onClick={(e) => {
  229. e.stopPropagation();
  230. copyText(model.model_name);
  231. }}
  232. />
  233. {/* 选择框 */}
  234. {rowSelection && (
  235. <Checkbox
  236. checked={isSelected}
  237. onChange={(e) => {
  238. e.stopPropagation();
  239. handleCheckboxChange(model, e.target.checked);
  240. }}
  241. />
  242. )}
  243. </div>
  244. </div>
  245. {/* 模型描述 - 占据剩余空间 */}
  246. <div className="flex-1 mb-4">
  247. <p
  248. className="text-xs line-clamp-2 leading-relaxed"
  249. style={{ color: 'var(--semi-color-text-2)' }}
  250. >
  251. {getModelDescription(model)}
  252. </p>
  253. </div>
  254. {/* 底部区域 */}
  255. <div className="mt-auto">
  256. {/* 标签区域 */}
  257. <div className="mb-3">
  258. {renderTags(model)}
  259. </div>
  260. {/* 倍率信息(可选) */}
  261. {showRatio && (
  262. <div
  263. className="pt-3 border-t border-dashed"
  264. style={{ borderColor: 'var(--semi-color-border)' }}
  265. >
  266. <div className="flex items-center space-x-1 mb-2">
  267. <span className="text-xs font-medium text-gray-700">{t('倍率信息')}</span>
  268. <Tooltip content={t('倍率是为了方便换算不同价格的模型')}>
  269. <IconHelpCircle
  270. className="text-blue-500 cursor-pointer"
  271. size="small"
  272. onClick={(e) => {
  273. e.stopPropagation();
  274. setModalImageUrl('/ratio.png');
  275. setIsModalOpenurl(true);
  276. }}
  277. />
  278. </Tooltip>
  279. </div>
  280. <div className="grid grid-cols-3 gap-2 text-xs text-gray-600">
  281. <div>
  282. {t('模型')}: {model.quota_type === 0 ? model.model_ratio : t('无')}
  283. </div>
  284. <div>
  285. {t('补全')}: {model.quota_type === 0 ? parseFloat(model.completion_ratio.toFixed(3)) : t('无')}
  286. </div>
  287. <div>
  288. {t('分组')}: {priceData?.usedGroupRatio ?? '-'}
  289. </div>
  290. </div>
  291. </div>
  292. )}
  293. </div>
  294. </div>
  295. </Card>
  296. );
  297. })}
  298. </div>
  299. {/* 分页 */}
  300. {filteredModels.length > 0 && (
  301. <div className="flex justify-center mt-6 py-4 border-t pricing-pagination-divider">
  302. <Pagination
  303. currentPage={currentPage}
  304. pageSize={pageSize}
  305. total={filteredModels.length}
  306. showSizeChanger={true}
  307. pageSizeOptions={[10, 20, 50, 100]}
  308. size={isMobile ? 'small' : 'default'}
  309. showQuickJumper={isMobile}
  310. onPageChange={(page) => setCurrentPage(page)}
  311. onPageSizeChange={(size) => {
  312. setPageSize(size);
  313. setCurrentPage(1);
  314. }}
  315. />
  316. </div>
  317. )}
  318. </div>
  319. );
  320. };
  321. export default PricingCardView;