ModelPricing.js 22 KB

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