ModelPricing.js 21 KB

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