ModelPricing.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650
  1. import React, { useContext, useEffect, useRef, useMemo, useState } from 'react';
  2. import { API, copy, showError, showInfo, showSuccess, getModelCategories, renderModelTag } from '../../helpers';
  3. import { useTranslation } from 'react-i18next';
  4. import {
  5. Input,
  6. Layout,
  7. Modal,
  8. Space,
  9. Table,
  10. Tag,
  11. Tooltip,
  12. Popover,
  13. ImagePreview,
  14. Button,
  15. Card,
  16. Tabs,
  17. TabPane,
  18. Dropdown,
  19. Empty
  20. } from '@douyinfe/semi-ui';
  21. import {
  22. IllustrationNoResult,
  23. IllustrationNoResultDark
  24. } from '@douyinfe/semi-illustrations';
  25. import {
  26. IconVerify,
  27. IconHelpCircle,
  28. IconSearch,
  29. IconCopy,
  30. IconInfoCircle,
  31. IconLayers
  32. } from '@douyinfe/semi-icons';
  33. import { UserContext } from '../../context/User/index.js';
  34. import { AlertCircle } from 'lucide-react';
  35. const ModelPricing = () => {
  36. const { t } = useTranslation();
  37. const [filteredValue, setFilteredValue] = useState([]);
  38. const compositionRef = useRef({ isComposition: false });
  39. const [selectedRowKeys, setSelectedRowKeys] = useState([]);
  40. const [modalImageUrl, setModalImageUrl] = useState('');
  41. const [isModalOpenurl, setIsModalOpenurl] = useState(false);
  42. const [selectedGroup, setSelectedGroup] = useState('default');
  43. const [activeKey, setActiveKey] = useState('all');
  44. const [pageSize, setPageSize] = useState(10);
  45. const rowSelection = useMemo(
  46. () => ({
  47. onChange: (selectedRowKeys, selectedRows) => {
  48. setSelectedRowKeys(selectedRowKeys);
  49. },
  50. }),
  51. [],
  52. );
  53. const handleChange = (value) => {
  54. if (compositionRef.current.isComposition) {
  55. return;
  56. }
  57. const newFilteredValue = value ? [value] : [];
  58. setFilteredValue(newFilteredValue);
  59. };
  60. const handleCompositionStart = () => {
  61. compositionRef.current.isComposition = true;
  62. };
  63. const handleCompositionEnd = (event) => {
  64. compositionRef.current.isComposition = false;
  65. const value = event.target.value;
  66. const newFilteredValue = value ? [value] : [];
  67. setFilteredValue(newFilteredValue);
  68. };
  69. function renderQuotaType(type) {
  70. switch (type) {
  71. case 1:
  72. return (
  73. <Tag color='teal' size='large' shape='circle'>
  74. {t('按次计费')}
  75. </Tag>
  76. );
  77. case 0:
  78. return (
  79. <Tag color='violet' size='large' shape='circle'>
  80. {t('按量计费')}
  81. </Tag>
  82. );
  83. default:
  84. return t('未知');
  85. }
  86. }
  87. function renderAvailable(available) {
  88. return available ? (
  89. <Popover
  90. content={
  91. <div style={{ padding: 8 }}>{t('您的分组可以使用该模型')}</div>
  92. }
  93. position='top'
  94. key={available}
  95. className="bg-green-50"
  96. >
  97. <IconVerify style={{ color: 'rgb(22 163 74)' }} size='large' />
  98. </Popover>
  99. ) : null;
  100. }
  101. const columns = [
  102. {
  103. title: t('可用性'),
  104. dataIndex: 'available',
  105. render: (text, record, index) => {
  106. return renderAvailable(record.enable_groups.includes(selectedGroup));
  107. },
  108. sorter: (a, b) => {
  109. const aAvailable = a.enable_groups.includes(selectedGroup);
  110. const bAvailable = b.enable_groups.includes(selectedGroup);
  111. return Number(aAvailable) - Number(bAvailable);
  112. },
  113. defaultSortOrder: 'descend',
  114. },
  115. {
  116. title: t('模型名称'),
  117. dataIndex: 'model_name',
  118. render: (text, record, index) => {
  119. return renderModelTag(text, {
  120. onClick: () => {
  121. copyText(text);
  122. }
  123. });
  124. },
  125. onFilter: (value, record) =>
  126. record.model_name.toLowerCase().includes(value.toLowerCase()),
  127. filteredValue,
  128. },
  129. {
  130. title: t('计费类型'),
  131. dataIndex: 'quota_type',
  132. render: (text, record, index) => {
  133. return renderQuotaType(parseInt(text));
  134. },
  135. sorter: (a, b) => a.quota_type - b.quota_type,
  136. },
  137. {
  138. title: t('可用分组'),
  139. dataIndex: 'enable_groups',
  140. render: (text, record, index) => {
  141. return (
  142. <Space wrap>
  143. {text.map((group) => {
  144. if (usableGroup[group]) {
  145. if (group === selectedGroup) {
  146. return (
  147. <Tag color='blue' size='large' shape='circle' prefixIcon={<IconVerify />}>
  148. {group}
  149. </Tag>
  150. );
  151. } else {
  152. return (
  153. <Tag
  154. color='blue'
  155. size='large'
  156. onClick={() => {
  157. setSelectedGroup(group);
  158. showInfo(
  159. t('当前查看的分组为:{{group}},倍率为:{{ratio}}', {
  160. group: group,
  161. ratio: groupRatio[group],
  162. }),
  163. );
  164. }}
  165. className="cursor-pointer hover:opacity-80 transition-opacity !rounded-full"
  166. >
  167. {group}
  168. </Tag>
  169. );
  170. }
  171. }
  172. })}
  173. </Space>
  174. );
  175. },
  176. },
  177. {
  178. title: () => (
  179. <div className="flex items-center space-x-1">
  180. <span>{t('倍率')}</span>
  181. <Tooltip content={t('倍率是为了方便换算不同价格的模型')}>
  182. <IconHelpCircle
  183. className="text-blue-500 cursor-pointer"
  184. onClick={() => {
  185. setModalImageUrl('/ratio.png');
  186. setIsModalOpenurl(true);
  187. }}
  188. />
  189. </Tooltip>
  190. </div>
  191. ),
  192. dataIndex: 'model_ratio',
  193. render: (text, record, index) => {
  194. let content = text;
  195. let completionRatio = parseFloat(record.completion_ratio.toFixed(3));
  196. content = (
  197. <div className="space-y-1">
  198. <div className="text-gray-700">
  199. {t('模型倍率')}:{record.quota_type === 0 ? text : t('无')}
  200. </div>
  201. <div className="text-gray-700">
  202. {t('补全倍率')}:
  203. {record.quota_type === 0 ? completionRatio : t('无')}
  204. </div>
  205. <div className="text-gray-700">
  206. {t('分组倍率')}:{groupRatio[selectedGroup]}
  207. </div>
  208. </div>
  209. );
  210. return content;
  211. },
  212. },
  213. {
  214. title: t('模型价格'),
  215. dataIndex: 'model_price',
  216. render: (text, record, index) => {
  217. let content = text;
  218. if (record.quota_type === 0) {
  219. let inputRatioPrice =
  220. record.model_ratio * 2 * groupRatio[selectedGroup];
  221. let completionRatioPrice =
  222. record.model_ratio *
  223. record.completion_ratio *
  224. 2 *
  225. groupRatio[selectedGroup];
  226. content = (
  227. <div className="space-y-1">
  228. <div className="text-gray-700">
  229. {t('提示')} ${inputRatioPrice.toFixed(3)} / 1M tokens
  230. </div>
  231. <div className="text-gray-700">
  232. {t('补全')} ${completionRatioPrice.toFixed(3)} / 1M tokens
  233. </div>
  234. </div>
  235. );
  236. } else {
  237. let price = parseFloat(text) * groupRatio[selectedGroup];
  238. content = (
  239. <div className="text-gray-700">
  240. {t('模型价格')}:${price.toFixed(3)}
  241. </div>
  242. );
  243. }
  244. return content;
  245. },
  246. },
  247. ];
  248. const [models, setModels] = useState([]);
  249. const [loading, setLoading] = useState(true);
  250. const [userState, userDispatch] = useContext(UserContext);
  251. const [groupRatio, setGroupRatio] = useState({});
  252. const [usableGroup, setUsableGroup] = useState({});
  253. const setModelsFormat = (models, groupRatio) => {
  254. for (let i = 0; i < models.length; i++) {
  255. models[i].key = models[i].model_name;
  256. models[i].group_ratio = groupRatio[models[i].model_name];
  257. }
  258. models.sort((a, b) => {
  259. return a.quota_type - b.quota_type;
  260. });
  261. models.sort((a, b) => {
  262. if (a.model_name.startsWith('gpt') && !b.model_name.startsWith('gpt')) {
  263. return -1;
  264. } else if (
  265. !a.model_name.startsWith('gpt') &&
  266. b.model_name.startsWith('gpt')
  267. ) {
  268. return 1;
  269. } else {
  270. return a.model_name.localeCompare(b.model_name);
  271. }
  272. });
  273. setModels(models);
  274. };
  275. const loadPricing = async () => {
  276. setLoading(true);
  277. let url = '/api/pricing';
  278. const res = await API.get(url);
  279. const { success, message, data, group_ratio, usable_group } = res.data;
  280. if (success) {
  281. setGroupRatio(group_ratio);
  282. setUsableGroup(usable_group);
  283. setSelectedGroup(userState.user ? userState.user.group : 'default');
  284. setModelsFormat(data, group_ratio);
  285. } else {
  286. showError(message);
  287. }
  288. setLoading(false);
  289. };
  290. const refresh = async () => {
  291. await loadPricing();
  292. };
  293. const copyText = async (text) => {
  294. if (await copy(text)) {
  295. showSuccess(t('已复制:') + text);
  296. } else {
  297. Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
  298. }
  299. };
  300. useEffect(() => {
  301. refresh().then();
  302. }, []);
  303. const modelCategories = getModelCategories(t);
  304. const categoryCounts = useMemo(() => {
  305. const counts = {};
  306. if (models.length > 0) {
  307. counts['all'] = models.length;
  308. Object.entries(modelCategories).forEach(([key, category]) => {
  309. if (key !== 'all') {
  310. counts[key] = models.filter(model => category.filter(model)).length;
  311. }
  312. });
  313. }
  314. return counts;
  315. }, [models, modelCategories]);
  316. const renderArrow = (items, pos, handleArrowClick) => {
  317. const style = {
  318. width: 32,
  319. height: 32,
  320. margin: '0 12px',
  321. display: 'flex',
  322. justifyContent: 'center',
  323. alignItems: 'center',
  324. borderRadius: '100%',
  325. background: 'rgba(var(--semi-grey-1), 1)',
  326. color: 'var(--semi-color-text)',
  327. cursor: 'pointer',
  328. };
  329. return (
  330. <Dropdown
  331. render={
  332. <Dropdown.Menu>
  333. {items.map(item => {
  334. const key = item.itemKey;
  335. const modelCount = categoryCounts[key] || 0;
  336. return (
  337. <Dropdown.Item
  338. key={item.itemKey}
  339. onClick={() => setActiveKey(item.itemKey)}
  340. icon={modelCategories[item.itemKey]?.icon}
  341. >
  342. <div className="flex items-center gap-2">
  343. {modelCategories[item.itemKey]?.label || item.itemKey}
  344. <Tag
  345. color={activeKey === item.itemKey ? 'red' : 'grey'}
  346. size='small'
  347. shape='circle'
  348. >
  349. {modelCount}
  350. </Tag>
  351. </div>
  352. </Dropdown.Item>
  353. );
  354. })}
  355. </Dropdown.Menu>
  356. }
  357. >
  358. <div style={style} onClick={handleArrowClick}>
  359. {pos === 'start' ? '←' : '→'}
  360. </div>
  361. </Dropdown>
  362. );
  363. };
  364. // 检查分类是否有对应的模型
  365. const availableCategories = useMemo(() => {
  366. if (!models.length) return ['all'];
  367. return Object.entries(modelCategories).filter(([key, category]) => {
  368. if (key === 'all') return true;
  369. return models.some(model => category.filter(model));
  370. }).map(([key]) => key);
  371. }, [models]);
  372. // 渲染标签页
  373. const renderTabs = () => {
  374. return (
  375. <Tabs
  376. renderArrow={renderArrow}
  377. activeKey={activeKey}
  378. type="card"
  379. collapsible
  380. onChange={key => setActiveKey(key)}
  381. className="mt-2"
  382. >
  383. {Object.entries(modelCategories)
  384. .filter(([key]) => availableCategories.includes(key))
  385. .map(([key, category]) => {
  386. const modelCount = categoryCounts[key] || 0;
  387. return (
  388. <TabPane
  389. tab={
  390. <span className="flex items-center gap-2">
  391. {category.icon && <span className="w-4 h-4">{category.icon}</span>}
  392. {category.label}
  393. <Tag
  394. color={activeKey === key ? 'red' : 'grey'}
  395. size='small'
  396. shape='circle'
  397. >
  398. {modelCount}
  399. </Tag>
  400. </span>
  401. }
  402. itemKey={key}
  403. key={key}
  404. />
  405. );
  406. })}
  407. </Tabs>
  408. );
  409. };
  410. // 优化过滤逻辑
  411. const filteredModels = useMemo(() => {
  412. let result = models;
  413. // 先按分类过滤
  414. if (activeKey !== 'all') {
  415. result = result.filter(model => modelCategories[activeKey].filter(model));
  416. }
  417. // 再按搜索词过滤
  418. if (filteredValue.length > 0) {
  419. const searchTerm = filteredValue[0].toLowerCase();
  420. result = result.filter(model =>
  421. model.model_name.toLowerCase().includes(searchTerm)
  422. );
  423. }
  424. return result;
  425. }, [activeKey, models, filteredValue]);
  426. // 搜索和操作区组件
  427. const SearchAndActions = useMemo(() => (
  428. <Card className="!rounded-xl mb-6" bordered={false}>
  429. <div className="flex flex-wrap items-center gap-4">
  430. <div className="flex-1 min-w-[200px]">
  431. <Input
  432. prefix={<IconSearch />}
  433. placeholder={t('模糊搜索模型名称')}
  434. className="!rounded-lg"
  435. onCompositionStart={handleCompositionStart}
  436. onCompositionEnd={handleCompositionEnd}
  437. onChange={handleChange}
  438. showClear
  439. size="large"
  440. />
  441. </div>
  442. <Button
  443. theme='light'
  444. type='primary'
  445. icon={<IconCopy />}
  446. onClick={() => copyText(selectedRowKeys)}
  447. disabled={selectedRowKeys.length === 0}
  448. className="!rounded-lg !bg-blue-500 hover:!bg-blue-600 text-white"
  449. size="large"
  450. >
  451. {t('复制选中模型')}
  452. </Button>
  453. </div>
  454. </Card>
  455. ), [selectedRowKeys, t]);
  456. // 表格组件
  457. const ModelTable = useMemo(() => (
  458. <Card className="!rounded-xl overflow-hidden" bordered={false}>
  459. <Table
  460. columns={columns}
  461. dataSource={filteredModels}
  462. loading={loading}
  463. rowSelection={rowSelection}
  464. className="custom-table"
  465. empty={
  466. <Empty
  467. image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
  468. darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
  469. description={t('搜索无结果')}
  470. style={{ padding: 30 }}
  471. />
  472. }
  473. pagination={{
  474. defaultPageSize: 10,
  475. pageSize: pageSize,
  476. showSizeChanger: true,
  477. pageSizeOptions: [10, 20, 50, 100],
  478. formatPageText: (page) =>
  479. t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
  480. start: page.currentStart,
  481. end: page.currentEnd,
  482. total: filteredModels.length,
  483. }),
  484. onPageSizeChange: (size) => setPageSize(size),
  485. }}
  486. />
  487. </Card>
  488. ), [filteredModels, loading, columns, rowSelection, pageSize, t]);
  489. return (
  490. <div className="bg-gray-50">
  491. <Layout>
  492. <Layout.Content>
  493. <div className="flex justify-center p-4 sm:p-6 md:p-8">
  494. <div className="w-full">
  495. {/* 主卡片容器 */}
  496. <Card className="!rounded-2xl shadow-lg border-0">
  497. {/* 顶部状态卡片 */}
  498. <Card
  499. className="!rounded-2xl !border-0 !shadow-md overflow-hidden mb-6"
  500. style={{
  501. background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 25%, #a855f7 50%, #c084fc 75%, #d8b4fe 100%)',
  502. position: 'relative'
  503. }}
  504. bodyStyle={{ padding: 0 }}
  505. >
  506. {/* 装饰性背景元素 */}
  507. <div className="absolute inset-0 overflow-hidden">
  508. <div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
  509. <div className="absolute -bottom-16 -left-16 w-48 h-48 bg-white opacity-3 rounded-full"></div>
  510. <div className="absolute top-1/2 right-1/4 w-24 h-24 bg-yellow-400 opacity-10 rounded-full"></div>
  511. </div>
  512. <div className="relative p-6 sm:p-8" style={{ color: 'white' }}>
  513. <div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4 lg:gap-6">
  514. <div className="flex items-start">
  515. <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">
  516. <IconLayers size="extra-large" className="text-white" />
  517. </div>
  518. <div className="flex-1 min-w-0">
  519. <div className="text-base sm:text-lg font-semibold mb-1 sm:mb-2">
  520. {t('模型定价')}
  521. </div>
  522. <div className="text-sm text-white/80">
  523. {userState.user ? (
  524. <div className="flex items-center">
  525. <IconVerify className="mr-1.5 flex-shrink-0" size="small" />
  526. <span className="truncate">
  527. {t('当前分组')}: {userState.user.group},{t('倍率')}: {groupRatio[userState.user.group]}
  528. </span>
  529. </div>
  530. ) : (
  531. <div className="flex items-center">
  532. <AlertCircle size={14} className="mr-1.5 flex-shrink-0" />
  533. <span className="truncate">
  534. {t('未登录,使用默认分组倍率')}: {groupRatio['default']}
  535. </span>
  536. </div>
  537. )}
  538. </div>
  539. </div>
  540. </div>
  541. <div className="grid grid-cols-3 gap-2 sm:gap-3 mt-2 lg:mt-0">
  542. <div
  543. 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"
  544. style={{ backdropFilter: 'blur(10px)' }}
  545. >
  546. <div className="text-xs text-white/70 mb-0.5">{t('分组倍率')}</div>
  547. <div className="text-sm sm:text-base font-semibold">{groupRatio[selectedGroup] || '1.0'}x</div>
  548. </div>
  549. <div
  550. 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"
  551. style={{ backdropFilter: 'blur(10px)' }}
  552. >
  553. <div className="text-xs text-white/70 mb-0.5">{t('可用模型')}</div>
  554. <div className="text-sm sm:text-base font-semibold">
  555. {models.filter(m => m.enable_groups.includes(selectedGroup)).length}
  556. </div>
  557. </div>
  558. <div
  559. 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"
  560. style={{ backdropFilter: 'blur(10px)' }}
  561. >
  562. <div className="text-xs text-white/70 mb-0.5">{t('计费类型')}</div>
  563. <div className="text-sm sm:text-base font-semibold">2</div>
  564. </div>
  565. </div>
  566. </div>
  567. {/* 计费说明 */}
  568. <div className="mt-4 sm:mt-5">
  569. <div className="flex items-start">
  570. <div
  571. 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"
  572. style={{
  573. backgroundColor: 'rgba(255, 255, 255, 0.2)',
  574. color: 'white',
  575. backdropFilter: 'blur(10px)'
  576. }}
  577. >
  578. <IconInfoCircle className="flex-shrink-0 mt-0.5" size="small" />
  579. <span>
  580. {t('按量计费费用 = 分组倍率 × 模型倍率 × (提示token数 + 补全token数 × 补全倍率)/ 500000 (单位:美元)')}
  581. </span>
  582. </div>
  583. </div>
  584. </div>
  585. <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>
  586. </div>
  587. </Card>
  588. {/* 模型分类 Tabs */}
  589. <div className="mb-6">
  590. {renderTabs()}
  591. {/* 搜索和表格区域 */}
  592. {SearchAndActions}
  593. {ModelTable}
  594. </div>
  595. {/* 倍率说明图预览 */}
  596. <ImagePreview
  597. src={modalImageUrl}
  598. visible={isModalOpenurl}
  599. onVisibleChange={(visible) => setIsModalOpenurl(visible)}
  600. />
  601. </Card>
  602. </div>
  603. </div>
  604. </Layout.Content>
  605. </Layout>
  606. </div>
  607. );
  608. };
  609. export default ModelPricing;